FEAT: Add better dynamic pages features

This commit is contained in:
Brandon Nguyen
2026-07-16 22:48:21 -07:00
parent f848ab877c
commit 53d08d53a3
7 changed files with 671 additions and 40 deletions

View File

@@ -0,0 +1,24 @@
# Custom landing-page artwork
Add a 16:9 PNG here to make it available in **Admin Dashboard → Dynamic
Pages → Browse game artwork**. A 1920×1080 source is recommended.
Spirit uses the PNG filename as the Unity asset name, normalizes it to lowercase,
and adds `_landingpage` when needed. For example:
```text
summer-event.png → LandingPage/summer_event_landingpage
```
The server compiles every PNG in this folder into
`bundleCache/en_US_LandingPage_Custom` at startup. The dashboard's **Rescan
bundles** button performs the same check, and **Add custom artwork** uploads a
file here and rebuilds the bundle immediately.
Images that are not exactly 16:9 are center-cropped. The compiler then packs the
visible image into the original game's 2048×2048 LandingPage texture layout, so
the browser preview and Unity client use the same crop.
The first build needs one original `en_US_LandingPage_*` bundle as a template.
Place an original cache under `original_game_cache`, configure `PTCGO_CACHE_DIR`,
or install game artwork through the dashboard first.

View File

