Moomoo script

macro

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Moomoo script
// @namespace    Big Yahu
// @version      1.3.0
// @description  macro
// @match        *://moomoo.io/*
// @match        *://*.moomoo.io/*
// @run-at       document-start
// @grant        none
// @license MIT
// ==/UserScript==

/* ==================== Keybinds ==================== */
// v = spike
// f = trap
// n = windmill
// b = automill
// h = auto-heal toggle
// j = auto-buy toggle
/* ================================================== */
/* ==================== Version Log ==================== */
// 1.3.0 - added auto-buy (toggle with J, on by default): buys the first unbought store item in the list when you can afford it; each item is bought once (ownership tracked from the "5" UPDATE_STORE_ITEMS packet). Gold read from #scoreDisplay.
// 1.2.1 - removed the scroll-wheel zoom feature and restored the game's own canvas transform (no per-frame override). Player ID and X/Y offsets left untouched for testing.
// 1.2.0 - added scroll-wheel zoom (scroll up = see more / zoom out, scroll down = see closer / zoom in, range 0.5x-3x); added Player ID (sid) drawn on the player; switched overlays to world coordinates so they stay aligned at any zoom level (also fixes a resolution-dependence bug).
// 1.1.0 - shame counter: moved slightly lower (offset is now a tunable SHAME_Y_OFFSET / SHAME_X_OFFSET); fixed movement wiggle by switching player interpolation from wall-clock time to accumulated frame time (matches the game's r.dt), so the counter stays glued to the player.
// 1.0.9 - fixed shame counter not rendering: the "a" (UPDATE_PLAYERS) handler was reading the flat player array from the wrong nesting level (args instead of args[0]), so player tracking never started and drawShame bailed out every frame.
// 1.0.8 - shame counter now follows the player: tracks world position from the "a" packet and replicates the game's interpolation + camera follow so it stays glued to your character while moving. Removed the "LOCK" text (counter shows only the number).
// 1.0.7 - shame counter now drawn on the game canvas below the player (Rageok-style) instead of the DOM HUD; hooks requestAnimationFrame to render each frame.
// 1.0.6 - added on-screen shame counter HUD (shows current shame in real time; turns red with a countdown when shame-locked).
// 1.0.5 - reworked auto-heal: normal heal now delays >120ms after a hit so it stops triggering the shame hat (was force-healing); added shame tracking + one-tick resistance (instant heal when critically low).
// 1.0.4 - added auto-heal (toggle with H, on by default): eats food to heal when you take damage.
// 1.0.3 - changed automill keybind from M to B; added Keybinds + Version Log sections.
// 1.0.2 - added automill (toggle 3 windmills behind you based on walk direction); added windmill placer (N, hold-to-place).
// 1.0.1 - original: spike (V) + trap (F) hold-to-place; encrypted websocket comms; anti-cheat bypass.
/* ===================================================== */
(() => {
    "use strict";

    const NativeWebSocket = window.WebSocket;
    const nativeSend = NativeWebSocket.prototype.send;
    const states = new WeakMap();
    let activeSocket = null;
    let mouseX = innerWidth / 2;
    let mouseY = innerHeight / 2;
    const heldKeys = new Map();
    const encoder = new TextEncoder();
    const decoder = new TextDecoder();
    const c2sAlphabet = ["M", "D", "9", "e", "F", "z", "H", "K", "L", "N", "b", "P", "Q", "c", "6", "S", "0"];
    const s2cAlphabet = ["A", "B", "C", "D", "E", "a", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "X", "Y", "Z", "g", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
    const ioConstant = 1;
    const hmacLength = 6;
    const blockSize = 64;
    const shaK = new Uint32Array([ // copy pasted from the index.js thing
        1116352408, 1899447441, 3049323471, 3921009573, 961987163, 1508970993,
        2453635748, 2870763221, 3624381080, 310598401, 607225278, 1426881987,
        1925078388, 2162078206, 2614888103, 3248222580, 3835390401, 4022224774,
        264347078, 604807628, 770255983, 1249150122, 1555081692, 1996064986,
        2554220882, 2821834349, 2952996808, 3210313671, 3336571891, 3584528711,
        113926993, 338241895, 666307205, 773529912, 1294757372, 1396182291,
        1695183700, 1986661051, 2177026350, 2456956037, 2730485921, 2820302411,
        3259730800, 3345764771, 3516065817, 3600352804, 4094571909, 275423344,
        430227734, 506948616, 659060556, 883997877, 958139571, 1322822218,
        1537002063, 1747873779, 1955562222, 2024104815, 2227730452, 2361852424,
        2428436474, 2756734187, 3204031479, 3329325298
    ]);

    const concat = parts => {
        const size = parts.reduce((sum, part) => sum + part.length, 0);
        const out = new Uint8Array(size);
        let offset = 0;
        for (const part of parts) {
            out.set(part, offset);
            offset += part.length;
        }
        return out;
    };

    const pack = value => {
        if (value === null || value === undefined) return Uint8Array.of(192);
        if (value === false) return Uint8Array.of(194);
        if (value === true) return Uint8Array.of(195);
        if (typeof value === "number") {
            if (Number.isInteger(value)) {
                if (value >= 0 && value < 128) return Uint8Array.of(value);
                if (value >= -32 && value < 0) return Uint8Array.of(256 + value);
                if (value >= 0 && value <= 255) return Uint8Array.of(204, value);
                if (value >= 0 && value <= 65535) return Uint8Array.of(205, value >>> 8, value);
                if (value >= -128 && value < 0) return Uint8Array.of(208, value);
                if (value >= -32768 && value < 0) return Uint8Array.of(209, value >>> 8, value);
                const out = new Uint8Array(5);
                const view = new DataView(out.buffer);
                out[0] = value >= 0 ? 206 : 210;
                value >= 0 ? view.setUint32(1, value) : view.setInt32(1, value);
                return out;
            }
            const out = new Uint8Array(9);
            out[0] = 203;
            new DataView(out.buffer).setFloat64(1, value);
            return out;
        }
        if (typeof value === "string") {
            const data = encoder.encode(value);
            if (data.length < 32) return concat([Uint8Array.of(160 | data.length), data]);
            if (data.length <= 255) return concat([Uint8Array.of(217, data.length), data]);
            return concat([Uint8Array.of(218, data.length >>> 8, data.length), data]);
        }
        if (value instanceof Uint8Array) {
            if (value.length <= 255) return concat([Uint8Array.of(196, value.length), value]);
            return concat([Uint8Array.of(197, value.length >>> 8, value.length), value]);
        }
        if (Array.isArray(value)) {
            const head = value.length < 16 ? Uint8Array.of(144 | value.length) : Uint8Array.of(220, value.length >>> 8, value.length);
            return concat([head, ...value.map(pack)]);
        }
        const entries = Object.entries(value);
        const head = entries.length < 16 ? Uint8Array.of(128 | entries.length) : Uint8Array.of(222, entries.length >>> 8, entries.length);
        return concat([head, ...entries.flatMap(([key, entry]) => [pack(key), pack(entry)])]);
    };

    const unpack = input => {
        const data = input instanceof Uint8Array ? input : new Uint8Array(input);
        const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
        let offset = 0;
        const read = () => {
            const byte = data[offset++];
            if (byte <= 127) return byte;
            if (byte >= 224) return byte - 256;
            if ((byte & 224) === 160) return readString(byte & 31);
            if ((byte & 240) === 144) return readArray(byte & 15);
            if ((byte & 240) === 128) return readMap(byte & 15);
            if (byte === 192) return null;
            if (byte === 194) return false;
            if (byte === 195) return true;
            if (byte === 196) return readBinary(data[offset++]);
            if (byte === 197) return readBinary(readUint16());
            if (byte === 198) return readBinary(readUint32());
            if (byte === 202) return take(4, at => view.getFloat32(at));
            if (byte === 203) return take(8, at => view.getFloat64(at));
            if (byte === 204) return data[offset++];
            if (byte === 205) return readUint16();
            if (byte === 206) return readUint32();
            if (byte === 208) return take(1, at => view.getInt8(at));
            if (byte === 209) return take(2, at => view.getInt16(at));
            if (byte === 210) return take(4, at => view.getInt32(at));
            if (byte === 217) return readString(data[offset++]);
            if (byte === 218) return readString(readUint16());
            if (byte === 219) return readString(readUint32());
            if (byte === 220) return readArray(readUint16());
            if (byte === 221) return readArray(readUint32());
            if (byte === 222) return readMap(readUint16());
            if (byte === 223) return readMap(readUint32());
            throw new Error("Unsupported packet");
        };
        const take = (length, getter) => {
            const start = offset;
            offset += length;
            return getter(start);
        };
        const readUint16 = () => take(2, at => view.getUint16(at));
        const readUint32 = () => take(4, at => view.getUint32(at));
        const readString = length => decoder.decode(data.subarray(offset, offset += length));
        const readBinary = length => data.slice(offset, offset += length);
        const readArray = length => Array.from({ length }, read);
        const readMap = length => {
            const out = {};
            for (let i = 0; i < length; i++) out[read()] = read();
            return out;
        };
        return read();
    };

    const rotr = (value, shift) => value >>> shift | value << 32 - shift;
    const sha256 = message => {
        const hash = new Uint32Array([1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225]); // copy pasted from the game code
        const length = message.length;
        const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
        padded.set(message);
        padded[length] = 128;
        const paddedView = new DataView(padded.buffer);
        paddedView.setUint32(padded.length - 4, length * 8 >>> 0);
        paddedView.setUint32(padded.length - 8, Math.floor(length * 8 / 4294967296));
        const words = new Uint32Array(64);
        for (let block = 0; block < padded.length; block += 64) {
            for (let i = 0; i < 16; i++) words[i] = paddedView.getUint32(block + i * 4);
            for (let i = 16; i < 64; i++) {
                const s0 = rotr(words[i - 15], 7) ^ rotr(words[i - 15], 18) ^ words[i - 15] >>> 3;
                const s1 = rotr(words[i - 2], 17) ^ rotr(words[i - 2], 19) ^ words[i - 2] >>> 10;
                words[i] = words[i - 16] + s0 + words[i - 7] + s1 | 0;
            }
            let a = hash[0], b = hash[1], c = hash[2], d = hash[3], e = hash[4], f = hash[5], g = hash[6], h = hash[7];
            for (let i = 0; i < 64; i++) {
                const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
                const t1 = h + s1 + (e & f ^ ~e & g) + shaK[i] + words[i] | 0;
                const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
                const t2 = s0 + (a & b ^ a & c ^ b & c) | 0;
                h = g; g = f; f = e; e = d + t1 | 0; d = c; c = b; b = a; a = t1 + t2 | 0;
            }
            hash[0] = hash[0] + a | 0; hash[1] = hash[1] + b | 0; hash[2] = hash[2] + c | 0; hash[3] = hash[3] + d | 0;
            hash[4] = hash[4] + e | 0; hash[5] = hash[5] + f | 0; hash[6] = hash[6] + g | 0; hash[7] = hash[7] + h | 0;
        }
        const out = new Uint8Array(32);
        const outView = new DataView(out.buffer);
        for (let i = 0; i < 8; i++) outView.setUint32(i * 4, hash[i]);
        return out;
    };

    const hmac = (key, message) => {
        const source = key.length > blockSize ? sha256(key) : key;
        const padded = new Uint8Array(blockSize);
        padded.set(source);
        const inner = new Uint8Array(blockSize + message.length);
        const outer = new Uint8Array(blockSize + 32);
        for (let i = 0; i < blockSize; i++) {
            inner[i] = padded[i] ^ 54;
            outer[i] = padded[i] ^ 92;
        }
        inner.set(message, blockSize);
        outer.set(sha256(inner), blockSize);
        return sha256(outer).subarray(0, hmacLength);
    };

    const makeRng = seed => {
        let state = seed | 0;
        return () => {
            state = state + 1831565813 | 0;
            let value = Math.imul(state ^ state >>> 15, 1 | state);
            value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value;
            return ((value ^ value >>> 14) >>> 0) / 4294967296;
        };
    };

    const permutation = (alphabet, seed) => {
        const order = alphabet.map((_, index) => index);
        const random = makeRng(seed >>> 0);
        for (let i = order.length - 1; i > 0; i--) {
            const j = Math.floor(random() * (i + 1));
            [order[i], order[j]] = [order[j], order[i]];
        }
        return Object.fromEntries(alphabet.map((opcode, index) => [opcode, order[index]]));
    };
    const permutationDec = (alphabet, seed) => {
        const order = alphabet.map((_, index) => index);
        const random = makeRng(seed >>> 0);
        for (let i = order.length - 1; i > 0; i--) {
            const j = Math.floor(random() * (i + 1));
            [order[i], order[j]] = [order[j], order[i]];
        }
        return Object.fromEntries(order.map((shuffled, index) => [shuffled, alphabet[index]]));
    };

    const hexBytes = hex => Uint8Array.from({ length: hex.length / 2 }, (_, index) => parseInt(hex.slice(index * 2, index * 2 + 2), 16));
    const seal = (state, opcode, args, sequence) => {
        if (!state.key) return pack([opcode, args]);
        const mapped = state.c2s[opcode];
        if (mapped === undefined) return null;
        const inner = pack([mapped, args, sequence]);
        return concat([hmac(state.key, inner), inner]);
    };

    const sendPacket = (opcode, ...args) => {
        const socket = activeSocket;
        const state = socket && states.get(socket);
        if (!socket || socket.readyState !== NativeWebSocket.OPEN || !state || !state.ready) return false;
        const payload = seal(state, opcode, args, state.key ? ++state.seq : 0);
        if (!payload) return false;
        nativeSend.call(socket, payload);
        return true;
    };

    NativeWebSocket.prototype.send = function(data) {
        const state = states.get(this);
        if (!state || !state.ready || !state.key || !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) return nativeSend.call(this, data);
        try {
            const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
            const packet = unpack(bytes.subarray(hmacLength));
            if (Array.isArray(packet) && packet.length === 3) {
                const inner = pack([packet[0], packet[1], ++state.seq]);
                return nativeSend.call(this, concat([hmac(state.key, inner), inner]));
            }
        } catch {}
        return nativeSend.call(this, data);
    };

    function WrappedWebSocket(...args) {
        const socket = new NativeWebSocket(...args);
        const state = { ready: false, key: null, c2s: null, s2c: null, seq: 0 };
        states.set(socket, state);
        socket.addEventListener("message", event => {
            if (!(event.data instanceof ArrayBuffer)) return;
            try {
                const packet = unpack(new Uint8Array(event.data));
                if (!Array.isArray(packet)) return;
                let op = packet[0];
                const args = packet[1];
                if (op === "io-init") {
                    state.ready = true;
                    state.seq = 0;
                    if (args[3] === 1 && typeof args[2] === "string") {
                        const seed = (args[1] ^ Math.imul(ioConstant, 2654435761)) >>> 0;
                        state.key = hexBytes(args[2]);
                        state.c2s = permutation(c2sAlphabet, seed);
                        state.s2c = permutationDec(s2cAlphabet, (seed ^ 2246822507) >>> 0);
                    }
                    activeSocket = socket;
                    return;
                }
                if (typeof op === "number" && state.s2c) op = state.s2c[op];
                if (op === "C") {
                    mySid = args[0];
                    myHealth = 100;
                    shameCount = 0;
                    shameLockUntil = 0;
                    hitTime = 0;
                    myHavePos = false;
                    if (healTimer !== null) { clearTimeout(healTimer); healTimer = null; }
                } else if (op === "O" && args[0] === mySid) {
                    const newHealth = args[1];
                    if (autoHeal && myHealth !== undefined && newHealth < myHealth) {
                        hitTime = Date.now();
                        requestHeal(100 - newHealth, newHealth <= CRITICAL_HEALTH);
                    }
                    myHealth = newHealth;
                } else if (op === "a" && mySid !== null && Array.isArray(args) && Array.isArray(args[0])) {
                    const data = args[0];
                    for (let i = 0; i + 13 <= data.length; i += 13) {
                        if (data[i] === mySid) {
                            const nx = data[i + 1], ny = data[i + 2];
                            if (!myHavePos) {
                                myX = myX1 = myX2 = nx;
                                myY = myY1 = myY2 = ny;
                                camX = nx;
                                camY = ny;
                            } else {
                                myX1 = myX;
                                myY1 = myY;
                                myX2 = nx;
                                myY2 = ny;
                            }
                            myDt = 0;
                            myHavePos = true;
                            break;
                        }
                    }
                } else if (op === "5" && Array.isArray(args) && args[0] === 0 && args[1] !== undefined && args[2] !== undefined) {
                    // UPDATE_STORE_ITEMS: al(e, t, i) — e=1 equip / e=0 buy-or-own; i=1 accessory / i=0 hat
                    const key = args[2] + ":" + args[1];
                    autoBuyOwned.add(key);
                    autoBuyPending.delete(key);
                }
            } catch {}
        });
        socket.addEventListener("close", () => {
            if (activeSocket === socket) activeSocket = null;
        });
        return socket;
    }

    WrappedWebSocket.prototype = NativeWebSocket.prototype;
    Object.defineProperties(WrappedWebSocket, {
        CONNECTING: { value: NativeWebSocket.CONNECTING },
        OPEN: { value: NativeWebSocket.OPEN },
        CLOSING: { value: NativeWebSocket.CLOSING },
        CLOSED: { value: NativeWebSocket.CLOSED }
    });
    window.WebSocket = WrappedWebSocket;

    for (const name of ["__gmMonkey", "GM_info", "GM", "unsafeWindow"]) {
        try {
            Object.defineProperty(window, name, { configurable: false, enumerable: false, get: () => undefined, set: () => {} });
        } catch {}
    }

    const nativeInterval = window.setInterval;
    window.setInterval = function(handler, timeout, ...args) {
        if (typeof handler === "function" && /\bdebugger\b/.test(Function.prototype.toString.call(handler))) return 0;
        return nativeInterval.call(this, handler, timeout, ...args);
    };

    try {
        const descriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src");
        if (descriptor && descriptor.get && descriptor.set) {
            Object.defineProperty(HTMLImageElement.prototype, "src", {
                configurable: descriptor.configurable,
                enumerable: descriptor.enumerable,
                get() { return descriptor.get.call(this); },
                set(value) {
                    if (typeof value !== "string" || !value.startsWith("chrome-extension://")) descriptor.set.call(this, value);
                }
            });
        }
    } catch {}

    const visible = element => element && getComputedStyle(element).display !== "none";
    const findItem = ids => ids.find(id => visible(document.getElementById("actionBarItem" + (16 + id)))) ?? ids[0];
    const findPrimary = () => [0, 1, 2, 3, 4, 5, 6, 7, 8].find(id => visible(document.getElementById("actionBarItem" + id))) ?? 0;
    const itemIds = { spike: [6, 7, 8, 9], trap: [15], windmill: [10, 11, 12] };
    const keyToType = { KeyV: "spike", KeyF: "trap", KeyN: "windmill" };
    const place = type => {
        const item = findItem(itemIds[type]);
        const angle = Math.round(Math.atan2(mouseY - innerHeight / 2, mouseX - innerWidth / 2) * 100) / 100;
        sendPacket("z", item, false);
        sendPacket("D", angle);
        sendPacket("F", 1, angle);
        sendPacket("z", findPrimary(), true);
    };

    window.addEventListener("mousemove", event => {
        mouseX = event.clientX;
        mouseY = event.clientY;
    }, true);

    const moveVectors = { KeyW: [0, -1], KeyS: [0, 1], KeyA: [-1, 0], KeyD: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
    const moveHeld = new Set();
    const moveAngle = () => {
        let vx = 0, vy = 0;
        for (const code of moveHeld) {
            const v = moveVectors[code];
            vx += v[0];
            vy += v[1];
        }
        if (!vx && !vy) return null;
        return Math.atan2(vy, vx);
    };
    // tap b to toggle dropping 3 windmills behind you, spread in an arc opposite your walk direction
    const placeOne = (id, angle, weapon) => {
        sendPacket("z", id, false);
        sendPacket("F", 1, angle);
        sendPacket("F", 0, angle);
        sendPacket("z", weapon, true);
        sendPacket("z", id, false);
        sendPacket("F", 1, angle);
        sendPacket("F", 0, angle);
        sendPacket("z", weapon, true);
    };
    const placeWindmillsBack = () => {
        const angle = moveAngle();
        if (angle === null) return;
        const item = findItem(itemIds.windmill);
        const weapon = findPrimary();
        const behind = angle + Math.PI;
        const r78 = 78 * Math.PI / 180;
        placeOne(item, behind + r78, weapon);
        placeOne(item, behind - r78, weapon);
        placeOne(item, behind, weapon);
        sendPacket("D", Math.atan2(mouseY - innerHeight / 2, mouseX - innerWidth / 2));
    };
    let millInterval = null;
    let millDown = false;
    const toggleMillBack = () => {
        if (millInterval !== null) {
            clearInterval(millInterval);
            millInterval = null;
            return;
        }
        placeWindmillsBack();
        millInterval = setInterval(placeWindmillsBack, 1000 / 3);
    };
    const foodHeal = { 0: 20, 1: 40, 2: 30 };
    // normal heal waits >120ms after a hit so the server's shame counter decays instead of climbing;
    // one-tick resistance heals instantly (accepting shame) when a hit drops you to critical health
    const SHAME_DELAY = 130;
    const CRITICAL_HEALTH = 40;
    let mySid = null;
    let myHealth = undefined;
    let autoHeal = true;
    let healDown = false;
    let shameCount = 0;
    let shameLockUntil = 0;
    let hitTime = 0;
    let healTimer = null;
    // eat food to recover `amount` health: select food, consume (F down/up), switch back to weapon, repeated per heal amount
    const doHeal = amount => {
        if (hitTime) {
            const sinceHit = Date.now() - hitTime;
            hitTime = 0;
            if (sinceHit <= 120) {
                if (++shameCount >= 8) { shameCount = 0; shameLockUntil = Date.now() + 30000; }
            } else {
                shameCount = Math.max(0, shameCount - 2);
            }
        }
        const food = findItem([0, 1, 2]);
        const perUse = foodHeal[food] || 20;
        const weapon = findPrimary();
        for (let used = 0; used < amount; used += perUse) {
            sendPacket("z", food, false);
            sendPacket("F", 1, null);
            sendPacket("F", 0, null);
            sendPacket("z", weapon, true);
        }
    };
    const requestHeal = (amount, immediate) => {
        if (healTimer !== null) { clearTimeout(healTimer); healTimer = null; }
        if (Date.now() < shameLockUntil) return;
        if (immediate) doHeal(amount);
        else healTimer = setTimeout(() => { healTimer = null; doHeal(amount); }, SHAME_DELAY);
    };
    // canvas overlays (shame counter + player ID). the game's myPlayer/camera vars are module-scoped and
    // unreachable from a userscript, so we track the player's world position from the "a" packet and replicate
    // the game's interpolation + camera-follow each frame to map world -> screen, then draw on the game canvas.
    // offset from the player's screen center: +Y moves down, -Y up; +X right, -X left
    const SHAME_X_OFFSET = 0;
    const SHAME_Y_OFFSET = 97;
    const ID_X_OFFSET = 0;
    const ID_Y_OFFSET = 0;
    let shameCtx = null;
    let myX = 0, myY = 0, myX1 = 0, myY1 = 0, myX2 = 0, myY2 = 0, myDt = 0, myHavePos = false;
    let camX = 0, camY = 0, lastFrame = 0;
    const ensureShameCtx = () => {
        if (shameCtx && shameCtx.canvas.isConnected) return shameCtx;
        const canvas = document.getElementById("gameCanvas");
        if (!canvas) return null;
        shameCtx = canvas.getContext("2d");
        return shameCtx;
    };
    // advance the player + camera simulation one frame (matches the game's order: camera follow, then interpolate)
    // and return the player's screen position in world coords (the game's canvas transform maps it to pixels)
    const stepPlayerScreen = () => {
        const now = Date.now();
        const K = lastFrame ? Math.min(now - lastFrame, 50) : 16;
        lastFrame = now;
        const dx = myX - camX, dy = myY - camY;
        const c = Math.sqrt(dx * dx + dy * dy);
        if (c > 0.05) {
            const g = Math.min(c * 0.01 * K, c);
            camX += g * dx / c;
            camY += g * dy / c;
        } else {
            camX = myX;
            camY = myY;
        }
        myDt += K;
        const u = Math.min(1.7, myDt / 170);
        myX = myX1 + (myX2 - myX1) * u;
        myY = myY1 + (myY2 - myY1) * u;
        const sw = window.config.maxScreenWidth, sh = window.config.maxScreenHeight;
        return { x: myX - camX + sw / 2, y: myY - camY + sh / 2 };
    };
    const drawOverlays = () => {
        if (mySid === null || !myHavePos || !window.config) return;
        const ctx = ensureShameCtx();
        if (!ctx) return;
        const p = stepPlayerScreen();
        ctx.save();
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.lineJoin = "round";
        // shame counter (below the player)
        const shx = p.x + SHAME_X_OFFSET, shy = p.y + SHAME_Y_OFFSET;
        ctx.font = "bold 16px Hammersmith One";
        ctx.lineWidth = 5;
        ctx.strokeStyle = "#000";
        ctx.strokeText(String(shameCount), shx, shy);
        ctx.fillStyle = "#ff8c42";
        ctx.fillText(String(shameCount), shx, shy);
        // player ID / sid (on the player's center, like Rageok's sid display)
        const idx = p.x + ID_X_OFFSET, idy = p.y + ID_Y_OFFSET;
        ctx.font = "bold 18px Hammersmith One";
        ctx.lineWidth = 6;
        ctx.strokeStyle = "#5a4516";
        ctx.strokeText(String(mySid), idx, idy);
        ctx.fillStyle = "#fff";
        ctx.fillText(String(mySid), idx, idy);
        ctx.restore();
    };
    const nativeRAF = window.requestAnimationFrame;
    window.requestAnimationFrame = function(cb) {
        return nativeRAF.call(this, t => {
            cb(t);
            try { drawOverlays(); } catch {}
        });
    };
    // auto-buy: when you can afford the next unbought store item in the list, buy it once. ownership is tracked
    // from the "5" (UPDATE_STORE_ITEMS) packet so already-owned items are skipped and nothing is re-bought.
    // toggle with J (on by default). gold is read from the game's #scoreDisplay element.
    const autoBuyList = [
        { id: 15, type: 0, cost: 600 },   // Winter Cap
        { id: 11, type: 1, cost: 2000 },  // Monkey Tail
        { id: 31, type: 0, cost: 2500 },  // Flipper Hat
        { id: 6, type: 0, cost: 4000 },   // Soldier Helmet
        { id: 40, type: 0, cost: 15000 }, // Tank Gear
        { id: 7, type: 0, cost: 6000 },   // Bull Helmet
        { id: 12, type: 0, cost: 6000 },  // Booster Hat
        { id: 53, type: 0, cost: 10000 }, // Turret Gear
        { id: 19, type: 1, cost: 15000 }  // Shadow Wings
    ];
    const autoBuyOwned = new Set();
    const autoBuyPending = new Set();
    let autoBuy = true;
    let autoBuyDown = false;
    const readGold = () => {
        const el = document.getElementById("scoreDisplay");
        if (!el) return 0;
        const txt = (el.innerText || "").trim();
        const num = parseFloat(txt);
        if (isNaN(num)) return 0;
        return /k$/i.test(txt) ? Math.round(num * 1000) : Math.round(num);
    };
    const autoBuyTick = () => {
        if (!autoBuy || mySid === null) return;
        const gold = readGold();
        for (const item of autoBuyList) {
            const key = item.type + ":" + item.id;
            if (autoBuyOwned.has(key) || autoBuyPending.has(key)) continue;
            if (gold >= item.cost) {
                sendPacket("c", 1, item.id, item.type);
                autoBuyPending.add(key);
            }
            return; // one item per tick (the first unbought one), matching Rageok's break
        }
    };
    setInterval(autoBuyTick, 200);

    const typing = target => target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target instanceof HTMLElement && target.isContentEditable;

    const stopPlacing = code => {
        moveHeld.delete(code);
        if (code === "KeyB") { millDown = false; return; }
        if (code === "KeyH") { healDown = false; return; }
        if (code === "KeyJ") { autoBuyDown = false; return; }
        const timer = heldKeys.get(code);
        if (timer === undefined) return;
        clearInterval(timer);
        heldKeys.delete(code);
    };

    window.addEventListener("keydown", event => { // hold v, f or n to place continuously; tap b to toggle 3 windmills behind you; tap h to toggle auto-heal; tap j to toggle auto-buy
        const target = event.target;
        if (typing(target)) return;
        if (moveVectors[event.code]) moveHeld.add(event.code);
        if (event.ctrlKey || event.altKey || event.metaKey) return;
        if (event.code === "KeyB") {
            if (millDown) return;
            millDown = true;
            toggleMillBack();
            return;
        }
        if (event.code === "KeyH") {
            if (healDown) return;
            healDown = true;
            autoHeal = !autoHeal;
            return;
        }
        if (event.code === "KeyJ") {
            if (autoBuyDown) return;
            autoBuyDown = true;
            autoBuy = !autoBuy;
            return;
        }
        if (heldKeys.has(event.code)) return;
        const type = keyToType[event.code] ?? null;
        if (type === null) return;
        place(type);
        heldKeys.set(event.code, setInterval(() => place(type), 1000 / 9));
    }, true);

    window.addEventListener("keyup", event => stopPlacing(event.code), true);
    window.addEventListener("blur", () => {
        for (const code of heldKeys.keys()) stopPlacing(code);
        moveHeld.clear();
        if (millInterval !== null) { clearInterval(millInterval); millInterval = null; }
        if (healTimer !== null) { clearTimeout(healTimer); healTimer = null; }
        millDown = false;
        healDown = false;
        autoBuyDown = false;
    }, true);
})();