hangmu-docker / patch_app.mjs
bep40's picture
Fix "Có bản mới" reload loop + cache-bust v6 + lối vào hang & áo đỏ MU (#2)
7fba859
Raw History Blame Contribute Delete
15.5 kB
/* 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' }" ],
[ ` <button type="button" className="challenge-game is-last" onClick={() => setConfirm('penalty')}>
<span className="challenge-icon">⚽</span> Sút luân lưu
<small>5 lượt mỗi bên</small>
</button>`,
` <button type="button" className="challenge-game" onClick={() => setConfirm('penalty')}>
<span className="challenge-icon">⚽</span> Sút luân lưu
<small>5 lượt mỗi bên</small>
</button>
<button type="button" className="challenge-game" onClick={() => setConfirm('tug')}>
<span className="challenge-icon">💪</span> Kéo co
<small>1v1 giữ nút để kéo</small>
</button>
<button type="button" className="challenge-game is-last" onClick={() => setConfirm('ball')}>
<span className="challenge-icon">👑</span> Giữ bóng
<small>1v1 cướp bóng giữ hết giờ</small>
</button>` ],
[ `createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)`,
`/* ============ 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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'grid', placeItems: 'center',
background: '#120b08', color: '#f3e7d8', fontFamily: 'system-ui, sans-serif', padding: 24, textAlign: 'center' }}>
<div>
<p style={{ fontSize: 28, fontWeight: 800, margin: '0 0 8px' }}>Có lỗi xảy ra 🛠️</p>
<p style={{ opacity: 0.85, wordBreak: 'break-word', maxWidth: 640 }}>
{String((this.state.err && this.state.err.message) || this.state.err)}
</p>
<button onClick={() => location.reload()} style={{ marginTop: 14, padding: '10px 24px',
background: '#c8102e', color: '#fff', border: 0, borderRadius: 8, fontSize: 16, cursor: 'pointer' }}>
Tải lại
</button>
</div>
</div>
)
}
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(
<React.StrictMode>
<CaveErrorBoundary>
<App />
</CaveErrorBoundary>
</React.StrictMode>
)` ],
/* ---- 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 */
[ ` <Entrance />
<hemisphereLight args={[0xe8c090, 0x7a6152, 1.5]} />`,
` <JerseyDisplay />
<Entrance />
<OutdoorScene />
<hemisphereLight args={[0xe8c090, 0x7a6152, 1.5]} />` ],
/* LỐI VÀO HANG: thay tấm chặn mờ bằng vành đá sáng + vòm đá quanh cửa */
[ ` <mesh position={[0, 3, 17.5]}>
<planeGeometry args={[14, 10]} />
<meshBasicMaterial color="#f4e7cf" side={THREE.DoubleSide} fog={false} />
</mesh>`,
` <mesh position={[0, 3.2, 17.6]}>
<ringGeometry args={[6.3, 7.5, 30]} />
<meshLambertMaterial color="#6b5540" side={THREE.DoubleSide} fog={false} />
</mesh>
<mesh position={[0, 3.3, 17.3]}>
<torusGeometry args={[6.9, 0.72, 8, 26, Math.PI]} />
<meshLambertMaterial color="#8a6f55" flatShading side={THREE.DoubleSide} fog={false} />
</mesh>
<pointLight position={[0, 10, 15.5]} color="#ffe9c8" intensity={45} distance={30} decay={1.2} fog={false} />` ],
/* Á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 (
<group position={[15.5, 0, -41]} rotation={[0, -0.42, 0]}>
<mesh position={[0, 0.425, -0.12]}>
<boxGeometry args={[0.55, 0.85, 0.55]} />
<meshLambertMaterial color="#3d2a1a" flatShading />
</mesh>
<mesh position={[0, 2.15, -0.1]}>
<boxGeometry args={[2.7, 3.55, 0.16]} />
<meshLambertMaterial color="#5a3b22" flatShading />
</mesh>
{tex && (
<mesh position={[0, 2.18, 0.04]}>
<planeGeometry args={[2.35, 2.94]} />
<meshBasicMaterial map={tex} transparent fog={false} toneMapped={false} />
</mesh>
)}
</group>
)
}
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 (
<mesh position={[0, 20, 178]} rotation={[0, Math.PI, 0]} fog={false}>
<planeGeometry args={[170, 96]} />
<meshBasicMaterial map={tex} fog={false} toneMapped={false} depthWrite={false} />
</mesh>
)
}
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 (
<mesh geometry={geo} position={[0, 0, 126]} receiveShadow fog={false}>
<meshLambertMaterial vertexColors fog={false} />
</mesh>
)
}
function OutdoorPath() {
return (
<mesh position={[0, 0.04, 112]} rotation={[-Math.PI / 2, 0, 0]} fog={false}>
<planeGeometry args={[11, 66]} />
<meshBasicMaterial color="#a89474" fog={false} transparent opacity={0.85} />
</mesh>
)
}
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 (
<group>
{items.map((it, i) => it.kind === 'boulder' ? (
<mesh key={i} geometry={it.geo} position={[it.x, it.s * 0.35, it.z]} scale={[it.s, it.s * 0.85, it.s]} fog={false}>
<meshLambertMaterial vertexColors flatShading fog={false} />
</mesh>
) : (
<group key={i} position={[it.x, 0, it.z]} scale={[it.s, it.s, it.s]} fog={false}>
<mesh position={[0, 1.1, 0]} fog={false}>
<cylinderGeometry args={[0.22, 0.3, 2.2, 6]} />
<meshLambertMaterial color="#6b4a2c" flatShading fog={false} />
</mesh>
{[0, 1, 2].map((k) => (
<mesh key={k} position={[Math.sin(k * 2.1) * 0.7, 2.6 + k * 0.55, Math.cos(k * 2.1) * 0.7]} fog={false}>
<coneGeometry args={[1.35 - k * 0.25, 1.6, 7]} />
<meshLambertMaterial color={k === 0 ? '#3f6b2e' : k === 1 ? '#4c7a38' : '#5a8a42'} flatShading fog={false} />
</mesh>
))}
</group>
))}
</group>
)
}
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 (
<>
<directionalLight position={[15, 30, 140]} intensity={1.6} color="#fff1d6" fog={false} />
<pointLight position={[0, 16, 118]} color="#ffe6c0" intensity={5} distance={70} decay={0.5} fog={false} />
<sprite position={[0, 34, 132]} scale={[34, 34, 1]} fog={false}>
<spriteMaterial map={tex} transparent opacity={0.9} fog={false} depthWrite={false} blending={THREE.AdditiveBlending} />
</sprite>
</>
)
}
function OutdoorScene() {
return (
<group>
<SkyDome />
<OutdoorGround />
<OutdoorPath />
<OutdoorProps />
<OutdoorLight />
</group>
)
}` ],
])