src/worker.js

分割 1 / 2 · 元ファイル 1〜230行付近

原文の連続部分です。長い圧縮行は行の途中で分割する場合があります。全分割を順に連結するとファイル全文になります。

/* ===== 石とゾンビ サーバー(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':