src/worker.js
Workerルーティング・Game/Lobby/Meter/Bug Durable Objects・WebSocket・tick
/* ===== 石とゾンビ サーバー(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);
}
}