/* 2つの端末で本当に繋がるかを確かめる道具(2026-09-13)。 ブラウザを2枚開いて、片方が倒した物がもう片方に届くか・スクラップが共有されるか・ 抜けても壊れないかを、数字で確かめる。写真も撮る。 使い方: node tools/two.mjs [--url https://...] [--local] */ let webkit; try { ({ webkit } = await import('playwright')); } catch (e) { ({ webkit } = await import('file:///C:/平成ゾンビ学園/node_modules/playwright/index.mjs')); } import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const opt = (n,d)=>{ const i=argv.indexOf('--'+n); return i>=0?argv[i+1]:d; }; const URL_ = opt('url', 'https://ishi-to-zombie.kaiten-sushi-online.workers.dev/'); const wait = ms => new Promise(r=>setTimeout(r,ms)); const browser = await webkit.launch(); async function openOne(name){ const ctx = await browser.newContext({ viewport:{width:390,height:797} }); const page = await ctx.newPage(); const errs = []; page.on('pageerror', e=>errs.push(name+': '+String(e.message).slice(0,140))); await page.goto(URL_, { waitUntil:'load' }); await page.waitForTimeout(900); return { name, ctx, page, errs }; } const st = p => p.page.evaluate(()=>window.ZG.state()); const line = (tag,s)=> tag+' 通信='+s.net+' 部屋='+(s.room||'—')+' 人数='+s.players+ ' ゾンビ='+s.zombies+' 倒した='+s.kills+' スクラップ='+s.base+' HP='+s.hp; console.log('相手: '+URL_+'\n'); const A = await openOne('A'); await A.page.evaluate(()=>window.ZG.start()); await wait(2500); let sa = await st(A); console.log(line('A ひとりで開始 ', sa)); if (sa.net !== 'online'){ console.log(' ← つながっていない。ここで止める'); await browser.close(); process.exit(1); } const B = await openOne('B'); await B.page.evaluate(()=>window.ZG.start()); await wait(2800); sa = await st(A); let sb = await st(B); console.log(line('B が途中参加 ', sb)); console.log(line(' その時のA ', sa)); const two = (sa.players===2 && sb.players===2); console.log(two ? ' → 2人になった(モード選択なしで合流できている)' : ' ← 2人になっていない'); /* 同じ部屋か・ゾンビが揃っているか */ console.log('\n同じ部屋: ' + (sa.room===sb.room ? 'はい ('+sa.room+')' : 'いいえ ('+sa.room+' / '+sb.room+')')); const zz = await Promise.all([A,B].map(p=>p.page.evaluate(()=>window.ZG.zs().map(z=>+z.m.toFixed(2))))); console.log('Aから見たゾンビの位置: '+JSON.stringify(zz[0])); console.log('Bから見たゾンビの位置: '+JSON.stringify(zz[1])); /* ★スクラップの共有: Aが倒した分がBにも入るか(K1「キルの取り合いを発生させない」) */ const b0 = { a:sa.base, b:sb.base, k:sa.kills }; await A.page.evaluate(()=>window.ZG.hitNearest('kill')); await wait(1400); sa = await st(A); sb = await st(B); console.log('\nAが1体倒した(石は同じ道を通してサーバーへ出している)'); console.log(line(' A ', sa)); console.log(line(' B ', sb)); console.log((sa.kills>b0.k && sa.base>b0.a && sb.base>b0.b) ? ' → 倒した数もスクラップも**2人に同じだけ**入った(取り合いにならない)' : ' ← 共有できていない'); /* Bが倒した分もAへ入るか */ const b1 = { a:sa.base, b:sb.base }; await B.page.evaluate(()=>window.ZG.hitNearest('kill')); await wait(1400); sa = await st(A); sb = await st(B); console.log('こんどはBが1体倒した → A '+b1.a+'→'+sa.base+' / B '+b1.b+'→'+sb.base); /* 横から当てるとノックバックだけ(芯が2人でも効いているか) */ const zb = (await A.page.evaluate(()=>window.ZG.zs()))[0]; if (zb){ await A.page.evaluate(()=>window.ZG.hitNearest('knock')); await wait(1200); const za = (await A.page.evaluate(()=>window.ZG.zs()))[0]; console.log('横から当てた: ゾンビの位置 '+zb.m.toFixed(2)+'m → '+(za?za.m.toFixed(2)+'m':'—')+ (za && za.m>zb.m ? ' → 押し戻されただけ(倒れていない)' : ' ← おかしい')); } /* 交代 */ const sw = await A.page.evaluate(()=>window.ZG.swap()); await wait(1000); const pa = sa.px, pb = sb.px; sa = await st(A); sb = await st(B); console.log('\n前後交代をAが押した: '+(sw?'受け付けた':'今は押せない')); console.log(' Aの立ち位置 '+Math.round(pa)+'px → '+Math.round(sa.px)+'px / '+ 'Bの立ち位置 '+Math.round(pb)+'px → '+Math.round(sb.px)+'px'); console.log((sa.px!==pa && sb.px!==pb) ? ' → 2人とも前後が入れ替わった' : ' ← 入れ替わっていない'); /* 写真 */ fs.mkdirSync(path.join(ROOT,'tmp'),{recursive:true}); await A.page.screenshot({ path: path.join(ROOT,'tmp/two_A.png') }); await B.page.screenshot({ path: path.join(ROOT,'tmp/two_B.png') }); console.log('\n写真: tmp/two_A.png / tmp/two_B.png'); /* Bが抜ける → Aは壊れずに続くか */ await B.ctx.close(); await wait(2500); sa = await st(A); console.log('\nBが抜けたあと'); console.log(line(' A ', sa)); console.log(sa.players===1 && sa.net==='online' ? ' → ひとりに戻って続いている(壊れていない)' : ' ← おかしい'); await A.page.screenshot({ path: path.join(ROOT,'tmp/two_A_alone.png') }); /* Aも抜ける → 部屋が使い回されないか(相手探しから外れたか) */ const room = sa.room; await A.ctx.close(); await wait(1500); const C = await openOne('C'); await C.page.evaluate(()=>window.ZG.start()); await wait(2500); const sc = await st(C); console.log('\n全員抜けたあとに新しい人が来た'); console.log(line(' C ', sc)); console.log(sc.room!==room ? ' → 前の部屋は配られない(畳まれる部屋へ案内しない)' : ' ← 前の部屋がまた配られた'); await C.ctx.close(); /* ★誰も繋がっていない部屋が回り続けていないか(2026-09-13にぷっちーずで実際に起きた穴) */ await wait(4000); try{ const r = await fetch(URL_.replace(/\/$/,'')+'/api/room?r='+room); const j = await r.json(); console.log('\n誰もいない部屋のようす: 人数='+j.n+' / 時計='+(j.tick?'まだ回っている':'止まっている')); console.log((!j.n && !j.tick) ? ' → 空の部屋は1コマも進めていない(3分後に畳まれる)' : ' ← 空なのに回っている。直すこと'); }catch(e){ console.log('\n部屋のようすを見られなかった: '+e.message); } /* DoSガードの門がいくつまで来ているか */ try{ const m = await (await fetch(URL_.replace(/\/$/,'')+'/api/meter')).json(); console.log('DoSガード(盾2): '+m['今いくつ']+' / 上限 '+m['上限']); }catch(e){} const errs = [...A.errs, ...B.errs, ...C.errs]; console.log('\nJSエラー: '+(errs.length?errs.join(' / '):'なし')); await browser.close(); process.exit(0);