/* Patch app.js + cave3d.js tại build-time. app.js: thêm nút Kéo co + Giữ bóng (challenge sheet), ErrorBoundary + window.onerror/unhandledrejection → POST /api/log, FIX UpdateNotice "Có bản mới": lần check đầu = baseline version đang chạy; chỉ báo "Có bản mới" khi version.json thực sự đổi — hết tự reload liên tục. cave3d.js: OutdoorScene (sân ngoài cửa hang) + LỐI VÀO sáng rõ + ÁO ĐỎ MU #7 trưng bày. Chạy TRƯỚC esbuild trong Dockerfile build stage. Nếu pattern không khớp -> exit(1) để build fail (không ship bản thiếu). */ import fs from 'node:fs' function patchFile(path, reps) { let s = fs.readFileSync(path, 'utf8') const failures = [] function toRe(str) { const esc = str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return esc.replace(/\s+/g, '\\s+') } function rep(oldStr, newStr) { const exact = s.split(oldStr).length - 1 if (exact === 1) { s = s.replace(oldStr, newStr); return } const re = new RegExp(toRe(oldStr), 'g') const m = s.match(re) if (m && m.length === 1) { s = s.replace(re, newStr); return } failures.push(`pattern not unique/found (exact=${exact}, re=${m ? m.length : 0}): ${oldStr.slice(0, 70)}`) } for (const [o, n] of reps) rep(o, n) if (failures.length) { console.error(`PATCH FAILED (${path}):`) failures.forEach((f) => console.error(' -', f)) process.exit(1) } fs.writeFileSync(path, s) console.log('patched ok:', path) } /* ================= app.js ================= */ patchFile('frontend/app.js', [ [ "const INVITE_ICON = { talk: '💬', duel: '🥊', penalty: '⚽' }", "const INVITE_ICON = { talk: '💬', duel: '🥊', penalty: '⚽', tug: '💪', ball: '👑' }" ], [ "const INVITE_ASKING = { talk: 'giao lưu mồm', duel: 'giao lưu võ thuật', penalty: 'sút luân lưu' }", "const INVITE_ASKING = { talk: 'giao lưu mồm', duel: 'giao lưu võ thuật', penalty: 'sút luân lưu', tug: 'kéo co', ball: 'giữ bóng' }" ], [ ` `, ` ` ], [ `createRoot(document.getElementById('root')).render( )`, `/* ============ ErrorBoundary + client error log (chống màn hình đen) ============ */ class CaveErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { err: null } } static getDerivedStateFromError(err) { return { err } } componentDidCatch(err, info) { try { fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'react', msg: String((err && err.message) || err).slice(0, 600), stack: String((err && err.stack) || '').slice(0, 2500), info: String((info && info.componentStack) || '').slice(0, 2500), href: location.href }) }).catch(() => {}) } catch {} } render() { if (this.state.err) { return (

Có lỗi xảy ra 🛠️

{String((this.state.err && this.state.err.message) || this.state.err)}

) } return this.props.children } } window.addEventListener('error', (e) => { try { fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'window', msg: String(e.message || '').slice(0, 600), stack: String((e.error && e.error.stack) || '').slice(0, 2500), href: location.href }) }).catch(() => {}) } catch {} }) window.addEventListener('unhandledrejection', (e) => { try { fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'promise', msg: String((e.reason && e.reason.message) || e.reason || '').slice(0, 600), href: location.href }) }).catch(() => {}) } catch {} }) createRoot(document.getElementById('root')).render( )` ], /* ---- FIX "Có bản mới" tự tải lại liên tục ---- Cũ: `window.__hangmuVersion = null` mỗi lần mount → `id !== null` LUÔN đúng → cứ 15s reload, kể cả khi không có bản mới. Mới: lần check đầu = baseline (version đang chạy), chỉ báo "Có bản mới" khi version.json thay đổi THẬT SỰ sau đó. */ [ ` if (alive && id && id !== window.__hangmuVersion) setState({ available: true, seconds: 15, busy: false })`, ` if (!alive || !id) return if (baseline === null) { baseline = id; return } if (id !== baseline) setState({ available: true, seconds: 15, busy: false })` ], [ ` window.__hangmuVersion = null`, ` let baseline = null` ], ]) /* ================= cave3d.js ================= */ patchFile('frontend/cave3d.js', [ /* CaveScene: thêm JerseyDisplay + OutdoorScene */ [ ` `, ` ` ], /* LỐI VÀO HANG: thay tấm chặn mờ bằng vành đá sáng + vòm đá quanh cửa */ [ ` `, ` ` ], /* ÁO ĐỎ MU TRƯNG BÀY — jersey #7 trong khung gỗ cạnh màn hình lớn */ [ `export function CaveScene() {`, `/* ========================================================================= ÁO ĐỎ MU TRƯNG BÀY — jersey #7 trong khung gỗ cạnh màn hình lớn ========================================================================= */ function _jerseyTex() { if (typeof document === 'undefined') return null const c = document.createElement('canvas'); c.width = 512; c.height = 640 const g = c.getContext('2d') g.fillStyle = '#17120d'; g.fillRect(0, 0, 512, 640) g.fillStyle = '#241b12'; g.fillRect(26, 26, 460, 588) g.fillStyle = '#c8102e' g.beginPath(); g.moveTo(138, 168); g.lineTo(374, 168); g.lineTo(374, 468); g.lineTo(138, 468); g.closePath(); g.fill() g.beginPath(); g.moveTo(138, 168); g.lineTo(50, 118); g.lineTo(86, 332); g.lineTo(138, 298); g.closePath(); g.fill() g.beginPath(); g.moveTo(374, 168); g.lineTo(462, 118); g.lineTo(426, 332); g.lineTo(374, 298); g.closePath(); g.fill() g.fillStyle = '#7c0d20' g.beginPath(); g.arc(256, 172, 36, 0, Math.PI * 2); g.fill() g.fillStyle = '#ffffff' g.font = '800 200px Georgia, serif'; g.textAlign = 'center'; g.textBaseline = 'middle' g.fillText('7', 256, 368) g.fillStyle = '#ffe9c0'; g.font = '700 40px Georgia, serif' g.fillText('MANCHESTER UNITED', 256, 548) const t = new THREE.CanvasTexture(c) t.colorSpace = THREE.SRGBColorSpace t.anisotropy = 16 return t } function JerseyDisplay() { const [tex, setTex] = React.useState(null) React.useEffect(() => { let alive = true const t = _jerseyTex() if (alive && t) setTex(t) return () => { alive = false } }, []) return ( {tex && ( )} ) } export function CaveScene() {` ], /* OutdoorScene — sân ngoài cửa hang */ [ `export { floorHeight, domeHeight }`, `export { floorHeight, domeHeight } /* ========================================================================= OUTDOOR SCENE — sân ngoài cửa hang (+z), nhìn qua lối vào ========================================================================= */ function _makeSkyTex() { if (typeof document === 'undefined') return null const c = document.createElement('canvas'); c.width = 64; c.height = 256 const g = c.getContext('2d') const gr = g.createLinearGradient(0, 0, 0, 256) gr.addColorStop(0, '#7fb2e0'); gr.addColorStop(0.55, '#cfe3f2'); gr.addColorStop(0.8, '#e8d9bd'); gr.addColorStop(1, '#d8c2a0') g.fillStyle = gr; g.fillRect(0, 0, 64, 256) const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace return t } const _skyTexC = _makeSkyTex() function SkyDome() { const tex = useMemo(() => _skyTexC, []) return ( ) } function OutdoorGround() { const geo = useMemo(() => { const m = new THREE.PlaneGeometry(170, 100, 70, 40) m.rotateX(-Math.PI / 2) const p = m.attributes.position const cols = new Float32Array(p.count * 3) const cA = new THREE.Color('#8a7a5c'), cB = new THREE.Color('#6d5c42'), moss = new THREE.Color('#7d7a45'), tmp = new THREE.Color() for (let i = 0; i < p.count; i++) { const x = p.getX(i), z = p.getZ(i) p.setY(i, fbm3(x * 0.06, z * 0.06, 0, 21) * 1.1 - 0.3) tmp.copy(cA).lerp(cB, fbm3(x * 0.12, z * 0.12, 3, 22) * 0.5 + 0.25) tmp.lerp(moss, Math.max(0, fbm3(x * 0.08, 9, z * 0.08, 23)) * 0.35) cols[i * 3] = tmp.r; cols[i * 3 + 1] = tmp.g; cols[i * 3 + 2] = tmp.b } m.setAttribute('color', new THREE.BufferAttribute(cols, 3)) m.computeVertexNormals() return m }, []) return ( ) } function OutdoorPath() { return ( ) } function OutdoorProps() { const items = useMemo(() => { const out = [] const boulder = (x, z, s, seed) => { const geo = displaceAndColor(new THREE.IcosahedronGeometry(1, 1), { amp: 0.3, freq: 1.1, seed, base: '#9a8a6c', shade: '#77664c', dark: '#4c3f2e', warm: '#b3a284' }) out.push({ kind: 'boulder', geo, x, z, s }) } const tree = (x, z, s, seed) => out.push({ kind: 'tree', x, z, s, seed }) boulder(-8, 80, 1.6, 101); boulder(9, 82, 2.1, 102); boulder(-14, 86, 2.8, 103) boulder(15, 88, 2.4, 104); boulder(-6, 94, 1.2, 105); boulder(11, 96, 1.4, 106) boulder(-18, 100, 3.2, 107); boulder(19, 102, 3.0, 108) tree(-12, 88, 1.5, 201); tree(12, 90, 1.8, 202); tree(-16, 96, 2.1, 203); tree(16, 98, 2.0, 204) tree(-8, 104, 1.4, 205); tree(8, 108, 1.6, 206) return out }, []) return ( {items.map((it, i) => it.kind === 'boulder' ? ( ) : ( {[0, 1, 2].map((k) => ( ))} ))} ) } function OutdoorLight() { const tex = useMemo(() => { const c = document.createElement('canvas'); c.width = c.height = 128 const g = c.getContext('2d') const gr = g.createRadialGradient(64, 64, 2, 64, 64, 62) gr.addColorStop(0, 'rgba(255,240,200,0.9)'); gr.addColorStop(0.5, 'rgba(255,214,140,0.35)'); gr.addColorStop(1, 'rgba(255,214,140,0)') g.fillStyle = gr; g.fillRect(0, 0, 128, 128) const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace return t }, []) return ( <> ) } function OutdoorScene() { return ( ) }` ], ])