@@ -151,6 +151,7 @@
.asset-selected-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.asset-controls { min-width: 0; }
.asset-controls input { width: 100%; margin-bottom: 7px; }
.asset-action-row { display: flex; flex-wrap: wrap; gap: 7px; }
.asset-status { margin-top: 5px; min-height: 18px; }
.page-form-actions { display: flex; gap: 8px; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--line); }
.page-form-actions .save-state { flex: 1; font-size: 11px; color: var(--muted); }
@@ -215,20 +216,33 @@
#pageAssetOverlay { position: fixed; inset: 0; z-index: 35; display: none; align-items: center; justify-content: center; padding: 3vh 3vw; background: rgba(4,8,14,.82); }
#pageAssetOverlay.open { display: flex; }
.page-asset-picker { width: min(1120px,95vw); max-height: 92vh; display: flex; flex-direction: column; border: 1px solid #435778; border-radius: 12px; overflow: hidden; background: var(--panel); box-shadow: 0 20px 60px rgba(0,0,0,.55); }
.page-asset-picker { width: min(1120px,95vw); height: min(900px,92vh); min-height: 520px; display: flex; flex-direction: column; border: 1px solid #435778; border-radius: 12px; overflow: hidden; background: var(--panel); box-shadow: 0 20px 60px rgba(0,0,0,.55); }
.asset-picker-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
.asset-picker-head > div:first-child { flex: 1; }
.asset-picker-head h3 { color: var(--accent); font-size: 15px; }
details.custom-artwork-panel { flex: 0 0 auto; border-bottom: 1px solid var(--line); background: rgba(61,125,202,.06); }
details.custom-artwork-panel > summary { cursor: pointer; padding: 10px 16px; color: #bcd9f3; font-size: 12px; font-weight: 650; }
.custom-artwork-body { display: grid; grid-template-columns: 180px minmax(0,1fr); gap: 13px; padding: 2px 16px 14px; }
.custom-artwork-preview { aspect-ratio: 16/9; border: 1px dashed #4d6788; border-radius: 7px; overflow: hidden; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 10px; background: #07111e; }
.custom-artwork-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.custom-artwork-fields { min-width: 0; }
.custom-artwork-fields input[type="file"] { width: 100%; margin-bottom: 8px; }
.custom-upload-row { display: grid; grid-template-columns: minmax(180px,1fr) auto auto; align-items: center; gap: 8px; }
.custom-upload-row input[type="text"] { width: 100%; }
.custom-replace { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; white-space: nowrap; }
.custom-upload-help { margin-top: 7px; color: var(--muted); font-size: 10px; }
.custom-upload-status { min-height: 17px; margin-top: 5px; font-size: 10px; color: #9ec8ec; }
.asset-picker-tools { display: grid; grid-template-columns: minmax(220px,1fr) auto; gap: 10px; padding: 12px 16px; }
.asset-picker-tools input { width: 100%; }
.landing-asset-grid { overflow-y: auto; padding: 0 16px 16px; display: grid; grid-template-columns: repeat(auto-fill,minmax(190px,1fr)); gap: 10px; }
.landing-asset { border: 2px solid transparent; border-radius: 9px; background: var(--panel2); overflow: hidden; cursor: pointer; color: var(--text); text-align: left; }
.landing-asset-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 16px 16px; display: flex; flex-direction: column; gap: 12px; scrollbar-gutter: stable; }
.landing-asset { flex: 0 0 auto; display: grid; grid-template-columns: minmax(340px,48%) minmax(0,1fr); align-items: stretch; border: 2px solid transparent; border-radius: 9px; background: var(--panel2); overflow: hidden; cursor: pointer; color: var(--text); text-align: left; padding: 0; }
.landing-asset:hover { border-color: var(--accent2); }
.landing-asset.sel { border-color: var(--accent); }
.landing-asset img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #07111e; }
.landing-asset-info { padding: 7px 8px; }
.landing-asset-name { font-size: 11px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.landing-asset-sub { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 9px; margin-top: 2px; }
.landing-asset img { width: 100%; height: auto; aspect-ratio: 16/9; object-fit: cover; display: block; background: #07111e; }
.landing-asset-info { min-width: 0; padding: 14px 16px; display: flex; flex-direction: column; justify-content: center; }
.landing-asset-name { display: block; font-size: 14px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.landing-asset-path { display: block; color: #9ec8ec; font-size: 10px; margin: 5px 0 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.landing-asset-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; }
.asset-source-summary { color: var(--muted); font-size: 10px; padding: 0 16px 10px; }
@media (max-width: 980px) {
@@ -241,6 +255,10 @@
.asset-selection { grid-template-columns: 1fr; }
.asset-selected-thumb { max-width: 220px; }
.template-grid { grid-template-columns: 1fr; }
.page-asset-picker { min-height: 0; }
.custom-artwork-body, .custom-upload-row { grid-template-columns: 1fr; }
.custom-artwork-preview { max-width: 240px; }
.landing-asset { grid-template-columns: 1fr; }
}
</style>
</head>
@@ -460,7 +478,7 @@
</div>
<input type="hidden" id="pageId">
<input type="hidden" id="pageTemplate" value="LandingPageRight">
<input type="hidden" id="pageTemplate" value="LandingPageLeft">
<div class="page-meta-grid">
<div><label>Page type</label>
<select id="pageType" onchange="pageTypeChanged()">
@@ -482,7 +500,10 @@
<div class="asset-controls">
<label>Client asset path</label>
<input id="pageAssetPath" placeholder="LandingPage/asset_name" oninput="pageAssetPathChanged()">
<button class="btn ghost" onclick="openPageAssetPicker()">Browse game artwork...</button>
<div class="asset-action-row">
<button class="btn ghost" onclick="openPageAssetPicker()">Browse game artwork...</button>
<button class="btn ghost" onclick="openCustomPageArtwork()">+ Add custom artwork</button>
</div>
<div class="asset-status" id="pageAssetStatus"></div>
</div>
</div>
@@ -646,9 +667,25 @@
<button class="mini" onclick="refreshPageAssets()">Rescan bundles</button>
<button class="mini" onclick="closePageAssetPicker()">Close</button>
</div>
<details class="custom-artwork-panel" id="pageCustomArtworkPanel">
<summary>+ Add your own landing-page artwork</summary>
<div class="custom-artwork-body">
<div class="custom-artwork-preview" id="pageCustomPreview"><span>Choose an image</span></div>
<div class="custom-artwork-fields">
<input id="pageCustomFile" type="file" accept="image/png,image/jpeg,image/webp" onchange="customPageFileChanged()">
<div class="custom-upload-row">
<input id="pageCustomName" type="text" maxlength="96" placeholder="summer_event" aria-label="Unity asset name" oninput="this.dataset.auto='false'">
<label class="custom-replace"><input id="pageCustomReplace" type="checkbox"> Replace existing</label>
<button class="btn" id="pageCustomUploadButton" onclick="uploadCustomPageArtwork()">Build &amp; add</button>
</div>
<div class="custom-upload-help">Use a 16:9 image (1920×1080 recommended). Spirit center-crops it, packs it into the native LandingPage texture layout, and builds a Unity bundle for players.</div>
<div class="custom-upload-status" id="pageCustomStatus"></div>
</div>
</div>
</details>
<div class="asset-picker-tools">
<input id="pageAssetSearch" placeholder="Search artwork (set, Pokemon, ladder...)" oninput="renderPageAssetGrid()">
<select id="pageAssetFilter" onchange="renderPageAssetGrid()"><option value="all">All artwork</option><option value="installed">Ready for players</option><option value="source">Needs install</option></select>
<select id="pageAssetFilter" onchange="renderPageAssetGrid()"><option value="all">All artwork</option><option value="custom">My custom artwork</option><option value="installed">Ready for players</option><option value="source">Needs install</option></select>
</div>
<div class="asset-source-summary" id="pageAssetSourceSummary">Looking for LandingPage bundles...</div>
<div class="landing-asset-grid" id="pageAssetGrid"></div>
@@ -919,10 +956,11 @@ async function deleteShopItem(id) {
/* ---------------- Pages ---------------- */
const PAGE_FOREVER = 4102444800000;
const PAGE_TEMPLATES = [
{id:'LandingPageRight', label:'Artwork on right', sub:'Message and CTA on the left', visual:'visual-right', copy:'left', buttons:true},
{id:'LandingPageLeft', label:'Artwork on left', sub:'Message and CTA on the right', visual:'visual-left', copy:'right', buttons:true},
{id:'LandingPageRightNoButtons', label:'Artwork right, no CTA', sub:'Message on the left', visual:'visual-right no-buttons', copy:'left', buttons:false},
{id:'LandingPageLeftNoButtons', label:'Artwork left, no CTA', sub:'Message on the right', visual:'visual-left no-buttons', copy:'right', buttons:false}
// Unity names these prefabs for the TEXT side, not the artwork side.
{id:'LandingPageLeft', label:'Artwork on right', sub:'Message and CTA on the left', visual:'visual-right', copy:'left', buttons:true},
{id:'LandingPageRight', label:'Artwork on left', sub:'Message and CTA on the right', visual:'visual-left', copy:'right', buttons:true},
{id:'LandingPageLeftNoButtons', label:'Artwork right, no CTA', sub:'Message on the left', visual:'visual-right no-buttons', copy:'left', buttons:false},
{id:'LandingPageRightNoButtons', label:'Artwork left, no CTA', sub:'Message on the right', visual:'visual-left no-buttons', copy:'right', buttons:false}
];
const PAGE_CTA_LABELS = {
shop:'VISIT SHOP', redeem:'REDEEM CODE', tournament:'VIEW EVENTS', trade:'OPEN TRADE', url:'LEARN MORE'
@@ -934,6 +972,7 @@ let PAGE_ASSETS_LOADED = false;
let PAGE_ASSET_REVISION = 0;
let PAGE_BASE_CONTENT = {};
let PAGE_EDITOR_INITIALIZED = false;
let PAGE_CUSTOM_PREVIEW_URL = '';
const cloneJson = value => JSON.parse(JSON.stringify(value || {}));
const pageAssetName = path => String(path || '').trim().replaceAll('\\', '/').split('/').pop();
@@ -954,7 +993,7 @@ function defaultDynamicContent(type='landing') {
};
}
return {
template: 'LandingPageRight', sortOrder: nextPageOrder(), startTime: 0, endTime: PAGE_FOREVER,
template: 'LandingPageLeft', sortOrder: nextPageOrder(), startTime: 0, endTime: PAGE_FOREVER,
labels: {GameText: {token: 'spirit.dynamic_page.game_text', bundle: {en_US: ''}}},
images: {}, actions: {}
};
@@ -1007,7 +1046,7 @@ function hydratePageEditor(page) {
$('#pageSort').value = page.sort_order ?? content.sortOrder ?? nextPageOrder();
$('#pageEnabled').checked = page.enabled !== false;
const template = PAGE_TEMPLATES.some(t => t.id === content.template) ? content.template : 'LandingPageRight';
const template = PAGE_TEMPLATES.some(t => t.id === content.template) ? content.template : 'LandingPageLeft';
$('#pageTemplate').value = template;
$('#pageAssetPath').value = backgroundPath(content);
$('#pageMessage').value = labelText(content, 'GameText');
@@ -1052,7 +1091,7 @@ function selectPageTemplate(template) {
function pageTypeChanged() {
const maintenance = $('#pageType').value === 'maintenance';
if (!maintenance && !PAGE_TEMPLATES.some(t => t.id === $('#pageTemplate').value)) {
$('#pageTemplate').value = 'LandingPageRight';
$('#pageTemplate').value = 'LandingPageLeft';
}
if (maintenance && !$('#pageStart').value && !$('#pageEnd').value) {
const start = Math.floor(Date.now() / 60000) * 60000;
@@ -1347,6 +1386,8 @@ function renderSelectedPageAsset() {
}
if (!meta) {
$('#pageAssetStatus').innerHTML = `<span class="pill tag">Custom path</span> <span class="muted">${esc(name)}</span>`;
} else if (meta.custom) {
$('#pageAssetStatus').innerHTML = `<span class="pill tag">Custom · ready for players</span> <span class="muted">${esc(meta.title)}</span>`;
} else if (meta.installed) {
$('#pageAssetStatus').innerHTML = `<span class="pill on">Ready for players</span> <span class="muted">${esc(meta.title)}</span>`;
} else {
@@ -1404,30 +1445,36 @@ async function loadPageAssets(force=false) {
try {
const d = await api(force ? 'pages/assets/refresh' : 'pages/assets', force ? {} : undefined);
if (force) PAGE_ASSET_REVISION += 1;
PAGE_ASSETS = d.assets || [];
PAGE_ASSET_BY_NAME = Object.fromEntries(PAGE_ASSETS.map(a => [a.name.toLowerCase(), a]));
PAGE_ASSETS_LOADED = true;
const installed = PAGE_ASSETS.filter(a => a.installed).length;
const sourceText = (d.sources || []).map(s => `${s.textures} from ${s.source}`).join(' + ');
const errorText = d.errors?.length
? ` ${d.errors.length} bundle${d.errors.length === 1 ? '' : 's'} could not be read.`
: '';
$('#pageAssetSourceSummary').textContent = PAGE_ASSETS.length
? `${PAGE_ASSETS.length} artworks found (${installed} already server-ready). ${sourceText}.${errorText}`
: `No LandingPage bundles were found. Set PTCGO_CACHE_DIR or place the original cache in this repository.${errorText}`;
renderPageAssetGrid();
renderSelectedPageAsset();
renderPageLibrary();
if (PAGE_EDITOR_INITIALIZED) pageEditorChanged();
applyPageAssetCatalog(d);
} catch(e) {
$('#pageAssetSourceSummary').textContent = 'Could not scan landing-page bundles: ' + e.message;
}
}
function applyPageAssetCatalog(d) {
PAGE_ASSETS = d.assets || [];
PAGE_ASSET_BY_NAME = Object.fromEntries(PAGE_ASSETS.map(a => [a.name.toLowerCase(), a]));
PAGE_ASSETS_LOADED = true;
const installed = PAGE_ASSETS.filter(a => a.installed).length;
const custom = PAGE_ASSETS.filter(a => a.custom).length;
const sourceText = (d.sources || []).map(s => `${s.textures} from ${s.source}`).join(' + ');
const errorText = d.errors?.length
? ` ${d.errors.length} bundle${d.errors.length === 1 ? '' : 's'} could not be read.`
: '';
const customText = custom ? ` ${custom} custom.` : '';
$('#pageAssetSourceSummary').textContent = PAGE_ASSETS.length
? `${PAGE_ASSETS.length} artworks found (${installed} server-ready).${customText} ${sourceText}.${errorText}`
: `No LandingPage bundles were found. Set PTCGO_CACHE_DIR or place the original cache in this repository.${errorText}`;
renderPageAssetGrid();
renderSelectedPageAsset();
renderPageLibrary();
if (PAGE_EDITOR_INITIALIZED) pageEditorChanged();
}
async function refreshPageAssets() {
PAGE_ASSETS_LOADED = false;
await loadPageAssets(true);
toast(`Found ${PAGE_ASSETS.length} landing-page artworks`);
toast(`Found ${PAGE_ASSETS.length} landing-page artworks, including ${PAGE_ASSETS.filter(a => a.custom).length} custom`);
}
function openPageAssetPicker() {
@@ -1439,6 +1486,90 @@ function openPageAssetPicker() {
}
function closePageAssetPicker() { $('#pageAssetOverlay').classList.remove('open'); }
function openCustomPageArtwork() {
openPageAssetPicker();
$('#pageCustomArtworkPanel').open = true;
setTimeout(() => $('#pageCustomFile').focus(), 60);
}
function customPageAssetName(filename) {
let name = String(filename || '').replace(/\.[^.]+$/, '');
name = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
name = name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
if (name && !name.endsWith('_landingpage')) name += '_landingpage';
return name;
}
function customPageFileChanged() {
const file = $('#pageCustomFile').files?.[0];
const preview = $('#pageCustomPreview');
$('#pageCustomStatus').textContent = '';
if (PAGE_CUSTOM_PREVIEW_URL) URL.revokeObjectURL(PAGE_CUSTOM_PREVIEW_URL);
PAGE_CUSTOM_PREVIEW_URL = '';
if (!file) {
preview.innerHTML = '<span>Choose an image</span>';
return;
}
const nameInput = $('#pageCustomName');
if (!nameInput.value.trim() || nameInput.dataset.auto !== 'false') {
nameInput.value = customPageAssetName(file.name);
nameInput.dataset.auto = 'true';
}
PAGE_CUSTOM_PREVIEW_URL = URL.createObjectURL(file);
preview.innerHTML = `<img src="${PAGE_CUSTOM_PREVIEW_URL}" alt="Custom artwork preview">`;
$('#pageCustomStatus').textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`;
}
function readPageImageDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The browser could not read that image'));
reader.readAsDataURL(file);
});
}
async function uploadCustomPageArtwork() {
const file = $('#pageCustomFile').files?.[0];
const name = $('#pageCustomName').value.trim();
const status = $('#pageCustomStatus');
if (!file) return toast('Choose an artwork image first', true);
if (!name) return toast('Give the custom artwork an asset name', true);
if (file.size > 12 * 1024 * 1024) return toast('Custom artwork must be 12 MB or smaller', true);
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return toast('Use a PNG, JPEG, or WebP image', true);
const button = $('#pageCustomUploadButton');
button.disabled = true;
button.textContent = 'Building Unity bundle...';
status.textContent = 'Uploading and packing the native 2048×2048 LandingPage texture...';
try {
const d = await api('pages/assets/custom', {
name,
image:await readPageImageDataUrl(file),
replace:$('#pageCustomReplace').checked
});
PAGE_ASSET_REVISION += 1;
applyPageAssetCatalog(d);
const imported = d.asset?.catalog_asset;
if (!imported) throw new Error('The bundle built, but the new artwork was not cataloged');
$('#pageCustomFile').value = '';
$('#pageCustomName').value = '';
$('#pageCustomName').dataset.auto = 'true';
$('#pageCustomReplace').checked = false;
if (PAGE_CUSTOM_PREVIEW_URL) URL.revokeObjectURL(PAGE_CUSTOM_PREVIEW_URL);
PAGE_CUSTOM_PREVIEW_URL = '';
$('#pageCustomPreview').innerHTML = '<span>Choose an image</span>';
selectPageAsset(imported.name);
toast(`${imported.title} is built, selected, and ready for players`);
} catch(e) {
status.textContent = e.message;
toast(e.message, true);
} finally {
button.disabled = false;
button.textContent = 'Build & add';
}
}
function renderPageAssetGrid() {
const grid = $('#pageAssetGrid');
if (!grid) return;
@@ -1446,15 +1577,17 @@ function renderPageAssetGrid() {
const filter = $('#pageAssetFilter')?.value || 'all';
const selected = pageAssetName($('#pageAssetPath')?.value || '').toLowerCase();
const matches = PAGE_ASSETS.filter(a => {
if (filter === 'custom' && !a.custom) return false;
if (filter === 'installed' && !a.installed) return false;
if (filter === 'source' && a.installed) return false;
return !query || `${a.title} ${a.name} ${a.bundle}`.toLowerCase().includes(query);
});
grid.innerHTML = matches.map(a => `
<button class="landing-asset ${a.name.toLowerCase() === selected ? 'sel' : ''}" data-asset="${esc(a.name)}" onclick="selectPageAsset(this.dataset.asset)">
<img src="${pageAssetUrl(a.name, 'thumb')}" loading="lazy" alt="" onerror="this.style.visibility='hidden'">
<img src="${pageAssetUrl(a.name, 'thumb')}" loading="lazy" decoding="async" alt="Preview of ${esc(a.title)}" onerror="this.style.visibility='hidden'">
<span class="landing-asset-info"><span class="landing-asset-name">${esc(a.title)}</span>
<span class="landing-asset-sub"><span class="pill ${a.installed ? 'on' : 'tag'}">${a.installed ? 'ready' : 'installs on save'}</span><span>${esc(a.bundle)}</span></span>
<span class="landing-asset-path">${esc(a.request_path)}</span>
<span class="landing-asset-sub"><span class="pill ${a.custom ? 'tag' : (a.installed ? 'on' : 'tag')}">${a.custom ? 'custom' : (a.installed ? 'ready' : 'installs on save')}</span><span>${a.width}×${a.height}</span><span>${esc(a.bundle)}</span></span>
</span>
</button>`).join('') || '<div class="empty-page-library">No artwork matches this filter.</div>';
}

View File

@@ -1,9 +1,12 @@
import base64
import binascii
import json
import logging
import os
import re
from urllib.parse import unquote
from urllib.parse import unquote
from spirit.server import http_server
from spirit.database import db_session, Account, TradeOffer
from spirit.database import economy_data
from spirit.game import season_manager
@@ -32,6 +35,27 @@ def _err(status, message):
return _json(status, {"ok": False, "error": message})
def _decode_image_data_url(value):
if not isinstance(value, str):
raise ValueError("image data is required")
header, separator, encoded = value.partition(',')
allowed = {
'data:image/png;base64',
'data:image/jpeg;base64',
'data:image/jpg;base64',
'data:image/webp;base64',
}
if separator != ',' or header.lower() not in allowed:
raise ValueError("upload a PNG, JPEG, or WebP image")
max_encoded = ((dynamic_pages.MAX_CUSTOM_IMAGE_BYTES + 2) // 3) * 4
if len(encoded) > max_encoded:
raise ValueError("custom artwork must be 12 MB or smaller")
try:
return base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("image upload is not valid base64 data") from exc
def _products_summary():
from spirit.game.scripts.products import loader as product_loader
if not product_loader.products:
@@ -493,7 +517,38 @@ def _dispatch(method, endpoint, data):
if method == 'POST' and endpoint == 'pages/assets/refresh':
dynamic_pages.invalidate_asset_catalog()
return _ok(dynamic_pages.asset_catalog_payload())
try:
build = dynamic_pages.compile_custom_landing_bundle(force=True)
except dynamic_pages.PageValidationError as exc:
return _err(400, str(exc))
dynamic_pages.invalidate_asset_catalog()
if build.get("built"):
http_server.register_asset_path(dynamic_pages.BUNDLE_CACHE_DIR)
return _ok({**dynamic_pages.asset_catalog_payload(), "custom_build": build})
if method == 'POST' and endpoint == 'pages/assets/custom':
try:
image_bytes = _decode_image_data_url(data.get("image"))
imported = dynamic_pages.save_custom_landing_image(
data.get("name", ""),
image_bytes,
replace=bool(data.get("replace", False)),
)
except (ValueError, dynamic_pages.PageValidationError) as exc:
return _err(400, str(exc))
from spirit.server import http_server
http_server.register_asset_path(dynamic_pages.BUNDLE_CACHE_DIR)
catalog = dynamic_pages.asset_catalog_payload()
imported["catalog_asset"] = next(
(
asset
for asset in catalog["assets"]
if asset["name"].casefold() == imported["name"].casefold()
),
None,
)
return _ok({"asset": imported, **catalog})
if method == 'GET' and endpoint.startswith('pages/assets/thumb/'):
asset_name = unquote(endpoint[len('pages/assets/thumb/'):])

View File

@@ -10,6 +10,7 @@ from PIL import Image, ImageFilter
from spirit.game.attributes import AttrID, TrainerType
from spirit.game.scripts.cards import loader
from spirit.server.auto_bundle_cosmetics import compile_all_cosmetics
from spirit.server import dynamic_pages
ASSETS_DIR = "spirit/assets"
BUNDLE_CACHE_DIR = os.path.join(ASSETS_DIR, "bundleCache")
@@ -223,6 +224,12 @@ def check_and_generate_bundles() -> int:
except Exception as e:
logging.error(f"[AutoBundle] Failed to compile cosmetics: {e}")
logging.info("[AutoBundle] Checking custom landing-page artwork...")
try:
dynamic_pages.compile_custom_landing_bundle()
except Exception as e:
logging.error(f"[AutoBundle] Failed to compile custom landing pages: {e}")
logging.info("[AutoBundle] Checking for missing card AssetBundles...")
if not os.path.exists(DEFAULT_TEMPLATE):

View File

@@ -14,16 +14,19 @@ players can load the same art that an admin selected in the preview.
from __future__ import annotations
import copy
import hashlib
import io
import logging
import os
import re
import shutil
import threading
import unicodedata
from collections import OrderedDict
from pathlib import Path
import UnityPy
from PIL import Image
from PIL import Image, ImageOps
LANDING_TEMPLATES = {
@@ -49,6 +52,14 @@ _PROJECT_DIR = _SPIRIT_DIR.parent
BUNDLE_CACHE_DIR = _SPIRIT_DIR / "assets" / "bundleCache"
EXTERNAL_CACHE_DIR = _SPIRIT_DIR / "assets" / "externalCache"
ORIGINAL_CACHE_DIR = _PROJECT_DIR / "original_game_cache"
CUSTOM_IMAGE_DIR = _SPIRIT_DIR / "assets" / "landing_pages"
CUSTOM_BUNDLE_NAME = "en_US_LandingPage_Custom"
CUSTOM_BUNDLE_DIR = BUNDLE_CACHE_DIR / CUSTOM_BUNDLE_NAME
CUSTOM_BUNDLE_DATA_PATH = (
CUSTOM_BUNDLE_DIR / "00000000000000000000000001000000" / "__data"
)
MAX_CUSTOM_IMAGE_BYTES = 12 * 1024 * 1024
MAX_CUSTOM_IMAGE_PIXELS = 40_000_000
_catalog_lock = threading.RLock()
_catalog: dict[str, dict] | None = None
@@ -59,6 +70,8 @@ _image_lock = threading.RLock()
_image_cache: OrderedDict[tuple, bytes] = OrderedDict()
_IMAGE_CACHE_MAX = 128
_custom_bundle_lock = threading.RLock()
class PageValidationError(ValueError):
"""Raised when a page would violate the Unity client's wire contract."""
@@ -150,6 +163,29 @@ def _asset_rank(asset: dict) -> tuple:
)
def _bundle_texture_path_ids(env) -> set[int] | None:
"""Returns Texture2D PathIDs exported by an AssetBundle container.
UnityPy exposes orphaned serialized objects too. Custom bundles are rebuilt
from a real landing bundle and may retain unused prototype objects, so only
container-exported textures belong in the dashboard/manifest catalog.
Environments without an AssetBundle object (including loose test fixtures)
intentionally return ``None`` and keep the historical scan behavior.
"""
for obj in env.objects:
if obj.type.name != "AssetBundle":
continue
try:
bundle = obj.read()
return {
int(info.asset.m_PathID)
for _, info in getattr(bundle, "m_Container", [])
}
except Exception:
return None
return None
def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]:
assets: dict[str, dict] = {}
roots_public: list[dict] = []
@@ -180,9 +216,15 @@ def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]:
try:
stat = data_path.stat()
env = UnityPy.load(str(data_path))
exported_texture_ids = _bundle_texture_path_ids(env)
for obj in env.objects:
if obj.type.name != "Texture2D":
continue
if (
exported_texture_ids is not None
and int(obj.path_id) not in exported_texture_ids
):
continue
texture = obj.read()
name = str(getattr(texture, "m_Name", "") or "").strip()
if not name:
@@ -197,6 +239,8 @@ def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]:
"width": int(getattr(texture, "m_Width", 0) or 0),
"height": int(getattr(texture, "m_Height", 0) or 0),
"installed": bool(installed),
"custom": bundle_dir.name.casefold()
== CUSTOM_BUNDLE_NAME.casefold(),
"source": source,
"data_path": str(data_path),
"bundle_dir": str(bundle_dir),
@@ -253,7 +297,14 @@ def asset_catalog_payload() -> dict:
roots = copy.deepcopy(_catalog_roots)
errors = list(_catalog_errors)
public_assets = []
for item in sorted(catalog.values(), key=lambda a: (a["title"].casefold(), a["name"].casefold())):
for item in sorted(
catalog.values(),
key=lambda a: (
not bool(a.get("custom")),
a["title"].casefold(),
a["name"].casefold(),
),
):
public_assets.append(
{
key: item[key]
@@ -265,6 +316,7 @@ def asset_catalog_payload() -> dict:
"width",
"height",
"installed",
"custom",
"source",
)
}
@@ -344,6 +396,283 @@ def render_asset_jpeg(name_or_path: str, variant: str = "preview") -> bytes | No
return rendered
def normalize_custom_asset_name(value: str) -> str:
"""Turns an upload/file name into a collision-resistant Unity asset name."""
text = Path(str(value or "").strip().replace("\\", "/")).name
text = Path(text).stem
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
text = re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()
text = re.sub(r"_+", "_", text)
if not text:
raise PageValidationError("Custom artwork needs a file-safe asset name")
suffix = "_landingpage"
if not text.endswith(suffix):
text += suffix
if len(text) > 96:
text = text[: 96 - len(suffix)].rstrip("_") + suffix
return text
def _custom_source_images() -> list[tuple[str, Path]]:
if not CUSTOM_IMAGE_DIR.is_dir():
return []
sources: dict[str, Path] = {}
paths = (
path
for path in CUSTOM_IMAGE_DIR.iterdir()
if path.is_file() and path.suffix.casefold() == ".png"
)
for path in sorted(paths, key=lambda item: item.name.casefold()):
asset_name = normalize_custom_asset_name(path.name)
previous = sources.get(asset_name)
if previous is not None:
raise PageValidationError(
f"{previous.name} and {path.name} normalize to the same landing asset name"
)
sources[asset_name] = path
return list(sources.items())
def _prepare_custom_texture(image: Image.Image, size: tuple[int, int]) -> Image.Image:
"""Packs a normal 16:9 image into the square texture sampled by the prefab."""
width, height = size
x, y, crop_w, crop_h = _LANDING_UV_RECT
left = max(0, round(width * x))
top = max(0, round(height * y))
right = min(width, round(width * (x + crop_w)))
bottom = min(height, round(height * (y + crop_h)))
visible_size = (max(1, right - left), max(1, bottom - top))
artwork = ImageOps.fit(
ImageOps.exif_transpose(image).convert("RGBA"),
visible_size,
method=Image.Resampling.LANCZOS,
centering=(0.5, 0.5),
)
texture = Image.new("RGBA", size, (0, 0, 0, 255))
texture.paste(artwork, (left, top))
return texture
def _custom_bundle_template_path() -> Path:
catalog = _get_catalog()
candidates = [
Path(item["data_path"])
for item in catalog.values()
if item.get("bundle", "").casefold() != CUSTOM_BUNDLE_NAME.casefold()
]
if candidates:
return sorted(candidates, key=lambda path: str(path).casefold())[0]
if CUSTOM_BUNDLE_DATA_PATH.is_file():
return CUSTOM_BUNDLE_DATA_PATH
raise PageValidationError(
"A real LandingPage Unity bundle is required as the custom-art template. "
"Set PTCGO_CACHE_DIR or add the original game cache, then rescan."
)
def _rename_custom_bundle_cab(env) -> None:
new_cab = f"CAB-{hashlib.md5(CUSTOM_BUNDLE_NAME.encode('utf-8')).hexdigest()}"
bundle_file = env.file
files = getattr(bundle_file, "files", None)
if isinstance(files, dict):
old_keys = [key for key in list(files) if str(key).startswith("CAB-")]
if old_keys:
serialized_file = files.pop(old_keys[0])
for duplicate in old_keys[1:]:
files.pop(duplicate, None)
files[new_cab] = serialized_file
for asset in env.assets:
if hasattr(asset, "name"):
asset.name = new_cab
def compile_custom_landing_bundle(force: bool = False) -> dict:
"""Builds all ``assets/landing_pages/*.png`` files into one Unity bundle.
This mirrors the custom-card workflow: source PNGs remain easy to edit and
the ignored bundleCache output is regenerated on startup or from the admin
artwork rescan button.
"""
with _custom_bundle_lock:
sources = _custom_source_images()
if not sources:
return {
"built": False,
"assets": 0,
"bundle": CUSTOM_BUNDLE_NAME,
}
newest_source = max(
[path.stat().st_mtime_ns for _, path in sources]
+ [Path(__file__).stat().st_mtime_ns]
)
if (
not force
and CUSTOM_BUNDLE_DATA_PATH.is_file()
and CUSTOM_BUNDLE_DATA_PATH.stat().st_mtime_ns >= newest_source
):
return {
"built": False,
"assets": len(sources),
"bundle": CUSTOM_BUNDLE_NAME,
}
template_path = _custom_bundle_template_path()
env = UnityPy.load(str(template_path))
serialized_asset = None
asset_bundle_obj = None
texture_objects = []
for asset in env.assets:
objects = list(asset.objects.values())
bundle_obj = next(
(obj for obj in objects if obj.type.name == "AssetBundle"), None
)
textures = [obj for obj in objects if obj.type.name == "Texture2D"]
if bundle_obj is not None and textures:
serialized_asset = asset
asset_bundle_obj = bundle_obj
texture_objects = textures
break
if serialized_asset is None or asset_bundle_obj is None or not texture_objects:
raise PageValidationError("LandingPage template has no editable Texture2D assets")
asset_bundle = asset_bundle_obj.read()
prototype = texture_objects[0]
prototype_info = next(
(
info
for _, info in asset_bundle.m_Container
if info.asset.m_PathID == prototype.path_id
),
None,
)
if prototype_info is None:
raise PageValidationError("LandingPage template has no texture container mapping")
# Reuse existing texture slots first. Clone the prototype only when the
# custom collection is larger than its source template.
target_objects = texture_objects[: len(sources)]
next_path_id = max(serialized_asset.objects) + 1
while len(target_objects) < len(sources):
cloned = copy.copy(prototype)
cloned.path_id = next_path_id
serialized_asset.objects[next_path_id] = cloned
target_objects.append(cloned)
next_path_id += 1
new_mappings = []
for (asset_name, source_path), target_obj in zip(sources, target_objects):
texture = target_obj.read()
with Image.open(source_path) as source_image:
texture.image = _prepare_custom_texture(
source_image,
(int(texture.m_Width), int(texture.m_Height)),
)
texture.m_Name = asset_name
target_obj.save_typetree(texture)
mapping = copy.copy(prototype_info)
mapping.asset = copy.copy(prototype_info.asset)
mapping.asset.m_PathID = target_obj.path_id
new_mappings.append((asset_name, mapping))
asset_bundle.m_Container = new_mappings
asset_bundle.m_Name = CUSTOM_BUNDLE_NAME
asset_bundle_obj.save_typetree(asset_bundle)
used_path_ids = {obj.path_id for obj in target_objects}
for obj in texture_objects:
if obj.path_id not in used_path_ids:
serialized_asset.objects.pop(obj.path_id, None)
_rename_custom_bundle_cab(env)
CUSTOM_BUNDLE_DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
temp_path = CUSTOM_BUNDLE_DATA_PATH.with_name(
f"{CUSTOM_BUNDLE_DATA_PATH.name}.{threading.get_ident()}.tmp"
)
try:
temp_path.write_bytes(env.file.save(packer="lz4"))
os.replace(temp_path, CUSTOM_BUNDLE_DATA_PATH)
finally:
temp_path.unlink(missing_ok=True)
invalidate_asset_catalog()
logging.info(
"[Admin] Built %s with %d custom landing artworks",
CUSTOM_BUNDLE_NAME,
len(sources),
)
return {
"built": True,
"assets": len(sources),
"bundle": CUSTOM_BUNDLE_NAME,
}
def save_custom_landing_image(
name: str,
payload: bytes,
*,
replace: bool = False,
) -> dict:
"""Validates, stores, and bundles an admin-uploaded landing-page image."""
if not isinstance(payload, bytes) or not payload:
raise PageValidationError("Choose a PNG, JPEG, or WebP image to upload")
if len(payload) > MAX_CUSTOM_IMAGE_BYTES:
raise PageValidationError("Custom artwork must be 12 MB or smaller")
asset_name = normalize_custom_asset_name(name)
existing_catalog_asset = _get_catalog().get(asset_name.casefold())
if existing_catalog_asset and not existing_catalog_asset.get("custom"):
raise PageValidationError(
f"{asset_name} conflicts with original game artwork; choose another name"
)
try:
with Image.open(io.BytesIO(payload)) as uploaded:
width, height = uploaded.size
if width * height > MAX_CUSTOM_IMAGE_PIXELS:
raise PageValidationError("Custom artwork may not exceed 40 megapixels")
if width < 320 or height < 180:
raise PageValidationError("Custom artwork must be at least 320x180 pixels")
if uploaded.format not in {"PNG", "JPEG", "WEBP"}:
raise PageValidationError("Custom artwork must be PNG, JPEG, or WebP")
uploaded.load()
normalized_image = ImageOps.exif_transpose(uploaded).convert("RGBA")
except PageValidationError:
raise
except Exception as exc:
raise PageValidationError("The uploaded file is not a readable image") from exc
with _custom_bundle_lock:
CUSTOM_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
target = CUSTOM_IMAGE_DIR / f"{asset_name}.png"
if target.exists() and not replace:
raise PageValidationError(
f"{asset_name} already exists; enable Replace existing artwork to update it"
)
temp_path = target.with_name(f".{target.name}.{threading.get_ident()}.tmp")
try:
normalized_image.save(temp_path, format="PNG", optimize=True)
os.replace(temp_path, target)
finally:
temp_path.unlink(missing_ok=True)
build = compile_custom_landing_bundle(force=True)
asset = _get_catalog().get(asset_name.casefold())
if asset is None:
raise PageValidationError("Custom artwork bundle was built but could not be indexed")
return {
"name": asset_name,
"request_path": f"LandingPage/{asset_name}",
"width": width,
"height": height,
"build": build,
}
def _background_asset_paths(content: dict) -> set[str]:
paths: set[str] = set()
images = content.get("images")

View File

@@ -162,9 +162,29 @@ class ManifestManager:
prefix = "vstartoken"
elif "landingpage" in lower_entry:
prefix = "LandingPage"
exported_texture_ids = None
if "landingpage" in lower_entry:
for obj in env.objects:
if obj.type.name != "AssetBundle":
continue
try:
bundle = obj.read()
exported_texture_ids = {
int(info.asset.m_PathID)
for _, info in bundle.m_Container
}
except Exception:
exported_texture_ids = None
break
for obj in env.objects:
if obj.type.name == "Texture2D":
if (
exported_texture_ids is not None
and int(obj.path_id) not in exported_texture_ids
):
continue
tex_name = obj.read().m_Name.lower()
exact_assets.append(tex_name)
if prefix:

View File

@@ -262,6 +262,69 @@ class DynamicPageAssetTests(unittest.TestCase):
self.assertFalse(installed)
self.assertEqual(unresolved, ["Custom/preview_art"])
def test_custom_art_is_packed_into_the_native_visible_texture_region(self):
source = Image.new("RGB", (1600, 900), (25, 140, 220))
texture = dynamic_pages._prepare_custom_texture(source, (2048, 2048))
self.assertEqual(texture.size, (2048, 2048))
self.assertEqual(texture.getpixel((0, 0)), (0, 0, 0, 255))
red, green, blue, alpha = texture.getpixel((1024, 1024))
self.assertLess(red, 35)
self.assertGreater(green, 130)
self.assertGreater(blue, 210)
self.assertEqual(alpha, 255)
def test_custom_upload_normalizes_saves_and_rebuilds_the_bundle(self):
image = Image.new("RGB", (1280, 720), "purple")
payload = io.BytesIO()
image.save(payload, format="PNG")
asset_name = "summer_event_landingpage"
catalog_asset = {
"name": asset_name,
"custom": True,
"request_path": f"LandingPage/{asset_name}",
}
with workspace_temp_dir() as root:
with (
patch.object(dynamic_pages, "CUSTOM_IMAGE_DIR", root),
patch.object(
dynamic_pages,
"_get_catalog",
side_effect=[{}, {asset_name: catalog_asset}],
),
patch.object(
dynamic_pages,
"compile_custom_landing_bundle",
return_value={"built": True, "assets": 1},
) as compile_bundle,
):
imported = dynamic_pages.save_custom_landing_image(
"Summer Event.jpg", payload.getvalue()
)
saved = root / f"{asset_name}.png"
self.assertTrue(saved.is_file())
with Image.open(saved) as saved_image:
self.assertEqual(saved_image.size, (1280, 720))
self.assertEqual(imported["name"], asset_name)
compile_bundle.assert_called_once_with(force=True)
def test_custom_upload_does_not_shadow_original_game_art(self):
image = Image.new("RGB", (640, 360), "blue")
payload = io.BytesIO()
image.save(payload, format="PNG")
original = {"summer_landingpage": {"custom": False}}
with patch.object(dynamic_pages, "_get_catalog", return_value=original):
with self.assertRaisesRegex(
dynamic_pages.PageValidationError, "conflicts with original game artwork"
):
dynamic_pages.save_custom_landing_image(
"summer", payload.getvalue()
)
class LandingPageManifestTests(unittest.TestCase):
def test_manifest_publishes_prefixed_landing_texture_key(self):