/* 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 (
)
}` ],
])