fix: restore game focus and ship local 3d

This commit is contained in:
jwinterm
2026-07-25 07:52:11 -04:00
parent 28112e1f6f
commit 57048546de
12 changed files with 310 additions and 47 deletions
+3
View File
@@ -88,6 +88,9 @@
<script src="js/render/packRegistry.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="/runtime-config.js"></script>
<script type="importmap">
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
</script>
<script src="js/render/renderModes.js"></script>
<script src="js/render/charCustomize.js"></script>
<script src="js/render/avatarVisuals.js"></script>
+36 -5
View File
@@ -2,13 +2,18 @@
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
RK.localRenderersEnabled = function () {
return !!(root.WOWNGEON_RUNTIME && root.WOWNGEON_RUNTIME.rendererLocalEnabled === true);
};
RK.externalRenderersEnabled = function () {
return !!(root.WOWNGEON_RUNTIME && root.WOWNGEON_RUNTIME.rendererCdnEnabled === true);
};
RK.isModeRuntimeAvailable = function (id) {
if (!RK.modeMeta || !RK.modeMeta(id)) return false;
if (id === '3d') {
return !!(RK.THREE && RK.THREE.THREE) || RK.externalRenderersEnabled();
return !!(RK.THREE && RK.THREE.THREE)
|| RK.localRenderersEnabled()
|| RK.externalRenderersEnabled();
}
return true;
};
@@ -116,20 +121,46 @@
var threeLoading = false, threeCbs = [];
RK.threeReady = function () { return !!(RK.THREE && RK.THREE.THREE); };
RK.threeModuleSources = function () {
if (RK.localRenderersEnabled()) {
var version = String(root.WOWNGEON_RUNTIME.rendererLocalVersion || '');
if (!/^\d+\.\d+\.\d+$/.test(version)) return null;
var base = '/vendor/three/' + version;
return {
library: base + '/three.module.min.js',
loader: base + '/addons/loaders/GLTFLoader.js',
skeleton: base + '/addons/utils/SkeletonUtils.js'
};
}
if (!RK.externalRenderersEnabled()) return null;
return {
library: 'https://cdn.jsdelivr.net/npm/three@0.160.0/+esm',
loader: 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js/+esm',
skeleton: 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/utils/SkeletonUtils.js/+esm'
};
};
RK.ensureThree = function (cb) {
if (RK.threeReady()) { cb(true); return; }
if (!RK.externalRenderersEnabled()) { cb(false); return; }
var sources = RK.threeModuleSources();
if (!sources) { cb(false); return; }
threeCbs.push(cb);
if (threeLoading) return;
threeLoading = true;
Promise.all([
import('https://cdn.jsdelivr.net/npm/three@0.160.0/+esm'),
import('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js/+esm')
import(sources.library),
import(sources.loader),
import(sources.skeleton)
]).then(function (mods) {
RK.THREE = { THREE: mods[0], GLTFLoader: mods[1].GLTFLoader };
threeLoading = false;
RK.THREE = {
THREE: mods[0],
GLTFLoader: mods[1].GLTFLoader,
SkeletonUtils: mods[2]
};
var cbs = threeCbs; threeCbs = [];
cbs.forEach(function (f) { f(true); });
}).catch(function (e) {
threeLoading = false;
if (root.console) console.warn('Three renderer unavailable:', e && e.message);
var cbs = threeCbs; threeCbs = [];
cbs.forEach(function (f) { f(false); });
+21 -4
View File
@@ -16,6 +16,19 @@
function doc() { return root.document; }
function gameDisplay() { return doc() && doc().getElementById('game-display'); }
// Keyboard movement is intentionally scoped to #game-display so typing in chat and controls
// can never move the player. UI controls that affect the live renderer call this immediately
// after activation to restore that explicit gameplay focus.
SP.focusGameplay = function () {
var gd = gameDisplay();
if (!gd || !gd.focus) return false;
gd.setAttribute('tabindex', '-1');
try { gd.focus({ preventScroll: true }); }
catch (_) { gd.focus(); }
if (root.UI && UI.updateFocusIndicator) UI.updateFocusIndicator();
return doc().activeElement === gd;
};
// A dedicated, SELF-SIZING child host so the RK canvas doesn't collide with the ROT canvas and
// doesn't depend on the ROT canvas for its dimensions (it's an aspect-ratio viewport the camera
// transform is clipped to).
@@ -93,7 +106,11 @@
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
b.addEventListener('click', function (e) { e.stopPropagation(); action(); });
b.addEventListener('click', function (e) {
e.stopPropagation();
action();
SP.focusGameplay();
});
controls.appendChild(b);
}
addButton('−', 'Zoom out', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) / 1.18); });
@@ -113,9 +130,9 @@
var host = rkHost();
if (!host || !RK.createRenderer) return null;
var mode = SP.mode();
// 3D needs THREE, lazy-loaded from a CDN. The sync createRenderer() silently returns a Tiled
// renderer when THREE isn't loaded yet ("picked 3D, got original"). Kick off the load and
// re-mount as real 3D once it's ready; meanwhile we render Tiled so the screen isn't blank.
// 3D needs THREE, lazy-loaded from the pinned same-origin vendor route (or the explicitly
// enabled CDN fallback). The sync createRenderer() returns Tiled until it is ready; remount
// as real 3D after loading so the screen never goes blank.
if (mode === '3d' && RK.ensureThree && RK.threeReady && !RK.threeReady()) {
if (!SP._threePending) {
SP._threePending = true;
+60 -14
View File
@@ -18,6 +18,7 @@
this.host = host;
this.THREE = RK.THREE.THREE;
this.GLTFLoader = RK.THREE.GLTFLoader;
this.SkeletonUtils = RK.THREE.SkeletonUtils;
this.models = {};
this.mixers = [];
this.entities = {};
@@ -26,6 +27,7 @@
this.panX = 0;
this.panY = 0;
this._sizeKey = null;
this._destroyed = false;
this._init();
}
@@ -66,7 +68,11 @@
this.scene.add(this.world);
this.clock = new T.Clock();
var self = this;
this._raf = requestAnimationFrame(function tick() { self._animate(); self._raf = requestAnimationFrame(tick); });
this._raf = requestAnimationFrame(function tick() {
if (self._destroyed || !self.renderer) { self._raf = null; return; }
if (self._animate() === false) { self._raf = null; return; }
self._raf = requestAnimationFrame(tick);
});
};
// Fill the host and frame a fixed "screenful" around the player. The camera FOLLOWS the player
@@ -110,12 +116,31 @@
return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 });
};
ThreeRenderer.prototype._disposeObject = function (object) {
if (!object || !object.traverse) return;
object.traverse(function (obj) {
if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
if (!obj.material) return;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach(function (mat) { if (mat && mat.dispose) mat.dispose(); });
});
};
ThreeRenderer.prototype._buildTiles = function (scene) {
var T = this.THREE;
this.world.clear();
// Visibility changes rebuild the floor/walls, but live entities must stay attached to their
// stable layer. Clearing the whole world here made every avatar disappear after the first
// fog-of-war update because this.entities still pointed at detached objects.
if (this.tileLayer) {
this.world.remove(this.tileLayer);
this._disposeObject(this.tileLayer);
}
this.tileLayer = new T.Group();
this.entityLayer = new T.Group();
this.world.add(this.tileLayer, this.entityLayer);
this.world.add(this.tileLayer);
if (!this.entityLayer) {
this.entityLayer = new T.Group();
this.world.add(this.entityLayer);
}
var cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
for (var y = 0; y < scene.rows; y++) {
for (var x = 0; x < scene.cols; x++) {
@@ -146,16 +171,16 @@
ThreeRenderer.prototype._applyModelTint = function (model, visual) {
var tint = tintForVisual(visual, null);
if (!tint || !model) return;
if (!model) return;
var T = this.THREE;
var color = new T.Color(colorNum(tint));
var color = tint ? new T.Color(colorNum(tint)) : null;
model.traverse(function (obj) {
if (!obj.isMesh || !obj.material) return;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
var next = materials.map(function (mat) {
if (!mat || !mat.color) return mat;
if (!mat) return mat;
var copy = mat.clone();
copy.color.copy(color);
if (color && copy.color) copy.color.copy(color);
return copy;
});
obj.material = Array.isArray(obj.material) ? next : next[0];
@@ -182,7 +207,7 @@
box.getSize(size);
box.getCenter(center);
var maxDim = Math.max(size.x, size.y, size.z) || 1;
var scale = 5.0 / maxDim;
var scale = 1.8 / maxDim;
model.position.set(-center.x, -box.min.y, -center.z);
wrapper.scale.setScalar(scale);
wrapper.add(model);
@@ -240,10 +265,17 @@
if (visual && visual.allowed !== false && visual.model) {
this._loadModel(visual, function (rec) {
if (!rec || !rec.gltf || !shell.parent) return;
var model = rec.gltf.scene.clone(true);
var model = self.SkeletonUtils && self.SkeletonUtils.clone
? self.SkeletonUtils.clone(rec.gltf.scene)
: rec.gltf.scene.clone(true);
self._applyModelTint(model, visual);
model.rotation.y = Math.PI;
var fitted = self._fitModel(model);
if (shell._body) {
shell.remove(shell._body);
self._disposeObject(shell._body);
shell._body = null;
}
shell.add(fitted);
shell._model = fitted;
if (rec.gltf.animations && rec.gltf.animations.length) {
@@ -275,7 +307,9 @@
seen[id] = true;
var ent = this.entities[id];
if (!ent) {
ent = this.entities[id] = { obj: this._makeEntity(e), x: e.x, y: e.y };
var obj = this._makeEntity(e);
obj.position.set(e.x - cx, 0, e.y - cz);
ent = this.entities[id] = { obj: obj, x: e.x, y: e.y };
this.entityLayer.add(ent.obj);
}
ent.e = e;
@@ -284,7 +318,14 @@
}
for (id in this.entities) {
if (!seen[id]) {
this.entityLayer.remove(this.entities[id].obj);
var removed = this.entities[id].obj;
this.entityLayer.remove(removed);
if (removed._mixer) {
removed._mixer.stopAllAction();
var mixerIndex = this.mixers.indexOf(removed._mixer);
if (mixerIndex >= 0) this.mixers.splice(mixerIndex, 1);
}
this._disposeObject(removed);
delete this.entities[id];
}
}
@@ -348,16 +389,21 @@
this.renderer.render(this.scene, this.camera);
} catch (err) {
if (root.console) console.warn('3D animate error; stopping 3D loop:', err && err.message);
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
return;
return false;
}
return true;
};
ThreeRenderer.prototype.destroy = function () {
this._destroyed = true;
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = null;
for (var i = 0; i < this.mixers.length; i++) this.mixers[i].stopAllAction();
this._disposeObject(this.world);
if (this.renderer) {
if (this.renderer.domElement && this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
this.renderer.dispose();
if (this.renderer.forceContextLoss) this.renderer.forceContextLoss();
}
this.renderer = null;
this.entities = {};
+5 -1
View File
@@ -59,7 +59,10 @@
b.setAttribute('aria-disabled', usable ? 'false' : 'true');
b.onclick = function () {
if (!usable) return;
if (RK.SPGame.setMode(m.id)) build();
if (RK.SPGame.setMode(m.id)) {
build();
if (RK.SPGame.focusGameplay) RK.SPGame.focusGameplay();
}
};
modeRow.appendChild(b);
});
@@ -80,6 +83,7 @@
if (RK.setActivePack && RK.setActivePack(p.id)) {
if (RK.SPGame.refreshPack) RK.SPGame.refreshPack();
build();
if (RK.SPGame.focusGameplay) RK.SPGame.focusGameplay();
}
};
packRow.appendChild(b);
+3
View File
@@ -106,6 +106,9 @@
<script src="js/render/isoRenderer.js"></script>
<script src="js/render/threeRenderer.js"></script>
<script src="/runtime-config.js"></script>
<script type="importmap">
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
</script>
<script src="js/render/renderModes.js"></script>
<script src="js/ui/matchUiCopy.js"></script>
</head>
+83 -20
View File
@@ -28,6 +28,9 @@
<script src="js/render/threeRenderer.js"></script>
<script src="js/render/tavernLife.js"></script>
<script src="/runtime-config.js"></script>
<script type="importmap">
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
</script>
<script src="js/render/renderModes.js"></script>
<script src="js/ui/matchUiCopy.js"></script>
<style>
@@ -47,6 +50,14 @@
.muted { color:var(--muted);font-size:13px; }
.rk-canvas { background:#050609;display:block;max-width:100%;image-rendering:pixelated; }
.panel { background:rgba(21,26,32,.97);border:1px solid var(--line);border-radius:9px;padding:14px;margin-bottom:12px;box-shadow:inset 0 1px rgba(255,255,255,.025); }
#joinPanel { padding:18px; }
.join-copy { display:flex;align-items:baseline;gap:10px;margin-bottom:13px; }
.join-copy strong { color:#fff;font-size:18px;letter-spacing:.02em; }
.join-fields { display:grid;grid-template-columns:auto minmax(150px,1fr) auto auto;gap:8px;align-items:center; }
#joinStatus { display:flex;align-items:center;gap:7px;margin:12px 0 0;min-height:18px;color:var(--muted);font-size:13px; }
#joinStatus::before { content:"";width:8px;height:8px;flex:0 0 auto;border-radius:50%;background:var(--gold);box-shadow:0 0 10px rgba(239,182,75,.5); }
#joinStatus.connected::before { background:var(--accent);box-shadow:0 0 10px rgba(81,213,105,.55); }
#joinStatus.error::before { background:#ff6b6b;box-shadow:0 0 10px rgba(255,107,107,.5); }
label { font-size: 13px; margin-right: 6px; }
input, select, button { font:inherit;background:#0d1116;color:var(--text);border:1px solid var(--line);border-radius:5px;padding:7px 9px; }
input:focus,button:focus-visible { outline:2px solid rgba(98,216,255,.7);outline-offset:2px; }
@@ -86,7 +97,7 @@
.touch-pad { display:none;grid-template-areas:". up ." "left down right";gap:5px;justify-content:end;margin:8px 0; }
.touch-pad button { width:44px;height:40px;padding:0;touch-action:manipulation;background:rgba(25,45,31,.94);color:#93f3a2;border-color:#4f9a5c; }
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} #joinPanel{display:flex;flex-wrap:wrap;gap:7px;align-items:center} #joinPanel label{width:100%} #name{min-width:0;flex:1} }
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} .join-copy{display:block}.join-copy .muted{display:block;margin-top:5px}.join-fields{grid-template-columns:auto minmax(0,1fr)}.join-fields #customizeBtn,.join-fields #joinBtn{grid-column:auto / span 1}.join-fields label{grid-column:1}.join-fields #name{grid-column:2} }
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head .muted{display:none}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after{animation:none!important;transition:none!important} }
</style>
@@ -98,13 +109,20 @@
<span><a class="backlink" href="/responsible-play">Responsible Play</a> <a class="backlink" href="/">← Lobby</a></span>
</header>
<div id="joinPanel" class="panel">
<label for="name">Name</label>
<input id="name" maxlength="16" placeholder="anon" autocomplete="off">
<button id="customizeBtn" type="button">🎭 Customize</button>
<form id="joinPanel" class="panel" autocomplete="off">
<div class="join-copy">
<strong>Walk in anonymously</strong>
<span class="muted">No account required. Pick an optional display name and character.</span>
</div>
<div class="join-fields">
<label for="name">Name</label>
<input id="name" maxlength="16" placeholder="anon" autocomplete="nickname">
<button id="customizeBtn" type="button">🎭 Customize</button>
<button id="joinBtn" type="submit" disabled>Connecting…</button>
</div>
<span id="appearancePreview" class="muted"></span>
<button id="joinBtn">Enter tavern</button>
</div>
<p id="joinStatus" role="status" aria-live="polite">Connecting to the tavern…</p>
</form>
<div id="modebar" class="hidden"></div>
<div id="packbar" class="hidden"></div>
@@ -116,7 +134,7 @@
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
<p id="status" class="muted" role="status" aria-live="polite"></p>
<p id="movementHint" class="muted">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
<p id="movementHint" class="muted hidden">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
<div id="chatPanel" class="panel hidden">
<div id="chatLog" aria-live="polite"></div>
@@ -181,6 +199,7 @@
var statusEl = document.getElementById('status');
var joinPanel = document.getElementById('joinPanel');
var joinBtn = document.getElementById('joinBtn');
var joinStatus = document.getElementById('joinStatus');
var stageEl = document.getElementById('stage');
var modebar = document.getElementById('modebar');
var packbar = document.getElementById('packbar');
@@ -236,8 +255,28 @@
var activeMatchSnapshots = {};
var activeMatchTickMs = 250;
var currentMatchRuleset = MatchCopy.normalize({ id: 'race' });
var joinPending = false;
var joinTimer = null;
function setStatus(msg) { statusEl.textContent = msg; }
function setStatus(msg, kind) {
statusEl.textContent = msg;
joinStatus.textContent = msg;
var connectionKind = kind || (socket.connected ? 'connected' : '');
joinStatus.classList.toggle('connected', connectionKind === 'connected');
joinStatus.classList.toggle('error', connectionKind === 'error');
}
function updateJoinButton() {
joinBtn.disabled = joinPending || !socket.connected;
joinBtn.textContent = joinPending
? 'Entering…'
: (socket.connected ? 'Enter tavern' : 'Connecting…');
}
function clearJoinAttempt() {
if (joinTimer) window.clearTimeout(joinTimer);
joinTimer = null;
joinPending = false;
updateJoinButton();
}
function applyEntitlements(data) {
if (RK.setEntitlementSnapshot) RK.setEntitlementSnapshot(data || {});
updateAppearancePreview();
@@ -375,16 +414,30 @@
}
// ---- Join / state ------------------------------------------------------------
joinBtn.addEventListener('click', function () {
joinPanel.addEventListener('submit', function (event) {
event.preventDefault();
if (joinPending) return;
if (!socket.connected) {
setStatus('Not connected yet. Retrying automatically…', 'error');
updateJoinButton();
return;
}
var name = document.getElementById('name').value || '';
var appearance = RK.normalizeAppearance(selectedAppearance);
joinBtn.disabled = true;
joinPending = true;
updateJoinButton();
setStatus('Entering the tavern…', 'connected');
socket.emit('tavern_join', { name: name, avatar: appearance.avatar, appearance: appearance });
joinTimer = window.setTimeout(function () {
if (!joinPending) return;
clearJoinAttempt();
setStatus('The tavern did not respond. Check the connection and try again.', 'error');
}, 10000);
});
socket.on('tavern_error', function (e) {
joinBtn.disabled = false;
setStatus((e && e.message) || 'Could not enter the tavern.');
clearJoinAttempt();
setStatus((e && e.message) || 'Could not enter the tavern.', 'error');
});
socket.on('game_mode_info', function (data) {
lastGameModeInfo = data || null;
@@ -413,6 +466,7 @@
});
socket.on('tavern_joined', function (data) {
clearJoinAttempt();
state.you = data.you;
applyFull(data.state);
joinPanel.classList.add('hidden');
@@ -420,6 +474,8 @@
stageEl.classList.remove('hidden');
chatPanel.classList.remove('hidden');
tavernDpad.classList.remove('hidden');
movementHint.classList.remove('hidden');
stageEl.setAttribute('tabindex', '-1');
rebuildScene();
buildModebar();
setMode(RK.loadMode('tiles'));
@@ -437,7 +493,7 @@
rebuildScene();
draw();
});
setStatus('You are in the tavern.');
setStatus('You are in the tavern.', 'connected');
watchGamesBtn.classList.remove('hidden');
});
@@ -550,12 +606,23 @@
bindDpad(tavernDpad);
bindDpad(spectatorDpad);
setStatus('Connecting…');
setStatus('Connecting to the tavern…');
updateJoinButton();
socket.on('connect', function () {
clearJoinAttempt();
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
socket.emit('identity:get');
socket.emit('match_reconnect', {});
setStatus('Connected. Choose a name and enter.');
setStatus('Connected. Choose a name and enter.', 'connected');
});
socket.on('disconnect', function () {
clearJoinAttempt();
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
setStatus('Connection lost. Reconnecting automatically…', 'error');
});
socket.on('connect_error', function () {
clearJoinAttempt();
setStatus('Could not connect. Retrying automatically…', 'error');
});
// ---- Shared spectator viewport (solo runs + public PvP races) -----------------
@@ -929,10 +996,6 @@
// ---- End match UI ----------------------------------------------------
socket.on('disconnect', function () {
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
setStatus('Disconnected.');
});
})();
</script>
</body>
+31 -1
View File
@@ -133,6 +133,12 @@ let latePaymentReconciler = null;
let walletHealthInterval = null; // Keeps readiness tied to a live wallet, not startup state
let databaseHealthInterval = null; // Detects a database outage after startup
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
// Three.js is an exact production dependency and is served from this application origin. This
// keeps the 3D renderer available without granting a third-party CDN script privileges in the
// game/payment UI. The version is part of the public URL so its immutable cache cannot survive an
// upgrade to different bytes.
const rendererLocalEnabled = true;
const rendererLocalVersion = '0.160.0';
// Third-party renderer code executes with the same privileges as the game/payment UI. Keep CDN
// execution off by default; production operators must make the supply-chain tradeoff explicit.
const rendererCdnEnabled = /^true$/i.test(String(process.env.RENDERER_CDN_ENABLED || 'false'));
@@ -188,10 +194,32 @@ app.use(['/api/user', '/api/auth', '/api/admin', '/api/payment'], noStore);
app.get('/runtime-config.js', (req, res) => {
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.type('application/javascript').send(
`window.WOWNGEON_RUNTIME=Object.freeze({rendererCdnEnabled:${rendererCdnEnabled}});`
'window.WOWNGEON_RUNTIME=Object.freeze('
+ JSON.stringify({ rendererLocalEnabled, rendererLocalVersion, rendererCdnEnabled })
+ ');'
);
});
// Publish only the browser-facing Three.js build and addons. They remain same-origin under CSP;
// package metadata, source maps outside these directories, and the rest of node_modules are not
// exposed. Versioned immutable URLs prevent mixed-library deployments during a rolling release.
const rendererVendorPath = `/vendor/three/${rendererLocalVersion}`;
const threePackagePath = path.join(__dirname, 'node_modules/three');
const rendererVendorStaticOptions = {
etag: true,
immutable: true,
lastModified: true,
maxAge: '1y'
};
app.use(
`${rendererVendorPath}/addons`,
express.static(path.join(threePackagePath, 'examples/jsm'), rendererVendorStaticOptions)
);
app.use(
rendererVendorPath,
express.static(path.join(threePackagePath, 'build'), rendererVendorStaticOptions)
);
// Configure static file serving
const htmlPath = path.join(__dirname, '../html');
app.use(express.static(htmlPath, {
@@ -828,6 +856,8 @@ app.get('/api/game-modes', (req, res) => {
enabled: publicModeInfo.modes?.match?.enabled === true,
economies: publicModeInfo.modes?.match?.economies || {}
},
rendererLocalEnabled,
rendererLocalVersion,
rendererCdnEnabled,
hostedBy
});
+7
View File
@@ -18,6 +18,7 @@
"resend": "^4.0.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"three": "0.160.0",
"tweetnacl": "^1.0.3",
"uuid": "^11.1.1"
},
@@ -5405,6 +5406,12 @@
"node": "*"
}
},
"node_modules/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+1
View File
@@ -54,6 +54,7 @@
"resend": "^4.0.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"three": "0.160.0",
"tweetnacl": "^1.0.3",
"uuid": "^11.1.1"
},
+10
View File
@@ -89,6 +89,16 @@ describe('public multiplayer controls and boards', () => {
expect(tavernHtml).toContain("b.addEventListener('click'");
});
test('anonymous Tavern admission has explicit connection, pending, and retry states', () => {
expect(tavernHtml).toContain('Walk in anonymously');
expect(tavernHtml).toContain('No account required');
expect(tavernHtml).toMatch(/id="joinPanel"[^>]*>[\s\S]*id="joinBtn"[^>]*disabled/);
expect(tavernHtml).toContain("joinPanel.addEventListener('submit'");
expect(tavernHtml).toContain("socket.on('disconnect'");
expect(tavernHtml).toContain("socket.on('connect_error'");
expect(tavernHtml).toContain('The tavern did not respond');
});
test('Prestige is a distinct public board with an explicit non-mixing disclosure', () => {
expect(leaderboard).toContain('data-board="prestige"');
expect(leaderboard).toContain('Credit-entry competitive PvP scores only');
+50 -2
View File
@@ -76,13 +76,18 @@ function loadPickerWithRuntimeDisabled() {
context,
);
window.RK.entitlements.premium = true;
window.RK.SPGame = { mode: () => 'tiles', setMode: () => true };
const focusGameplay = jest.fn();
window.RK.SPGame = {
mode: () => 'tiles',
setMode: () => true,
focusGameplay,
};
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'),
context,
);
window.RK.rebuildRenderPicker();
return { picker, RK: window.RK };
return { picker, RK: window.RK, focusGameplay };
}
describe('optional renderer CDN policy', () => {
@@ -118,6 +123,25 @@ describe('optional renderer CDN policy', () => {
expect(callback).toHaveBeenCalledWith(true);
});
test('the pinned same-origin renderer is available without enabling CDN execution', () => {
const RK = loadRenderModes({
rendererLocalEnabled: true,
rendererLocalVersion: '0.160.0',
rendererCdnEnabled: false,
});
RK.entitlements.premium = true;
expect(RK.externalRenderersEnabled()).toBe(false);
expect(RK.localRenderersEnabled()).toBe(true);
expect(RK.isModeRuntimeAvailable('3d')).toBe(true);
expect(RK.canUseMode('3d')).toBe(true);
expect(RK.threeModuleSources()).toEqual({
library: '/vendor/three/0.160.0/three.module.min.js',
loader: '/vendor/three/0.160.0/addons/loaders/GLTFLoader.js',
skeleton: '/vendor/three/0.160.0/addons/utils/SkeletonUtils.js',
});
});
test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => {
const { picker } = loadPickerWithRuntimeDisabled();
const modeButtons = picker.children[0].children;
@@ -130,6 +154,15 @@ describe('optional renderer CDN policy', () => {
expect(threeButton.attributes['aria-disabled']).toBe('true');
});
test('homepage renderer selection immediately returns keyboard focus to gameplay', () => {
const { picker, focusGameplay } = loadPickerWithRuntimeDisabled();
const tiledButton = picker.children[0].children.find(button => button.textContent === 'Tiled');
tiledButton.onclick();
expect(focusGameplay).toHaveBeenCalledTimes(1);
});
test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => {
const serverSource = fs.readFileSync(
path.join(__dirname, '../src/index.js'),
@@ -153,4 +186,19 @@ describe('optional renderer CDN policy', () => {
expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js');
expect(homepage + debugPage).not.toContain('jquery-3.4.1');
});
test('server and renderer pages publish the exact same-origin Three.js build', () => {
const packageJson = require('../src/package.json');
const serverSource = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
const pages = ['index.html', 'tavern.html', 'match.html']
.map(name => fs.readFileSync(path.join(__dirname, '../html', name), 'utf8'))
.join('\n');
expect(packageJson.dependencies.three).toBe('0.160.0');
expect(serverSource).toContain("const rendererLocalVersion = '0.160.0'");
expect(serverSource).toContain("'examples/jsm'");
expect(serverSource).toContain("'node_modules/three'");
expect(pages.split('"three":"/vendor/three/0.160.0/three.module.min.js"'))
.toHaveLength(4);
});
});