Created
May 31, 2026 10:59
-
-
Save jun-lsh/78b7298927a267b7d27bf9ab3f254cf0 to your computer and use it in GitHub Desktop.
claire code boat movemint script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """2bird2can — voyage: wind-pump + leapfrog relay in one process. | |
| The loop (the original strategy, just with correct boat-id tracking): | |
| 1. spawn a boat; let it ride the leaked wind until it gets STUCK | |
| 2. spawn a NEW boat (BOTH alive now -> the new one anchors to the old frontier, | |
| spawning ~10u away via GetSpawnPositionForJoin) | |
| 3. after a short delay (--handoff-ms), during which we LEARN the new boat's id, | |
| kill the old boat (so the new one becomes the oldest and the NEXT spawn | |
| anchors to it -> the frontier walks forward) | |
| 4. the new boat is now the frontier; let it ride until it stucks; repeat | |
| We ALWAYS commit to the new boat. It may spawn straight onto a rock and be stuck | |
| immediately — no harm, the monitor just detects that and we hand off again. The | |
| ~10u spawn offset random-walks the anchor, which is what eventually clears a rock | |
| pocket. No reroll / no "must be moving to commit" gate. | |
| The ONLY clever bit is identifying the new boat's id at handoff (this is what v1 | |
| got wrong, blending all boats / latching None). We pick the new id (!= old) that | |
| spawned closest to the anchor and traces smoothly. Per-boat isolation by id | |
| (bytes --boat-offset.., from boat_id.py) keeps tracking clean; filter_id(None) | |
| returns [] so an unidentified frontier never blends; a >--max-jump step is | |
| dropped so a misaligned/teleport packet can't pollute the track. | |
| boat0 is the one special case: it spawns at origin where parked junk also sits, | |
| so it must be seen MOVING (not just identified) to confirm. | |
| This runs the leak phase ITSELF (no separate windpump), then drains and sails — | |
| all under ONE wall clock, so the t+/avg/ETA readouts include the pump time and | |
| reflect your real budget. The pump auto-aims at the bearing from the origin | |
| (boats spawn at 0,0) to --flag unless you pass an explicit --target. | |
| If dist-to-flag climbs during travel, the wind is aimed wrong; re-run with a | |
| corrected --target (or --skip-pump if the wind is already leaked). | |
| Usage: | |
| python voyage.py --flag "117368 56984" [--until-wind 110] [--headless] | |
| python voyage.py --flag "117368 56984" --skip-pump # wind already leaked | |
| """ | |
| import argparse | |
| import asyncio | |
| import math | |
| import time | |
| FLAG_HASH_HEX = "cfbf2907" # FlagChest.SendFlagClientRpc hash 0x0729BFCF, little-endian | |
| INIT_JS = r""" | |
| (() => { | |
| if (window._uiPatched) return; | |
| window._uiPatched = true; | |
| const wrap = (fn)=>{ if(fn&&fn.__wrapped) return fn; | |
| const w=function(){ return fn.apply(this,arguments).then(i=>{window.unityInstance=i;return i;}); }; | |
| w.__wrapped=true; return w; }; | |
| const tryWrap=()=>{ const f=window.createUnityInstance; | |
| if(typeof f==='function'&&!f.__wrapped){ try{window.createUnityInstance=wrap(f);}catch(e){} } }; | |
| tryWrap(); | |
| const _iv=setInterval(()=>{ tryWrap(); if(window.unityInstance) clearInterval(_iv); },1); | |
| setTimeout(()=>clearInterval(_iv),60000); | |
| window._raw = []; // [x,y,t_ms,nid] position points (this connection's view) | |
| window._gotFlagHex = null; | |
| window._exploiting = false; // pump pages set this true -> the send-hook leaks wind & closes | |
| // ---- protocol-correct position parse (from NetworkVariableDeltaMessage.Serialize) ---- | |
| // body: bp NetObjId(u64), bp behaviourIndex(u16), +4 NetworkDelivery, then per | |
| // variable a 1-byte presence bool followed by the value if set. index 0 = position | |
| // (Vec2, 8B), index 1 = facingAngle (float, 4B). We read index 0 only. | |
| function _bpU64(u8, pos){ const b0=u8[pos], n=b0&7; | |
| if(n===0||pos+n>u8.length) return null; | |
| let v=0; for(let k=0;k<n;k++) v += u8[pos+k]*Math.pow(256,k); | |
| return [Math.floor(v/8), pos+n]; } | |
| function _bpU16(u8, pos){ const b0=u8[pos], n=b0&3; | |
| if(n===1) return [b0>>2, pos+1]; | |
| if(n===2) return [((u8[pos+1]<<8)|b0)>>2, pos+2]; | |
| if(n===3) return [u8[pos+1]|(u8[pos+2]<<8), pos+3]; | |
| return null; } | |
| // TWO MODES. Discovery (no _trackNid): BROAD scan -> every id incl. garbage, but | |
| // catches the fast boat's BATCHED travel frames so we can identify it WHILE it's | |
| // still moving (Python rejects garbage by requiring a smooth, moving track). | |
| // Tracking (_trackNid set): scan for THAT id only -> dense + garbage-free. | |
| function _scanAll(u8){ | |
| const out=[]; const dv=new DataView(u8.buffer,u8.byteOffset,u8.byteLength); | |
| const hi=u8.length-12; | |
| for(let S=0;S<=hi;S++){ | |
| let r=_bpU64(u8,S); if(!r) continue; const nid=r[0]; let pos=r[1]; | |
| if(nid<1||nid>100000) continue; | |
| r=_bpU16(u8,pos); if(!r) continue; pos=r[1]; // behaviourIndex | |
| pos+=4; // NetworkDelivery (targetVersion>0) | |
| // variable section: per-var 1-byte presence bool (NGO non-DA). index 0 = position (Vec2). | |
| if(pos>=u8.length) continue; | |
| const present0=u8[pos]; pos+=1; | |
| if(present0!==1) continue; // position not in this delta -> skip | |
| if(pos+8>u8.length) continue; | |
| const x=dv.getFloat32(pos,true), y=dv.getFloat32(pos+4,true); | |
| if(!Number.isFinite(x)||!Number.isFinite(y)||Math.abs(x)>5e5||Math.abs(y)>5e5) continue; | |
| out.push([x,y,nid]); | |
| } | |
| return out; | |
| } | |
| function _scanFor(u8, target){ | |
| const out=[]; const dv=new DataView(u8.buffer,u8.byteOffset,u8.byteLength); | |
| const hi=u8.length-12; | |
| for(let S=0;S<=hi;S++){ | |
| let r=_bpU64(u8,S); if(!r) continue; if(r[0]!==target) continue; let pos=r[1]; | |
| r=_bpU16(u8,pos); if(!r) continue; pos=r[1]; // behaviourIndex | |
| pos+=4; // NetworkDelivery (targetVersion>0) | |
| // variable section: per-var 1-byte presence bool (NGO non-DA). index 0 = position (Vec2). | |
| if(pos>=u8.length) continue; | |
| const present0=u8[pos]; pos+=1; | |
| if(present0!==1) continue; // position not in this delta -> skip | |
| if(pos+8>u8.length) continue; | |
| const x=dv.getFloat32(pos,true), y=dv.getFloat32(pos+4,true); | |
| if(!Number.isFinite(x)||!Number.isFinite(y)||Math.abs(x)>5e5||Math.abs(y)>5e5) continue; | |
| out.push([x,y,target]); | |
| } | |
| return out; | |
| } | |
| const OrigWS=window.WebSocket; | |
| function Hook(url,p){ | |
| const ws=new OrigWS(url,p); window._lastWs=ws; | |
| // ---- wind-leak primitive (pump phase only): on the FIRST non-zero Move frame, | |
| // mark + close the socket within the disconnect grace so the input sticks in | |
| // _globalWindAccum. Inert unless window._exploiting is set on this page. ---- | |
| ws._gotMove=false; | |
| const realSend=OrigWS.prototype.send.bind(ws); | |
| ws.send=function(data){ | |
| let s8; | |
| if(data instanceof ArrayBuffer) s8=new Uint8Array(data); | |
| else if(ArrayBuffer.isView(data)) s8=new Uint8Array(data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength)); | |
| if(window._exploiting && ws._gotMove) return; // swallow extra sends post-leak | |
| if(window._exploiting && s8 && s8.length===70){ | |
| const hex=Array.from(s8).map(b=>b.toString(16).padStart(2,'0')).join(''); | |
| const i=hex.indexOf('d4edbb6c'); // Move RPC, LE hash | |
| if(i>=0 && hex.substr(i+8,16)!=='0000000000000000'){ ws._gotMove=true; setTimeout(()=>{try{ws.close();}catch(e){}},25); } | |
| } | |
| return realSend(data); | |
| }; | |
| ws.addEventListener('message', e=>{ | |
| let u8; | |
| if(e.data instanceof ArrayBuffer) u8=new Uint8Array(e.data); | |
| else if(ArrayBuffer.isView(e.data)) u8=new Uint8Array(e.data.buffer.slice(e.data.byteOffset,e.data.byteOffset+e.data.byteLength)); | |
| if(!u8) return; | |
| if(!window._gotFlagHex){ | |
| const hex=Array.from(u8).map(b=>b.toString(16).padStart(2,'0')).join(''); | |
| if(hex.includes('""" + FLAG_HASH_HEX + r"""')) window._gotFlagHex=hex; | |
| } | |
| const tn=window._trackNid; | |
| if(tn!=null){ | |
| for(const pp of _scanFor(u8, tn)) window._raw.push([pp[0],pp[1],performance.now(),String(pp[2])]); | |
| } else { | |
| for(const pp of _scanAll(u8)) window._raw.push([pp[0],pp[1],performance.now(),String(pp[2])]); | |
| } | |
| if(window._raw.length>2000) window._raw.splice(0, window._raw.length-2000); | |
| }); | |
| return ws; | |
| } | |
| Hook.prototype=OrigWS.prototype; Object.setPrototypeOf(Hook,OrigWS); | |
| Hook.OPEN=OrigWS.OPEN;Hook.CLOSED=OrigWS.CLOSED;Hook.CONNECTING=OrigWS.CONNECTING;Hook.CLOSING=OrigWS.CLOSING; | |
| window.WebSocket=Hook; | |
| })(); | |
| """ | |
| LOADER_APPEND = r""" | |
| ;(function(){ try { | |
| if (typeof createUnityInstance === 'function' && !createUnityInstance.__wrapped) { | |
| var __r=createUnityInstance; | |
| var __w=function(){ return __r.apply(this,arguments).then(function(i){window.unityInstance=i;return i;}); }; | |
| __w.__wrapped=true; createUnityInstance=__w; try{window.createUnityInstance=__w;}catch(e){} | |
| } | |
| } catch(e){} })(); | |
| """ | |
| def parse_xy(s): | |
| if not s: | |
| return None | |
| parts = s.replace(",", " ").split() | |
| try: | |
| return (float(parts[0]), float(parts[1])) if len(parts) == 2 else None | |
| except ValueError: | |
| return None | |
| def linreg(ts, vs): | |
| n = len(ts) | |
| if n < 2: | |
| return 0.0, (vs[-1] if vs else 0.0) | |
| mt = sum(ts) / n | |
| mv = sum(vs) / n | |
| den = sum((t - mt) ** 2 for t in ts) | |
| if den < 1e-12: | |
| return 0.0, mv | |
| s = sum((t - mt) * (v - mv) for t, v in zip(ts, vs)) / den | |
| return s, mv - s * mt | |
| # ---- pump aiming (from windpump.py): leak global wind toward a bearing using ONE | |
| # cardinal key per cycle; the error-feedback picker alternates cardinals so the | |
| # CUMULATIVE wind points where we want (the headless driver fumbles 2-key holds). | |
| SQ = 1.0 / math.sqrt(2.0) | |
| OCTANTS = [ | |
| ("E", ["d"], (1.0, 0.0)), | |
| ("N", ["w"], (0.0, 1.0)), | |
| ("W", ["a"], (-1.0, 0.0)), | |
| ("S", ["s"], (0.0, -1.0)), | |
| ] | |
| TOKEN_VEC = { | |
| "E": (1.0, 0.0), "N": (0.0, 1.0), "W": (-1.0, 0.0), "S": (0.0, -1.0), | |
| "NE": (SQ, SQ), "NW": (-SQ, SQ), "SE": (SQ, -SQ), "SW": (-SQ, -SQ), | |
| } | |
| def parse_target(s, fallback): | |
| if not s: | |
| return fallback | |
| s = s.strip() | |
| if s.upper() in TOKEN_VEC: | |
| return TOKEN_VEC[s.upper()] | |
| parts = s.replace(",", " ").split() | |
| try: | |
| if len(parts) == 2 and parts[0].lower() == "angle": | |
| a = math.radians(float(parts[1])) | |
| return (math.cos(a), math.sin(a)) | |
| if len(parts) == 2: | |
| x, y = float(parts[0]), float(parts[1]) | |
| m = math.hypot(x, y) | |
| if m > 1e-9: | |
| return (x / m, y / m) | |
| except ValueError: | |
| pass | |
| return fallback | |
| def read_target(path, fallback): | |
| try: | |
| with open(path) as f: | |
| return parse_target(f.read(), fallback) | |
| except OSError: | |
| return fallback | |
| def pick_octant(acc, target, n): | |
| ideal = (target[0] * (n + 1), target[1] * (n + 1)) | |
| err = (ideal[0] - acc[0], ideal[1] - acc[1]) | |
| if err == (0.0, 0.0): | |
| err = target | |
| return max(OCTANTS, key=lambda o: o[2][0] * err[0] + o[2][1] * err[1]) | |
| def filter_id(raw, only_id): | |
| # None means "no boat locked" -> NO data (never fall back to all = blend). | |
| if only_id is None: | |
| return [] | |
| return [p for p in raw if len(p) > 3 and p[3] == only_id] | |
| def clean_seq(pts, max_jump): | |
| """Drop points that jump > max_jump from the last kept point (kills single | |
| teleport/misaligned packets). pts are time-ordered (x,y,t,...).""" | |
| out = [] | |
| for p in pts: | |
| if not out or math.hypot(p[0] - out[-1][0], p[1] - out[-1][1]) <= max_jump: | |
| out.append(p) | |
| return out | |
| def boat_progress(raw, now, only_id, window_ms, max_jump, stale_ms): | |
| """Position/velocity for one boat, measured RELATIVE TO NOW. If the latest | |
| frame is older than stale_ms, the position NetVar hasn't gone dirty -> the | |
| boat hasn't moved -> report STATIC (v=0). This kills the v2 bug where a frozen | |
| boat kept replaying the last batch of moving frames forever (|v| stuck at | |
| e.g. 233 while pos and dist never changed), because the fit was anchored to | |
| the last frame's timestamp instead of the wall clock.""" | |
| mine = clean_seq(filter_id(raw, only_id), max_jump) | |
| if not mine: | |
| return None | |
| lx, ly, last_t = mine[-1][0], mine[-1][1], mine[-1][2] | |
| age = now - last_t | |
| recent = [(x, y, t) for (x, y, t, *_) in mine if now - t <= window_ms] | |
| if age > stale_ms or len(recent) < 2: | |
| # no fresh frames -> position NetVar isn't dirtying -> boat is static | |
| return {"x": lx, "y": ly, "vx": 0.0, "vy": 0.0, "n": len(recent), | |
| "age": age, "disp": 0.0, "span_ms": 0.0, "static": True} | |
| ts = [t / 1000.0 for (_, _, t) in recent] | |
| xs = [x for (x, _, _) in recent] | |
| ys = [y for (_, y, _) in recent] | |
| vx, _ = linreg(ts, xs) | |
| vy, _ = linreg(ts, ys) | |
| return {"x": xs[-1], "y": ys[-1], "vx": vx, "vy": vy, "n": len(recent), | |
| "age": age, "disp": math.hypot(xs[-1] - xs[0], ys[-1] - ys[0]), | |
| "span_ms": recent[-1][2] - recent[0][2], "static": False} | |
| def confirm_pick(raw, exclude, anchor, radius, min_frames, min_move, max_jump): | |
| """boat0-at-origin confirm: the real boat is the one actually MOVING (parked | |
| junk doesn't exist anymore now that we parse real positions, but requiring | |
| motion still cleanly picks the boat we just spawned and launched). Newest id | |
| that isn't `exclude`, >= min_frames, smooth, displacement >= min_move. | |
| (anchor/radius kept in signature but unused — id is real, no geometry needed.)""" | |
| by = {} | |
| for p in raw: | |
| if len(p) > 3 and p[3] != exclude: | |
| by.setdefault(p[3], []).append(p) | |
| best = None | |
| for idh, pts in by.items(): | |
| pts = sorted(pts, key=lambda q: q[2]) | |
| if len(pts) < min_frames: | |
| continue | |
| steps = [math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1)] | |
| if steps and max(steps) > max_jump: | |
| continue | |
| disp = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1]) | |
| if disp < min_move: | |
| continue | |
| ni = _nid_int(idh) | |
| if best is None or ni > best[0]: | |
| best = (ni, idh) | |
| return best[1] if best else None | |
| def _nid_int(idh): | |
| try: | |
| return int(idh) | |
| except (TypeError, ValueError): | |
| return -1 | |
| def identify_pick(raw, exclude, anchor, radius, min_frames, max_jump, min_travel=40.0): | |
| """Identify the just-spawned boat from BROAD-scan discovery data (which contains | |
| (0,0)/random garbage ids). The real traveling boat is the one whose track is | |
| SMOOTH and actually MOVED (displacement >= min_travel) — garbage sits at ~(0,0) | |
| with zero displacement and gets rejected. Among qualifying ids, pick the one with | |
| the most frames (the dominant real boat). This catches a fast boat WHILE it's | |
| still moving, so we can lock onto it before it wedges. (anchor/radius unused.)""" | |
| by = {} | |
| for p in raw: | |
| if len(p) > 3 and p[3] != exclude: | |
| by.setdefault(p[3], []).append(p) | |
| best = None # (nframes, idh, pts) | |
| for idh, pts in by.items(): | |
| pts = sorted(pts, key=lambda q: q[2]) | |
| if len(pts) < min_frames: | |
| continue | |
| steps = [math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1)] | |
| if steps and max(steps) > max_jump: | |
| continue # teleporter / misaligned packet | |
| disp = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1]) | |
| if disp < min_travel: | |
| continue # not moving -> garbage (0,0) jitter | |
| if best is None or len(pts) > best[0]: | |
| best = (len(pts), idh, pts) | |
| return (0.0, best[1], best[2]) if best else None | |
| def flag_from_hex(hex_str): | |
| try: | |
| raw = bytes.fromhex(hex_str) | |
| except ValueError: | |
| return None | |
| ascii_run = "".join(chr(b) if 32 <= b < 127 else "." for b in raw) | |
| i = ascii_run.find("bbb{") | |
| if i >= 0: | |
| j = ascii_run.find("}", i) | |
| if j >= 0: | |
| return ascii_run[i:j + 1] | |
| return ascii_run | |
| class Boat: | |
| def __init__(self, page, n, boat_id=None): | |
| self.page, self.n, self.boat_id = page, n, boat_id | |
| self.connected_at = time.time() | |
| self.x = self.y = None | |
| async def main(): | |
| from playwright.async_api import async_playwright | |
| ap = argparse.ArgumentParser(description="2bird2can leapfrog relay (candidate-commit)") | |
| ap.add_argument("--flag", default=None, help='flag world pos "X Y" for distance readout') | |
| ap.add_argument("--wind", type=float, default=0.0, help="expected |wind| (speed sanity)") | |
| ap.add_argument("--url", default="http://localhost:3000") | |
| # ---- pump (leak) phase ---- | |
| ap.add_argument("--target", default=None, | |
| help="pump aim: octant (NE), vector ('117368 56984'), or 'angle 26'. " | |
| "default = bearing from origin to --flag") | |
| ap.add_argument("--pump-workers", type=int, default=3, | |
| help="parallel pump slots during the leak phase (<=4; they close before travel)") | |
| ap.add_argument("--until-wind", type=float, default=110.0, | |
| help="pump until |wind| reaches this (0 = use --pump-secs)") | |
| ap.add_argument("--pump-secs", type=float, default=0.0, | |
| help="pump for this many seconds instead of a wind target (0 = use --until-wind)") | |
| ap.add_argument("--drain", type=float, default=1.2, | |
| help="sec after the pump for the disconnect grace so leaked wind persists") | |
| ap.add_argument("--target-file", default=None, help="optional live re-aim file for the pump") | |
| ap.add_argument("--skip-pump", action="store_true", default=False, | |
| help="skip the leak phase (wind already leaked from a previous run)") | |
| ap.add_argument("--every", type=float, default=350.0, help="monitor interval ms") | |
| ap.add_argument("--stale-ms", type=float, default=800.0, help="no new frame this long => frozen") | |
| ap.add_argument("--min-progress", type=float, default=12.0, help="frontier displacement over fit window below this => stuck") | |
| ap.add_argument("--settle-ms", type=float, default=1500.0, help="grace after commit before stuck can fire") | |
| ap.add_argument("--confirm-ms", type=float, default=2500.0, help="boat0 only: window to confirm a MOVING boat at origin") | |
| ap.add_argument("--handoff-ms", type=float, default=1000.0, help="both-alive window to learn the new boat's id before killing the old") | |
| ap.add_argument("--ident-frames", type=int, default=2, help="min frames to identify the freshly-spawned boat at handoff") | |
| ap.add_argument("--fit-window", type=float, default=1500.0, help="ms of frames for velocity/progress") | |
| ap.add_argument("--msg-start", type=int, default=48, help="byte offset of the position message body in the frame (from derive_layout.py)") | |
| ap.add_argument("--learn-radius", type=float, default=150.0, | |
| help="max MIN-distance of the new boat's track from the anchor (it spawns ~10u away; " | |
| "keeps us from grabbing a far-off non-boat frame)") | |
| ap.add_argument("--confirm-frames", type=int, default=4, help="boat0 only: min frames to confirm") | |
| ap.add_argument("--confirm-move", type=float, default=20.0, help="boat0 only: min displacement to count as moving") | |
| ap.add_argument("--max-jump", type=float, default=3000.0, help="single-step jump above this = misaligned/teleport, dropped (raise for very fast/sparse boats)") | |
| ap.add_argument("--min-travel", type=float, default=40.0, help="min track displacement to accept an id during discovery (rejects (0,0) garbage)") | |
| ap.add_argument("--reach", type=float, default=8.0, help="stop handing off / park if within this of flag") | |
| ap.add_argument("--headless", action="store_true", default=False) | |
| args = ap.parse_args() | |
| flag = parse_xy(args.flag) | |
| async def wrap_loader(route): | |
| try: | |
| resp = await route.fetch(); body = await resp.text() | |
| await route.fulfill(status=resp.status, content_type="application/javascript", | |
| body=body + LOADER_APPEND) | |
| except Exception: | |
| try: await route.continue_() | |
| except Exception: pass | |
| async with async_playwright() as p: | |
| browser = await p.chromium.launch(headless=args.headless) | |
| session_start = time.time() # ONE clock for pump + drain + travel | |
| # pump aim: explicit --target wins; else bearing from origin (spawn) to flag | |
| if args.target: | |
| pump_dir = parse_target(args.target, (SQ, SQ)) | |
| elif flag: | |
| _m = math.hypot(*flag) | |
| pump_dir = (flag[0] / _m, flag[1] / _m) if _m > 1e-9 else (SQ, SQ) | |
| else: | |
| pump_dir = (SQ, SQ) | |
| async def pump_phase(direction): | |
| """Leak global wind toward `direction` until --until-wind / --pump-secs, | |
| on N independent slots, then close + drain so the wind persists. Shares | |
| session_start, so the leak time counts against the budget.""" | |
| acc = [0.0, 0.0] | |
| stats = {"n": 0, "fails": 0, "stop": False} | |
| slots = [] | |
| for wid in range(args.pump_workers): | |
| pctx = await browser.new_context(viewport={"width": 800, "height": 600}) | |
| await pctx.add_init_script(INIT_JS) | |
| await pctx.route("**/*loader.js", wrap_loader) | |
| pg = await pctx.new_page() | |
| pg.on("pageerror", lambda e: None) | |
| await pg.goto(args.url) | |
| ok = False | |
| for _ in range(80): | |
| if await pg.evaluate("() => !!window.unityInstance"): | |
| await pg.evaluate("() => { window._exploiting = true; }") | |
| ok = True | |
| break | |
| await asyncio.sleep(0.5) | |
| if ok: | |
| slots.append((wid, pg, pctx)) | |
| else: | |
| print(f"[pump w{wid}] failed to load Unity; skipping", flush=True) | |
| await pctx.close() | |
| if not slots: | |
| print("[pump] no slots loaded — is the stack up? skipping leak phase.", flush=True) | |
| return | |
| deadline = (time.time() + args.pump_secs) if args.pump_secs else None | |
| bearing0 = math.degrees(math.atan2(direction[1], direction[0])) | |
| goal = (f"|wind|>={args.until_wind}" if args.until_wind else f"{args.pump_secs:.0f}s") | |
| print(f"[pump] leaking toward {bearing0:+.1f}deg on {len(slots)} slot(s) until {goal}...", flush=True) | |
| async def pworker(wid, pg): | |
| await asyncio.sleep(wid * 0.7) # stagger to ease the 4-player cap | |
| while not stats["stop"]: | |
| if args.until_wind and math.hypot(*acc) >= args.until_wind: | |
| break | |
| if deadline and time.time() >= deadline: | |
| break | |
| tgt = read_target(args.target_file, direction) if args.target_file else direction | |
| name, keys, vec = pick_octant(acc, tgt, stats["n"]) | |
| try: | |
| await pg.evaluate("() => window.unityInstance && window.unityInstance.SendMessage('MainMenu','HandleConnect')") | |
| opened = False | |
| for _ in range(40): | |
| if (await pg.evaluate("() => window._lastWs && window._lastWs.readyState")) == 1: | |
| opened = True | |
| break | |
| await asyncio.sleep(0.1) | |
| if not opened: | |
| raise RuntimeError("ws never opened (cap hit?)") | |
| await asyncio.sleep(1.4) | |
| await pg.focus("#unity-canvas") | |
| for k in keys: | |
| await pg.keyboard.down(k) | |
| await asyncio.sleep(0.25) | |
| for k in keys: | |
| await pg.keyboard.up(k) | |
| await asyncio.sleep(0.4) | |
| except Exception as e: | |
| stats["fails"] += 1 | |
| print(f" [pump w{wid}] cycle fail: {e}", flush=True) | |
| await asyncio.sleep(0.5) | |
| continue | |
| acc[0] += vec[0]; acc[1] += vec[1]; stats["n"] += 1 | |
| mag = math.hypot(*acc) | |
| el = time.time() - session_start | |
| print(f" [t+{el:6.0f}s pump w{wid}] cycle {stats['n']:>4} press {name:<2} " | |
| f"|wind|~{mag:6.1f} bearing {math.degrees(math.atan2(acc[1], acc[0])):+6.1f}deg " | |
| f"vel~{7*mag:7.0f} u/s (fails {stats['fails']})", flush=True) | |
| tasks = [asyncio.create_task(pworker(wid, pg)) for (wid, pg, _c) in slots] | |
| try: | |
| await asyncio.gather(*tasks) | |
| finally: | |
| stats["stop"] = True | |
| for t in tasks: | |
| t.cancel() | |
| for (_w, pg, _c) in slots: | |
| try: | |
| await pg.evaluate("() => { try { if (window._lastWs && window._lastWs.readyState <= 1) window._lastWs.close(); } catch(e){} window._exploiting=false; }") | |
| except Exception: | |
| pass | |
| await asyncio.sleep(args.drain) # disconnect grace -> wind sticks in _globalWindAccum | |
| for (_w, _pg, pctx) in slots: | |
| try: | |
| await pctx.close() | |
| except Exception: | |
| pass | |
| mag = math.hypot(*acc) | |
| bear = math.degrees(math.atan2(acc[1], acc[0])) if mag else 0.0 | |
| print(f"[pump] done: {stats['n']} cycles, |wind|~{mag:.1f} bearing {bear:+.1f}deg " | |
| f"vel~{7*mag:.0f} u/s; drained. sailing now.\n", flush=True) | |
| if not args.skip_pump: | |
| await pump_phase(pump_dir) | |
| else: | |
| print("[pump] skipped (--skip-pump); assuming wind already leaked.\n", flush=True) | |
| ctx = await browser.new_context(viewport={"width": 900, "height": 600}) | |
| await ctx.add_init_script(INIT_JS) | |
| await ctx.route("**/*loader.js", wrap_loader) | |
| async def load_page(): | |
| page = await ctx.new_page() | |
| page.on("pageerror", lambda e: None) | |
| await page.goto(args.url) | |
| for _ in range(80): | |
| if await page.evaluate("() => !!window.unityInstance"): | |
| return page | |
| await asyncio.sleep(0.5) | |
| return None | |
| async def connect(page, n): | |
| await page.evaluate("(cfg) => { window._raw=[]; window._gotFlagHex=null;" | |
| " window._msgStart=cfg.s; window._trackNid=null; }", | |
| {"s": args.msg_start}) | |
| await page.evaluate("() => window.unityInstance.SendMessage('MainMenu','HandleConnect')") | |
| for _ in range(40): | |
| if (await page.evaluate("() => window._lastWs && window._lastWs.readyState")) == 1: | |
| break | |
| await asyncio.sleep(0.1) | |
| return Boat(page, n) | |
| async def disconnect(page): | |
| try: | |
| await page.evaluate("() => { try { if (window._lastWs) window._lastWs.close(); } catch(e){} }") | |
| except Exception: | |
| pass | |
| async def set_track(page, idh): | |
| """Lock the WS hook to ONE NetObjId -> dense, garbage-free tracking | |
| (id-locked scan). None = discovery mode (clean fixed-offset parse).""" | |
| val = "null" if idh is None else str(int(idh)) | |
| try: | |
| await page.evaluate(f"() => {{ window._trackNid = {val}; }}") | |
| except Exception: | |
| pass | |
| async def confirm(page, exclude, anchor): | |
| """Poll a candidate page until a near-anchor, moving, smooth boat id | |
| appears. Returns (id, x, y) or None. Used ONLY for boat0 at origin, | |
| where movement is what separates the real boat from parked junk.""" | |
| deadline = time.time() + args.confirm_ms / 1000.0 | |
| while time.time() < deadline: | |
| raw = await page.evaluate("() => window._raw") | |
| idh = confirm_pick(raw, exclude, anchor, args.learn_radius, | |
| args.confirm_frames, args.confirm_move, args.max_jump) | |
| if idh: | |
| pts = clean_seq(filter_id(raw, idh), args.max_jump) | |
| return idh, pts[-1][0], pts[-1][1] | |
| await asyncio.sleep(0.12) | |
| return None | |
| async def learn_new(page, exclude, anchor): | |
| """Spawn-then-identify: poll the new boat's page for handoff_ms and | |
| return (id, x, y) of the boat that just spawned at the anchor. No | |
| movement needed — it's allowed to be stuck immediately. Returns the | |
| best find even if it took the whole window; None only if nothing | |
| identifiable appeared at all.""" | |
| deadline = time.time() + args.handoff_ms / 1000.0 | |
| found = None | |
| while time.time() < deadline: | |
| raw = await page.evaluate("() => window._raw") | |
| pick = identify_pick(raw, exclude, anchor, args.learn_radius, | |
| args.ident_frames, args.max_jump, args.min_travel) | |
| if pick: | |
| _, idh, pts = pick | |
| found = (idh, pts[-1][0], pts[-1][1]) | |
| break | |
| await asyncio.sleep(0.1) | |
| return found | |
| print("loading 2 game pages once (only slow step)...", flush=True) | |
| pool = [] | |
| for i in range(2): | |
| pg = await load_page() | |
| if not pg: | |
| print("ERROR: a page failed to load Unity (stack up?)"); await browser.close(); return | |
| pool.append(pg) | |
| print("pages loaded. relay running — no more reloads. Ctrl-C to stop.\n", flush=True) | |
| # --- boat0: spawns at origin (no eligible player), anchor = (0,0). Must | |
| # move to confirm (separates the real drifting boat from parked junk). --- | |
| cur = 0 | |
| nextn = 0 | |
| frontier = None | |
| while frontier is None: | |
| cand = await connect(pool[cur], nextn) | |
| res = await confirm(cand.page, exclude=None, anchor=(0.0, 0.0)) | |
| if res: | |
| cand.boat_id, cand.x, cand.y = res | |
| frontier = cand | |
| await set_track(frontier.page, frontier.boat_id) | |
| print(f"boat{nextn} CONFIRMED id {frontier.boat_id} at ({frontier.x:+.0f},{frontier.y:+.0f})\n", flush=True) | |
| else: | |
| print(f"boat{nextn} didn't confirm (no moving boat near origin — is wind leaked?); retrying...", flush=True) | |
| await disconnect(cand.page) | |
| nextn += 1 | |
| handoffs = 0 | |
| unidentified_streak = 0 | |
| won = False | |
| start_dist = None # dist-to-flag at first reading (baseline for avg rate) | |
| # session_start was set at launch so the pump time counts toward the budget | |
| try: | |
| while True: | |
| try: | |
| snap = await frontier.page.evaluate( | |
| "() => ({raw: window._raw, flag: window._gotFlagHex, now: performance.now()})") | |
| except Exception: | |
| print("[frontier page died] reloading a replacement...", flush=True) | |
| pg = await load_page() | |
| if not pg: | |
| break | |
| pool[cur] = pg | |
| frontier = None | |
| while frontier is None: | |
| cand = await connect(pool[cur], nextn) | |
| res = await confirm(cand.page, None, (0.0, 0.0)) | |
| if res: | |
| cand.boat_id, cand.x, cand.y = res; frontier = cand | |
| await set_track(frontier.page, frontier.boat_id) | |
| else: | |
| await disconnect(cand.page) | |
| nextn += 1 | |
| continue | |
| if snap.get("flag"): | |
| fx = snap["flag"] | |
| print(f"\n*** FLAG RPC RECEIVED ***\n{flag_from_hex(fx)}\n(raw hex: {fx[:120]}...)", flush=True) | |
| won = True | |
| break | |
| raw = snap.get("raw") or [] | |
| now = snap.get("now") or 0.0 | |
| since_commit = time.time() - frontier.connected_at | |
| prog = boat_progress(raw, now, frontier.boat_id, args.fit_window, args.max_jump, args.stale_ms) | |
| if prog: | |
| frontier.x, frontier.y = prog["x"], prog["y"] | |
| speed = math.hypot(prog["vx"], prog["vy"]) | |
| tag = " STATIC" if prog.get("static") else "" | |
| elapsed = time.time() - session_start | |
| line = f"[t+{elapsed:6.0f}s] boat{frontier.n}[{frontier.boat_id}]: pos=({prog['x']:+9.1f},{prog['y']:+9.1f}) |v|={speed:7.1f} u/s{tag}" | |
| if flag: | |
| dist = math.hypot(flag[0]-prog['x'], flag[1]-prog['y']) | |
| if start_dist is None: | |
| start_dist = dist | |
| line += f" dist={dist:9.0f}" | |
| made = start_dist - dist # net units closed since session start | |
| if elapsed > 1.0 and made > 0: | |
| vmg = made / elapsed # overall made-good speed toward flag | |
| eta_min = (dist / vmg) / 60.0 | |
| line += f" avg={vmg:6.1f} u/s ETA={eta_min:5.1f}min" | |
| else: | |
| line += " avg= -- u/s ETA= --" | |
| line += f" prog={prog['disp']:5.0f}u/{prog['span_ms']/1000:.1f}s age={prog['age']/1000:.2f}s" | |
| print(line, flush=True) | |
| else: | |
| elapsed = time.time() - session_start | |
| print(f"[t+{elapsed:6.0f}s] boat{frontier.n}[{frontier.boat_id}]: (no frames, {since_commit:0.1f}s since commit)", flush=True) | |
| # parked on the flag: stop handing off, let reveal/interact fire here | |
| if flag and prog and math.hypot(flag[0]-prog["x"], flag[1]-prog["y"]) < args.reach: | |
| print(f"\n*** within {args.reach}u of flag — holding, waiting for RPC ***", flush=True) | |
| await asyncio.sleep(args.every / 1000.0) | |
| continue | |
| # SPEED-TUNE HOOK: once consistent, throttle wind near the flag so | |
| # per-tick step < ~10u to dwell inside r=10. For now: greedy ride. | |
| settled = since_commit * 1000.0 > args.settle_ms | |
| if prog is None: | |
| frozen = since_commit * 1000.0 > (args.settle_ms + args.stale_ms) | |
| crept = False | |
| else: | |
| frozen = prog.get("static", False) | |
| crept = (not frozen and prog["n"] >= 4 and prog["span_ms"] >= 0.6 * args.fit_window | |
| and prog["disp"] < args.min_progress) | |
| stuck = settled and (frozen or crept) | |
| if stuck: | |
| anchor = (frontier.x, frontier.y) if frontier.x is not None else None | |
| why = "frozen" if (prog is None or prog.get("static")) else "no-progress" | |
| lastpos = f"({anchor[0]:+.0f},{anchor[1]:+.0f})" if anchor else "?" | |
| nxt = 1 - cur | |
| nextn += 1 | |
| # 1) spawn the new boat (BOTH alive now -> it anchors to the old frontier) | |
| newb = await connect(pool[nxt], nextn - 1) | |
| # 2) short delay: learn the new boat's id (it spawned ~10u from anchor) | |
| res = await learn_new(newb.page, exclude=frontier.boat_id, anchor=anchor) | |
| # 3) kill the old boat (new is now the oldest -> next spawn anchors to IT) | |
| await disconnect(frontier.page) | |
| # 4) the new boat is the frontier; let it ride until it stucks, then repeat | |
| if res: | |
| newb.boat_id, newb.x, newb.y = res | |
| else: | |
| # nothing identifiable yet (rare: spawned silent). adopt anyway with no | |
| # id; next loop sees no frames -> frozen -> we hand off again. self-heals. | |
| newb.boat_id, newb.x, newb.y = None, anchor[0] if anchor else None, anchor[1] if anchor else None | |
| await set_track(newb.page, newb.boat_id) | |
| frontier = newb | |
| cur = nxt | |
| handoffs += 1 | |
| if frontier.boat_id: | |
| unidentified_streak = 0 | |
| else: | |
| unidentified_streak += 1 | |
| idtxt = frontier.boat_id if frontier.boat_id else "unidentified" | |
| atxt = f"({frontier.x:+.0f},{frontier.y:+.0f})" if frontier.x is not None else "?" | |
| print(f" -> handoff #{handoffs} ({why} @ {lastpos}): boat{frontier.n} id {idtxt} at {atxt}", flush=True) | |
| if unidentified_streak and unidentified_streak % 6 == 0: | |
| print(f" !! {unidentified_streak} spawns in a row emitted no frames at {lastpos} — " | |
| f"boats are wedging instantly. Wind is likely TOO STRONG: they slam into a rock " | |
| f"and the ~10u respawn can't clear the pocket. Try a LOWER |wind|.", flush=True) | |
| print(flush=True) | |
| continue | |
| await asyncio.sleep(args.every / 1000.0) | |
| except (KeyboardInterrupt, asyncio.CancelledError): | |
| print("\nstopping relay.", flush=True) | |
| if not won: | |
| elapsed = time.time() - session_start | |
| made = (start_dist - math.hypot(flag[0]-frontier.x, flag[1]-frontier.y)) \ | |
| if (flag and start_dist is not None and frontier.x is not None) else None | |
| extra = f" closed {made:.0f}u in {elapsed/60:.1f}min (~{made/elapsed:.1f} u/s avg)" \ | |
| if made else f" {elapsed/60:.1f}min elapsed" | |
| print(f"\n[done] {handoffs} handoffs.{extra}. wind stays leaked; rerun to continue.", flush=True) | |
| try: | |
| await browser.close() | |
| except Exception: | |
| pass | |
| if __name__ == "__main__": | |
| try: | |
| asyncio.run(main()) | |
| except KeyboardInterrupt: | |
| pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment