File size: 15,460 Bytes
1801051
 
7fba859
 
 
 
23f83a2
 
 
 
1801051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23f83a2
 
1801051
 
 
 
 
 
 
23f83a2
 
 
1801051
23f83a2
 
 
 
 
 
 
 
 
 
1801051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fba859
 
 
 
 
 
 
 
 
 
 
1801051
23f83a2
1801051
 
7fba859
1801051
 
7fba859
 
1801051
 
7fba859
1801051
 
 
 
 
 
7fba859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1801051
 
 
 
7fba859
1801051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
021cd2e
1801051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
021cd2e
1801051
 
 
 
 
 
021cd2e
1801051
 
 
 
 
 
 
 
 
 
 
 
 
 
021cd2e
 
 
 
 
1801051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331f563
fecb7c9
 
 
1801051
 
 
23f83a2
1801051
 
 
 
 
 
 
 
 
 
 
7fba859
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
/* 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>
  )
}` ],
])