src/worker.js

Workerルーティング・Game/Lobby/Meter/Bug Durable Objects・WebSocket・tick

349行 · 15,352 bytes · SHA-256 39727b7c8659e443ef66a0fa2a52f3ec2945df5c8298d9749de8cb5f36b74365

分割 1/2 分割 2/2
/* ===== 石とゾンビ サーバー(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);
  }
}