/* ===== 石とゾンビ サーバー(Cloudflare Workers + Durable Objects) ===== 手本は C:\回転寿司オンライン(読むだけ)。 ・1プレイ = 1部屋(Game)。**待合も部屋作りも作らない**——「はじめる」で即。 空いている部屋があれば途中参加、無ければ新しい部屋。最大2人。 ・**正はここ**: HP・死亡・撃破・スクラップ・大岩・前後交代・参加退出。 石の飛びと当たり判定は投げた本人の端末(通信を待たせないため)。 ・世界の中身は public/lib/world.js。**サーバーと端末が同じ1本を回す。** ★誰も繋がっていない部屋が回り続けないこと(2026-09-13 ぷっちーずで実際に起きた穴)。 ・人が0人になったら時計を止める(setIntervalを解く) ・そのうえで目覚ましを3分後に仕掛け、まだ0人なら**部屋を畳んで目覚ましも消す** ・止まる条件を「全滅」のような遊びの中の出来事にしない */ import '../public/lib/world.js'; const ZW = globalThis.ZWORLD; /* ===== DoSガード 盾2: 自分で止める門 ===== Cloudflareには「ここで止める」設定が無い。使った分だけ請求が伸びる。 ★上限は月2000万回(2026-09-13 K1指示「全てのガードを月2000万回にしてよ」 「今開発段階やし本格化したら外せばええんやし」)。 数えるのは変数+1だけ。1000回たまった時に1回保存。判定は**新しく繋ぐ時だけ**。 遊んでいる最中は一度も確かめない(K1「ゲームに影響出すな」)。 */ const MONTH_LIMIT = 20000000; const SAVE_EVERY = 1000; const GATE_CACHE_MS = 60000; let hits = 0, gate = { at: 0, busy: false, n: 0 }; function ym(){ const d=new Date(); return d.getUTCFullYear()+'-'+(d.getUTCMonth()+1); } async function saveHits(env, n){ try{ await env.METER.get(env.METER.idFromName('m')).fetch('https://do/add?n='+n+'&m='+ym()); }catch(e){} } async function tooMuch(env){ const now = Date.now(); if (now - gate.at < GATE_CACHE_MS) return gate.busy; let n = 0; try{ const r = await env.METER.get(env.METER.idFromName('m')).fetch('https://do/get?m='+ym()); n = (await r.json()).n || 0; }catch(e){} gate = { at: now, busy: n >= MONTH_LIMIT, n }; return gate.busy; } const J = (o, st) => new Response(JSON.stringify(o), { status: st||200, headers: { 'content-type':'application/json; charset=utf-8' } }); export default { async fetch(req, env, ctx){ const u = new URL(req.url); hits++; if (hits >= SAVE_EVERY){ const n = hits; hits = 0; ctx.waitUntil(saveHits(env, n)); } if (u.pathname === '/api/meter'){ let n=0; try{ const r = await env.METER.get(env.METER.idFromName('m')).fetch('https://do/get?m='+ym()); n = (await r.json()).n||0; }catch(e){} return J({ 月:ym(), 今いくつ:n, 上限:MONTH_LIMIT, 残り:Math.max(0,MONTH_LIMIT-n) }); } /* ★門をかけるのは「新しく繋ぐ入口」だけ */ const isNew = (u.pathname === '/api/join' || u.pathname === '/ws' || u.pathname === '/api/bug'); if (isNew){ if (env.RL){ try{ const ip = req.headers.get('CF-Connecting-IP') || 'x'; const { success } = await env.RL.limit({ key: ip }); if (!success) return J({ busy:1, why:'rate', msg:'ただいま混み合っています' }, 429); }catch(e){} } if (await tooMuch(env)) return J({ busy:1, why:'month', msg:'ただいま混み合っています' }, 503); } if (u.pathname === '/api/join'){ const r = await env.LOBBY.get(env.LOBBY.idFromName('hall')).fetch('https://do/claim'); return new Response(r.body, { status:r.status, headers:{'content-type':'application/json; charset=utf-8'} }); } if (u.pathname === '/ws'){ const room = (u.searchParams.get('r')||'').replace(/[^a-z0-9]/gi,'').slice(0,24); if (!room) return new Response('no room', { status:400 }); return env.GAME.get(env.GAME.idFromName(room)).fetch(req); } if (u.pathname === '/api/bug'){ return env.BUG.get(env.BUG.idFromName('box')).fetch(req); } /* 部屋の様子を覗く窓(点検用)。 ★ここは**人の気配を更新しない**。見ているこちらが部屋を延命させないため (DoSガードの落とし穴。C:\五月雨開発\DoSガード.md に書いてある) */ if (u.pathname === '/api/room'){ const room = (u.searchParams.get('r')||'').replace(/[^a-z0-9]/gi,'').slice(0,24); if (!room) return J({ ok:0 }, 400); return env.GAME.get(env.GAME.idFromName(room)).fetch('https://do/count'); } if (env.ASSETS) return env.ASSETS.fetch(req); return new Response('not found', { status:404 }); } }; /* ===== 使った回数を数えるだけの入れ物 ===== */ export class Meter { constructor(state){ this.s = state; } async fetch(req){ const u = new URL(req.url), m = u.searchParams.get('m')||ym(); const key = 'n:'+m; if (u.pathname === '/add'){ const add = parseInt(u.searchParams.get('n')||'0',10)||0; const cur = (await this.s.storage.get(key))||0; await this.s.storage.put(key, cur+add); return J({ n: cur+add }); } return J({ n: (await this.s.storage.get(key))||0 }); } } /* ===== 相手探し(1個だけ) ===== 持っているのは「空いている部屋 1件」だけ。 ★案内する前に必ず今の人数を確かめる(回転寿司の実測コメントの通り。 確かめないと、強制終了で席が埋まったままの部屋へ送ってしまう) */ export class Lobby { constructor(state, env){ this.s = state; this.env = env; } async fetch(req){ const u = new URL(req.url), now = Date.now(); if (u.pathname === '/report'){ const r = u.searchParams.get('r')||'', n = parseInt(u.searchParams.get('n')||'0',10); const open = u.searchParams.get('o') === '1'; const w = await this.s.storage.get('waiting'); if (open && n === 1) await this.s.storage.put('waiting', { id:r, at:now }); else if (w && w.id === r) await this.s.storage.delete('waiting'); return J({ ok:1 }); } /* /claim */ const w = await this.s.storage.get('waiting'); if (w && now - w.at < 5*60*1000){ let n = 2, open = false; try{ const r = await this.env.GAME.get(this.env.GAME.idFromName(w.id)).fetch('https://do/count'); const j = await r.json(); n = j.n; open = !!j.open; }catch(e){} if (open && n === 1){ await this.s.storage.delete('waiting'); return J({ room:w.id, join:1 }); } /* 取った直後でまだ誰も繋いでいない部屋だけ、20秒間は使い回す */ if (n === 0 && now - w.at < 20000) return J({ room:w.id, join:0 }); await this.s.storage.delete('waiting'); } const id = 'g' + now.toString(36) + Math.random().toString(36).slice(2,6); await this.s.storage.put('waiting', { id, at:now }); return J({ room:id, join:0 }); } } /* ===== 1プレイ = 1部屋 ===== */ const TICK_MS = 80; /* 1秒に12.5回 */ const IDLE_MS = 180000; /* ★誰も繋がっていない状態がこれだけ続いたら畳む(3分) */ const STALE_MS = 60000; /* 合図が来なくなった接続を切る */ const MSG_MIN = 45; /* 1本の繋がりが1秒に受け付ける通の上限の元(45通/秒) */ export class Game { constructor(state, env){ this.s = state; this.env = env; this.socks = new Set(); this.w = null; this.timer = null; this.last = 0; this.emptyAt = Date.now(); this.reported = -1; /* ★自分の部屋の名前。**内側の番号(state.id)ではなく名前**を相手探しへ伝える。 2026-09-13、ここで番号を伝えていたせいで、相手探しが `idFromName(番号)` という**別の空っぽの部屋**へ案内していた (2人で開いても永久に合流しなかった。実測で捕まえた) */ this.name = ''; } world(){ if (!this.w) this.w = new ZW.World({ sim:'full' }); return this.w; } info(ws){ return ws.__zg || (ws.__zg = {}); } send(ws, o){ try{ ws.send(JSON.stringify(o)); }catch(e){} } all(o, except){ for (const w of this.socks) if (w !== except) this.send(w, o); } players(){ let n=0; for (const w of this.socks) if (this.info(w).pid) n++; return n; } isOpen(){ return this.w ? (!this.w.over && this.players() < 2) : true; } async fetch(req){ const u = new URL(req.url); const rn = (u.searchParams.get('r')||'').replace(/[^a-z0-9]/gi,'').slice(0,24); if (rn) this.name = rn; /* tick=時計が回っているか。**人が0人なら必ずfalse**でなければならない(空の部屋が回り続けない証拠) */ if (u.pathname === '/count') return J({ n: this.players(), open: this.isOpen(), tick: !!this.timer }); if (req.headers.get('Upgrade') !== 'websocket') return new Response('ws only', { status:426 }); const dev = (u.searchParams.get('id')||'').replace(/[^a-z0-9]/gi,'').slice(0,40); const pair = new WebSocketPair(); const client = pair[0], server = pair[1]; /* ★hibernationは使わない。時計(setInterval)を回し続けたいので普通のacceptにする */ server.accept(); /* 同じ端末の古い繋がりを先に追い出す(幽霊が席を持ったまま満席になるのを防ぐ) */ for (const w of Array.from(this.socks)){ if (dev && this.info(w).dev === dev){ try{ w.close(4001,'same-device'); }catch(e){} this.gone(w); } } if (!this.isOpen()){ this.send(server, { y:'full' }); try{ server.close(1000,'full'); }catch(e){} return new Response(null, { status:101, webSocket: client }); } const pid = 'p' + (Date.now()%100000).toString(36) + Math.random().toString(36).slice(2,5); server.__zg = { pid, dev, t: Date.now(), n:0, nt:0 }; this.socks.add(server); this.emptyAt = 0; server.addEventListener('message', (ev) => this.onMsg(server, ev.data)); server.addEventListener('close', () => this.gone(server)); server.addEventListener('error', () => this.gone(server)); const w = this.world(); w.addPlayer(pid, null, null); this.send(server, { y:'w', me:pid, snap:w.snap(), C:{ } }); this.start(); this.report(); return new Response(null, { status:101, webSocket: client }); } onMsg(ws, raw){ const a = this.info(ws); /* 1本の繋がりの連打を断る(盾1の内側の守り) */ const now = Date.now(); if (now - a.nt > 1000){ a.nt = now; a.n = 0; } if (++a.n > MSG_MIN) return; a.t = now; let d; try{ d = JSON.parse(raw); }catch(e){ return; } const w = this.world(), p = w.find(a.pid); if (!p) return; switch (d.y){ case 'ping': this.send(ws, { y:'pong', t:d.t }); break; case 'me': /* 見た目(アバターと買った物)。数字しか受け取らない */ p.av = { h:(d.h|0)&7, hc:(d.hc|0)&7, sk:(d.sk|0)%4, w:(d.w|0)%8 }; p.look = { c:Math.max(0,Math.min(80,d.c|0)), ht:Math.max(0,Math.min(40,d.ht|0)), st:Math.max(0,Math.min(20,d.st|0)) }; break; case 'hit': { const k = (d.k==='kill'||d.k==='knock'||d.k==='crow'||d.k==='rock') ? d.k : null; if (k) w.hit(a.pid, k, d.i|0); break; } case 'swap': w.swap(); break; case 'rock': w.useRock(a.pid); break; } } gone(ws){ if (!this.socks.has(ws)) return; const a = this.info(ws); this.socks.delete(ws); if (this.w && a.pid) this.w.rmPlayer(a.pid); if (this.socks.size === 0){ this.emptyAt = Date.now(); this.stop(); } this.report(); } start(){ if (this.timer) return; this.last = Date.now(); this.timer = setInterval(() => this.tick(), TICK_MS); /* ★目覚ましは「畳むため」だけに使う。遊びの進行には使わない */ this.s.storage.setAlarm(Date.now() + IDLE_MS + 5000).catch(()=>{}); } stop(){ if (this.timer){ clearInterval(this.timer); this.timer = null; } } tick(){ /* ★人が0人なら1コマも進めない */ if (this.socks.size === 0){ this.stop(); return; } const now = Date.now(); const dt = Math.min(0.25, (now - this.last)/1000); this.last = now; /* 合図の来なくなった繋がりを切る */ for (const w of Array.from(this.socks)){ if (now - (this.info(w).t||now) > STALE_MS){ try{ w.close(1001,'stale'); }catch(e){} this.gone(w); } } if (this.socks.size === 0){ this.stop(); return; } const w = this.world(); w.step(dt); this.all(w.snap()); if (w.over){ /* 終わったら部屋は使い回さない。相手探しからも外す */ this.report(true); } } async report(force){ if (!this.name) return; const n = this.players(), open = this.isOpen(); const key = n + (open?'o':'x'); if (!force && key === this.reported) return; this.reported = key; try{ await this.env.LOBBY.get(this.env.LOBBY.idFromName('hall')) .fetch('https://do/report?r='+this.name+'&n='+n+'&o='+(open?1:0)); }catch(e){} } /* ★誰も繋がっていない部屋を畳む門。ここが無いと空の部屋が回り続ける */ async alarm(){ if (this.socks.size > 0){ await this.s.storage.setAlarm(Date.now() + IDLE_MS + 5000).catch(()=>{}); return; } if (this.emptyAt && Date.now() - this.emptyAt >= IDLE_MS){ this.stop(); this.w = null; await this.s.storage.deleteAll().catch(()=>{}); await this.s.storage.deleteAlarm().catch(()=>{}); return; } await this.s.storage.setAlarm(Date.now() + 30000).catch(()=>{}); } } /* ===== バグ報告の箱(平成ゾンビ学園から相続した🐛の受け先) ===== */ const BUG_MAX = 900*1024, BUG_KEEP = 200; export class Bug { constructor(state){ this.s = state; } async fetch(req){ const u = new URL(req.url); if (req.method === 'POST'){ const body = await req.text(); if (body.length > BUG_MAX) return J({ ok:0, why:'big' }, 413); const id = Date.now().toString(36) + Math.random().toString(36).slice(2,6); await this.s.storage.put('b:'+id, { at:Date.now(), body }); const list = (await this.s.storage.get('list'))||[]; list.unshift(id); while (list.length > BUG_KEEP){ const old = list.pop(); await this.s.storage.delete('b:'+old); } await this.s.storage.put('list', list); return J({ ok:1, id }); } const id = u.searchParams.get('id'); if (id){ if (u.searchParams.get('del') === '1'){ await this.s.storage.delete('b:'+id); const l = ((await this.s.storage.get('list'))||[]).filter(x=>x!==id); await this.s.storage.put('list', l); return J({ ok:1 }); } const r = await this.s.storage.get('b:'+id); if (!r) return J({ ok:0 }, 404); return new Response(r.body, { headers:{'content-type':'application/json; charset=utf-8'} }); } const list = (await this.s.storage.get('list'))||[]; const out = []; for (const x of list.slice(0,60)){ const r = await this.s.storage.get('b:'+x); if (r) out.push({ id:x, at:r.at, bytes:r.body.length }); } return J(out); } }