import { sizeOfNumberType, sign, CF__index, fuzzyEq, isNumberType, assertNumberRange, isNaN } from "./utility";
import { f8 } from "./utility/f8";
import { f16 } from "./utility/f16";
import { f24 } from "./utility/f24";
import { u12 } from "./utility/u12";
import { u24 } from "./utility/u24";
import { AXIS_ALIGNED_ORIENTATIONS, COMMON_UDIM2S, COMMON_VECTORS } from "./constants";
import type { ProcessedInfo } from "./info-processing";
import type { SerializerSchema, SerializedData } from "./types";

const { sub } = string;
const { max, clamp, ceil, map, pi: PI } = math;
const {
  copy, create: createBuffer, len: bufferLength,
  writei8, writei16, writei32, writeu8, writeu16, writeu32, writef32, writef64, writestring
} = buffer;
const toAxisAngle = CFrame.identity.ToAxisAngle as (cf: CFrame) => ReturnType<CFrame["ToAxisAngle"]>;

const LIMIT_16_BITS = 2 ** 16 - 1;
const LIMIT_15_BITS = 2 ** 15 - 1;

function validateIfNumber(value: unknown, [kind]: SerializerSchema): void {
  if (!isNumberType(kind)) return;
  if (value !== value) {
    throw `[@rbxts/serio]: Attempt to serialize NaN value as "${kind}"`;
  }

  const size = sizeOfNumberType(kind);
  const isSigned = sub(kind, 1, 1) !== "u";
  assertNumberRange(value as number, size, isSigned);
}

export function getSerializeFunction<T>(
  { schema, containsPacking, containsUnknownPacking, minimumPackedBits, minimumPackedBytes, sortedEnums }: ProcessedInfo
): (value: T) => SerializedData {
  let bits = table.create<boolean>(minimumPackedBits);
  let currentSize = 2 ** 8;
  let buf = createBuffer(currentSize);
  let packing = false;
  let offset!: number;
  let blobs!: defined[];
  let originalValue!: T;

  function allocate(size: number): void {
    if ((offset += size) <= currentSize) return;

    const newSize = max(currentSize * 2, offset);
    const oldBuffer = buf;
    currentSize = newSize;
    buf = createBuffer(newSize);
    copy(buf, 0, oldBuffer);
  }

  function serialize(value: unknown, meta: SerializerSchema, serializeOffset = offset): void {
    const currentOffset = serializeOffset;
    validateIfNumber(value, meta);

    const [typeName] = meta;
    switch (typeName) {
      case "u8": {
        allocate(1);
        writeu8(buf, currentOffset, value as never);
        break;
      }
      case "u16": {
        allocate(2);
        writeu16(buf, currentOffset, value as never);
        break;
      }
      case "u32": {
        allocate(4);
        writeu32(buf, currentOffset, value as never);
        break;
      }
      case "u12":
      case "i12": {
        const numberBits = u12.fromU32(value as never);
        allocate(1);
        writeu8(buf, currentOffset, numberBits & 0xFF);
        bits.push(((numberBits >> 11) & 1) === 1);
        bits.push(((numberBits >> 10) & 1) === 1);
        bits.push(((numberBits >> 9) & 1) === 1);
        bits.push(((numberBits >> 8) & 1) === 1);
        break;
      }
      case "u24":
      case "i24": {
        const numberBits = u24.fromU32(value as never);
        allocate(3);
        writei8(buf, currentOffset, numberBits & 0xFF); // lower byte
        writei8(buf, currentOffset + 1, (numberBits >> 8) & 0xFF); // middle byte
        writei8(buf, currentOffset + 2, (numberBits >> 16) & 0xFF); // upper byte
        break;
      }
      case "i8": {
        allocate(1);
        writei8(buf, currentOffset, value as never);
        break;
      }
      case "i16": {
        allocate(2);
        writei16(buf, currentOffset, value as never);
        break;
      }
      case "i32": {
        allocate(4);
        writei32(buf, currentOffset, value as never);
        break;
      }
      case "f8": {
        const bytes = f8.fromF32(value as never);
        allocate(1);
        writeu8(buf, currentOffset, bytes);
        break;
      }
      case "f16": {
        const bytes = f16.fromF32(value as never);
        allocate(2);
        writeu16(buf, currentOffset, bytes);
        break;
      }
      case "f24": {
        const numberBits = f24.fromF32(value as never);
        allocate(3);
        writeu8(buf, currentOffset, numberBits & 0xFF); // lower byte
        writeu8(buf, currentOffset + 1, (numberBits >> 8) & 0xFF); // middle byte
        writeu8(buf, currentOffset + 2, (numberBits >> 16) & 0xFF); // upper byte
        break;
      }
      case "f32": {
        allocate(4);
        writef32(buf, currentOffset, value as never);
        break;
      }
      case "f64": {
        allocate(8);
        writef64(buf, currentOffset, value as never);
        break;
      }
      case "bool": {
        if (packing) {
          bits.push(value as never);
          break;
        }

        allocate(1);
        writeu8(buf, currentOffset, value ? 1 : 0);
        break;
      }
      case "string": {
        const [_, lengthType] = meta;
        const str = value as string;
        const length = str.size();
        const lengthSize = sizeOfNumberType(lengthType);
        assertNumberRange(length, lengthSize, false, "string length");
        serialize(length, lengthType);
        allocate(length);
        writestring(buf, currentOffset + lengthSize, str);
        break;
      }
      case "set": {
        // We could just generate `Map<V, true>` for sets, but this is more efficient as it omits serializing a boolean per value.
        const [_, valueType, lengthType] = meta;

        const set = value as Set<unknown>;
        serialize(set.size(), lengthType);
        for (const elementValue of set)
          serialize(elementValue, valueType);

        break;
      }
      case "map": {
        const [_, keyType, valueType, lengthType] = meta;
        const map = value as Map<unknown, unknown>;

        serialize(map.size(), lengthType);
        for (const [key, value] of map) {
          serialize(key, keyType);
          serialize(value, valueType);
        }

        break;
      }
      case "enum": {
        const enumIndex = sortedEnums[meta[1]!].indexOf(value as never);

        assertNumberRange(enumIndex, 1, false, "enum index");
        allocate(1);
        writeu8(buf, currentOffset, enumIndex);
        break;
      }
      case "numbersequence": {
        const keypoints = (value as NumberSequence).Keypoints;
        const keypointCount = keypoints.size();
        assertNumberRange(keypointCount, 1, false, "number sequence keypoints length");
        allocate(1 + 6 * keypointCount);
        writeu8(buf, currentOffset, keypointCount);

        for (const i of $range(1, keypointCount)) {
          const keypointOffset = 1 + currentOffset + 6 * (i - 1);
          const keypoint = keypoints[i - 1];
          const time = 0xFFFF * keypoint.Time;
          const value = 0xFFFF * keypoint.Value;
          const envelope = 0xFFFF * keypoint.Envelope;
          assertNumberRange(time, 2, false, `number sequence keypoint #${i} time`);
          assertNumberRange(value, 2, false, `number sequence keypoint #${i} value`);
          assertNumberRange(envelope, 2, false, `number sequence keypoint #${i} envelope`);
          writeu16(buf, keypointOffset, time);
          writeu16(buf, 2 + keypointOffset, value);
          writeu16(buf, 4 + keypointOffset, envelope);
        }

        break;
      }
      case "colorsequence": {
        const keypoints = (value as ColorSequence).Keypoints;
        const keypointCount = keypoints.size();
        assertNumberRange(keypointCount, 1, false, "color sequence keypoints length");
        allocate(1 + keypointCount * 5);
        writeu8(buf, currentOffset, keypointCount);

        for (const i of $range(1, keypointCount)) {
          const keypointOffset = 1 + currentOffset + 5 * (i - 1);
          const keypoint = keypoints[i - 1];
          const time = 0xFFFF * keypoint.Time;
          assertNumberRange(time, 2, false, `color sequence keypoint #${i} time`);
          writeu16(buf, keypointOffset, time);
          serialize(keypoint.Value, ["color"], 2 + keypointOffset);
        }

        break;
      }
      case "color": {
        const color = value as Color3;
        const r = 0xFF * color.R;
        const g = 0xFF * color.G;
        const b = 0xFF * color.B;
        assertNumberRange(r, 1, false, "color R value");
        assertNumberRange(g, 1, false, "color G value");
        assertNumberRange(b, 1, false, "color B value");
        allocate(3);
        writeu8(buf, currentOffset, r);
        writeu8(buf, 1 + currentOffset, g);
        writeu8(buf, 2 + currentOffset, b);
        break;
      }
      case "udim": {
        const [_, xType, yType] = meta;
        const udim = value as UDim;
        serialize(udim.Scale, xType);
        serialize(udim.Offset, yType);
        break;
      }
      case "udim2": {
        const [_, scaleXType, offsetXType, scaleYType, offsetYType] = meta;
        const udim2 = value as UDim2;
        if (packing) {
          // 3 bits for special cases
          const specialCase = COMMON_UDIM2S.indexOf(udim2);
          const isOptimized = specialCase !== -1;
          const packed = isOptimized ? specialCase : 0x7;
          bits.push(isOptimized);

          if (isOptimized) {
            allocate(1);
            writeu8(buf, currentOffset, packed);
            break;
          }
        }

        serialize(udim2.X, ["udim", scaleXType, offsetXType]);
        serialize(udim2.Y, ["udim", scaleYType, offsetYType]);
        break;
      }
      case "vector": {
        const [_, xType, yType, zType] = meta;
        const vector3 = value as Vector3;
        if (packing) {
          const specialCase = COMMON_VECTORS.findIndex(v => fuzzyEq(v, vector3));
          const isOptimized = specialCase !== -1;
          const packed = isOptimized ? specialCase : 0x3F;
          bits.push(isOptimized);

          if (isOptimized) {
            allocate(1);
            writeu8(buf, currentOffset, packed);
            break;
          }
        }

        serialize(vector3.X, xType);
        serialize(vector3.Y, yType);
        serialize(vector3.Z, zType);
        break;
      }
      case "cframe": {
        const [_, xType, yType, zType] = meta;
        const cframe = value as CFrame;
        const position = CF__index(cframe, "Position");

        if (packing) {
          // 1-5: Orientation, 6-7: Position, 8: unused
          let optimizedPosition = false;
          let optimizedRotation = false;
          let packed = 0;

          const rotation = CF__index(cframe, "Rotation");
          if (position === Vector3.zero) {
            optimizedPosition = true;
            packed += 0x20;
          } else if (position === Vector3.one) {
            optimizedPosition = true;
            packed += 0x20;
            packed += 0x40;
          }

          const specialCase = AXIS_ALIGNED_ORIENTATIONS.indexOf(rotation);
          if (specialCase !== -1) {
            optimizedRotation = true;
            packed += specialCase;
          } else
            packed += 0x1F;

          const isOptimized = optimizedPosition || optimizedRotation;
          bits.push(isOptimized);

          const headerBytes = isOptimized ? 1 : 0;
          const rotationBytes = optimizedRotation ? 0 : 6;
          const totalBytes = headerBytes + rotationBytes;
          allocate(totalBytes);

          let newOffset = currentOffset;
          if (isOptimized) {
            writeu8(buf, newOffset, packed);
            newOffset += 1;
          }

          if (!optimizedRotation) {
            writeCFrameAngles(cframe, newOffset);
            newOffset += 6;
          }

          if (!optimizedPosition)
            serialize(position, ["vector", xType, yType, zType], newOffset);

          break;
        }

        allocate(6); // minimum
        writeCFrameAngles(cframe, currentOffset);
        serialize(position, ["vector", xType, yType, zType]);
        break;
      }
      case "list": {
        const [_, elementMeta, sizeMeta] = meta;
        const list = value as unknown[];

        serialize(list.size(), sizeMeta);
        for (const element of list)
          serialize(element, elementMeta);

        break;
      }
      case "object": {
        const [_, elements] = meta;
        const object = value as Record<string, unknown>;

        for (const i of $range(1, elements.size(), 2)) {
          const key = elements[i - 1] as string;
          const schema = elements[i] as SerializerSchema;
          serialize(object[key], schema);
        }

        break;
      }
      case "union": {
        const [_, tagName, tagged, byteSize] = meta;
        const objectTag = (value as Map<unknown, unknown>).get(tagName);

        let tagIndex = 0;
        let tagMetadata!: SerializerSchema;
        for (const i of $range(1, tagged.size())) {
          const tagObject = tagged[i - 1];
          if (tagObject[0] === objectTag) {
            tagIndex = i - 1;
            tagMetadata = tagObject[1];
            break;
          }
        }

        if (byteSize === 1) {
          assertNumberRange(tagIndex, 1, false, "literal union tag index");
          allocate(1);
          writeu8(buf, currentOffset, tagIndex);
        } else if (byteSize === 2) {
          assertNumberRange(tagIndex, 2, false, "literal union tag index");
          allocate(2);
          writeu16(buf, currentOffset, tagIndex);
        } else if (byteSize === -1) {
          bits.push(tagIndex === 0);
        }

        serialize(value, tagMetadata);
        break;
      }
      case "guard_union": {
        const constituents = meta[1];
        let serializerIndex: number | undefined;
        let serializer!: SerializerSchema;

        for (const i of $range(1, constituents.size())) {
          // Flamework can't generate "any table" guards so it gets replaced with nil
          const constituent = constituents[i - 1];
          const [potentialSerializer, guard] = constituent;
          if (guard ? guard(value) : typeIs(value, "table")) {
            serializerIndex = i - 1;
            serializer = potentialSerializer;
          }
        }

        if (serializerIndex === undefined)
          throw `[@rbxts/serio]: Value '${value}' matches none of the generated guards
          Stemmed from: ${originalValue}`;

        // Supports 256 unions for the time being.
        assertNumberRange(serializerIndex, 1, false, "literal guard union serializer index");
        allocate(1);
        writeu8(buf, currentOffset, serializerIndex);
        serialize(value, serializer);
        break;
      }
      case "literal": {
        const [_, literals, byteSize] = meta;
        if (byteSize === -1) {
          bits.push(value === literals[0]);
          break;
        }

        const index = literals.indexOf(value as defined);
        if (index !== -1)
          assertNumberRange(index, byteSize, false, "literal index");

        allocate(byteSize);
        if (byteSize === 1)
          writeu8(buf, currentOffset, index);
        else if (byteSize === 2)
          writeu16(buf, currentOffset, index);

        break;
      }
      case "tuple": {
        const [_, elements, restMetadata] = meta;
        const tuple = value as unknown[];
        const size = tuple.size();

        if (restMetadata !== undefined) {
          const restSize = size - elements.size();
          assertNumberRange(restSize, 2, false, "tuple rest length");
          allocate(2);
          writeu16(buf, currentOffset, restSize);
        }

        for (const i of $range(1, size)) {
          const newMetadata = elements[i - 1] ?? restMetadata;
          if (newMetadata === undefined) continue;

          serialize(tuple[i - 1], newMetadata);
        }

        break;
      }

      case "optional": {
        const [_, valueMeta] = meta;
        const exists = value !== undefined;
        if (packing) {
          bits.push(exists);
          if (exists)
            serialize(value, valueMeta);

          break;
        }

        allocate(1);
        writeu8(buf, currentOffset, exists ? 1 : 0);
        if (exists)
          serialize(value, valueMeta);

        break;
      }
      case "packed": {
        const [_, innerType] = meta;
        const enclosing = packing;
        packing = true;
        serialize(value, innerType);
        packing = enclosing;
        break;
      }

      case "blob": {
        blobs.push(value!);
        break;
      }

      default:
        throw `[@rbxts/serio]: Cannot serialize unknown schema type '${meta[0]}'`;
    }
  }

  function writeCFrameAngles(cframe: CFrame, offset: number): void {
    const [axis, angle] = toAxisAngle(cframe);
    let zAxis = axis.Z;
    if (isNaN(zAxis))
      zAxis = 0;

    const zSign = sign(zAxis);
    let xAxis = axis.X;
    if (isNaN(xAxis))
      xAxis = 0;
    let yAxis = axis.Y;
    if (isNaN(yAxis))
      yAxis = 0;

    const maxY = (1 - xAxis ** 2) ** 0.5;
    let mappedX = clamp(map(xAxis, -1, 1, 0, LIMIT_16_BITS), 0, LIMIT_16_BITS);
    let mappedY = clamp(map(yAxis, -maxY, maxY, 0, LIMIT_15_BITS) * zSign, -LIMIT_15_BITS, LIMIT_15_BITS - 1);
    let mappedAngle = clamp(map(angle, 0, PI, 0, LIMIT_16_BITS), 0, LIMIT_16_BITS);
    if (!isNaN(mappedX))
      assertNumberRange(mappedX, 2, false);
    if (!isNaN(mappedY))
      assertNumberRange(mappedY, 2, true);
    if (!isNaN(mappedAngle))
      assertNumberRange(mappedAngle, 2, false);

    writeu16(buf, offset, mappedX);
    writei16(buf, offset + 2, mappedY);
    writeu16(buf, offset + 4, mappedAngle);
  }

  function writeBits(buf: buffer, offset: number, bitOffset: number, bytes: number, variable: boolean): void {
    const bitSize = bits.size();
    for (const byte of $range(0, bytes - 1)) {
      let currentByte = 0;
      const bitsThisByte = clamp(bitSize - bitOffset, 0, 7);
      for (const bit of $range(variable ? 1 : 0, bitsThisByte)) {
        currentByte += (bits[bitOffset] ? 1 : 0) << bit;
        bitOffset++;
      }

      if (variable && byte !== bytes - 1)
        currentByte++;

      writeu8(buf, offset, currentByte);
      offset++;
    }
  }

  function calculatePackedBytes(): LuaTuple<[number, number, number]> {
    const minimumBytes = minimumPackedBytes;
    if (!containsUnknownPacking)
      return $tuple(minimumBytes, 0, minimumBytes);

    const variableBytes = max(1, ceil((bits.size() - 8 * minimumBytes) / 7));
    const totalBytes = minimumBytes + variableBytes;
    return $tuple(minimumBytes, variableBytes, totalBytes);
  }

  return (value: T) => {
    offset = 0;
    blobs = [];
    bits = [];
    originalValue = value;
    serialize(value, schema);

    if (!containsPacking) {
      const trimmed = createBuffer(offset);
      copy(trimmed, 0, buf, 0, offset);

      return createSerializedData(trimmed, blobs);
    }

    const [minimumBytes, variableBytes, totalBytes] = calculatePackedBytes();
    const trimmed = createBuffer(offset + totalBytes);
    copy(trimmed, totalBytes, buf, 0, offset);

    if (minimumBytes > 0)
      writeBits(trimmed, 0, 0, minimumBytes, false);

    if (variableBytes > 0)
      writeBits(trimmed, minimumBytes, 8 * minimumBytes, variableBytes, true);

    return createSerializedData(trimmed, blobs);
  };
}

function createSerializedData(trimmed: buffer, blobs: defined[]): SerializedData {
  return {
    buf: bufferLength(trimmed) === 0 ? undefined : trimmed,
    blobs: blobs.isEmpty() ? undefined : blobs
  };
}


local info = debug.info
local identity = CFrame.identity
local lune = string.sub(_VERSION, 1, 4) == "Lune"

local _, CF__index = xpcall(function()
  return identity[nil]
end, function()
  return info(2, "f")
end)

local _, CF__add = xpcall(function()
  return identity + nil
end, function()
  return info(2, "f")
end)

if lune then
  CF__index = function(cframe, key)
    return cframe[key]
  end
  CF__add = function(a, b)
    if typeof(a) == "vector" then
      a = Vector3.new(a.X, a.Y, a.Z)
    end
    if typeof(b) == "vector" then
      b = Vector3.new(b.X, b.Y, b.Z)
    end
    return a + b
  end
end

return {
  CF__index = CF__index,
  CF__add = CF__add
}


import { IS_LUNE } from "../constants";
import type { NumberType, Primitive } from "../types";

const { sort } = table;
const { magnitude } = vector;

export * from "./fastcalls";

export function assertNumberRange(n: number, sizeInBytes: number, signed: boolean, note?: string): void {
  const sizeInBits = 8 * sizeInBytes;
  const signedOffset = signed ? 1 : 0;
  const integerBits = sizeInBits - signedOffset;
  const maximum = 2 ** integerBits - 1;
  const minimum = signed ? -(1 + maximum) : 0;

  if (n >= minimum && n <= maximum) return;
  throw `[@rbxts/serio]: Attempt to serialize value out of bit range${note !== undefined ? ` (${note})` : ""}
    Value: ${n}
    Size (bits): ${sizeInBits}
    Signed: ${signed}`;
}

export function fuzzyEq(a: Vector3, b: Vector3, epsilon = 1e-6): boolean {
  if (IS_LUNE) {
    if (typeOf(a) === "vector")
      a = new Vector3(a.X, a.Y, a.Z);
    if (typeOf(b) === "vector")
      b = new Vector3(b.X, b.Y, b.Z);
  }

  let difference = a.sub(b) as unknown as vector;
  if (IS_LUNE && typeIs(difference, "Vector3"))
    difference = vector.create(difference.X, difference.Y, difference.Z);

  return magnitude(difference) <= epsilon;
}

/**
 * Returns a consistently ordered array for a specific Enum.
 *
 * We can't send Enum values over the network as the values aren't always within the 8 bit limit,
 * so instead we send the EnumItem's position in the array returned here.
 */
export function getSortedEnumItems(enumObject: Enum): EnumItem[] {
  const enumItems = enumObject.GetEnumItems();
  sort(enumItems, (a, b) => a.Value < b.Value);

  return enumItems;
}

const numberTypeSizes: Record<NumberType, number> = {
  u8: 1, i8: 1, f8: 1,
  u12: 1.5, i12: 1.5,
  u16: 2, i16: 2, f16: 2,
  u24: 3, i24: 3, f24: 3,
  u32: 4, i32: 4, f32: 4,
  f64: 8,
};

export function sizeOfNumberType(kind: NumberType): number;
export function sizeOfNumberType(primitive: Primitive<NumberType>): number;
export function sizeOfNumberType(typeInfo: NumberType | Primitive<NumberType>): number {
  const kind = typeIs(typeInfo, "string") ? typeInfo : typeInfo[0];
  return numberTypeSizes[kind];
}

export function isNumberType(typeName: string): typeName is NumberType {
  return numberTypeSizes[typeName as never] !== undefined;
}

export function sign(n: number): -1 | 1 {
  return n < 0 ? -1 : 1;
}

export function isNaN(n: number): boolean {
  return n !== n;
}


import { TestRunner } from "@rbxts/runit";
import { instrument, istanbul } from "@rbxts/coverage";

const http = game.GetService("HttpService");
const replicated = game.GetService("ReplicatedStorage");
instrument([replicated.WaitForChild("Library")]);

const testRunner = new TestRunner(replicated.WaitForChild("Tests"));
testRunner.run({ colors: true })
  .then(() => {
    const report = istanbul();
    const unmappedJSON = http.JSONEncode(report);
    const [reportJSON] = unmappedJSON.gsub('"ReplicatedStorage/Library/([^"]+)"', '"out/%1.luau"');
    const coverageValue = new Instance("StringValue");
    coverageValue.Name = "coverage";
    coverageValue.Value = reportJSON;
    coverageValue.Parent = replicated;
  });


import type { Modding } from "@flamework/core";
import { Assert } from "@rbxts/runit";

import type { Serializer, SerializerMetadata, SerializedData, String, Vector, u8, i32, u16 } from "../src/index";
import createSerializer from "../src/index";

export type NullableLiteralUnion = TestLiteralUnion | undefined;
export type TestLiteralUnion = "a" | "b" | "c" | "d";
export type TestMixedLiteralUnion = "a" | 69 | true;
export type TestTaggedUnion = { tag: "a", value: u8 } | { tag: "b", value: String<u8> };
export type TestComplexUnion = String<u8> | u8 | { a: String<u8> } | { b: u16 } | Vector<u8>;

export interface TestObject {
  readonly a: u8;
  readonly b: boolean;
  readonly c: "a" | "b" | "c";
  readonly d: i32;
}

export interface TestPackedBooleans {
  readonly a: boolean;
  readonly b: boolean;
  readonly c: boolean;
  readonly d: boolean;
  readonly e: boolean;
  readonly f: boolean;
  readonly g: boolean;
  readonly h: boolean;
}

export interface SerializeMetadata<T> {
  readonly text: Modding.Generic<T, "text">;
  readonly serializerMeta: SerializerMetadata<T>;
}

const serializers = new Map<string, Serializer<any>>;

/** @metadata macro */
export function getSerializer<T>(meta?: SerializeMetadata<T>): Serializer<T> | undefined {
  if (meta === undefined) return;

  const { text, serializerMeta } = meta;
  let serializer = serializers.get(text);
  if (!serializer)
    serializers.set(text, serializer = createSerializer<T>(serializerMeta as never));

  return serializer;
}

/** shallow */
export function assertIterableEqual(expected: defined[], actual: defined[]): void {
  Assert.equal(expected.size(), actual.size());

  // mega hack
  for (const [key, element] of pairs(expected as unknown as ReadonlyMap<string | number, unknown>)) {
    const resultElement = (actual as unknown as ReadonlyMap<string | number, unknown>).get(key);
    Assert.defined(resultElement);
    Assert.equal(element, resultElement);
  }
}

export function assertCFrameEqual(expected: CFrame, actual: CFrame, angleEpsilon = 1e-2 * 5): void {
  Assert.appendFailedMessage("CFrame X Coordinate", () =>
    Assert.equal(expected.X, actual.X)
  );
  Assert.appendFailedMessage("CFrame Y Coordinate", () =>
    Assert.equal(expected.Y, actual.Y)
  );
  Assert.appendFailedMessage("CFrame Z Coordinate", () =>
    Assert.equal(expected.Z, actual.Z)
  );

  Assert.appendFailedMessage("CFrame Rotation NaN Check", () => {
    assertNotNaNVector(expected.XVector);
    assertNotNaNVector(expected.YVector);
    assertNotNaNVector(expected.ZVector);
  });
  assertVectorFuzzyEqual(expected.XVector, actual.XVector, angleEpsilon, "CFrame X Vector");
  assertVectorFuzzyEqual(expected.YVector, actual.YVector, angleEpsilon, "CFrame Y Vector");
  assertVectorFuzzyEqual(expected.ZVector, actual.ZVector, angleEpsilon, "CFrame Z Vector");
}

export function assertVectorFuzzyEqual(expected: Vector3, actual: Vector3, epsilon = 1e-6, extraMessage?: string): void {
  const extra = extraMessage === undefined ? "" : extraMessage + " ";
  Assert.appendFailedMessage(extra + "X Coordinate", () =>
    Assert.fuzzyEqual(expected.X, actual.X, epsilon)
  );
  Assert.appendFailedMessage(extra + "Y Coordinate", () =>
    Assert.fuzzyEqual(expected.Y, actual.Y, epsilon)
  );
  Assert.appendFailedMessage(extra + "Z Coordinate", () =>
    Assert.fuzzyEqual(expected.Z, actual.Z, epsilon)
  );
}

const NaN = 0 / 0;
export function assertNotNaNVector(vector: Vector3): void {
  Assert.notEqual(vector.X, NaN);
  Assert.notEqual(vector.Y, NaN);
  Assert.notEqual(vector.Z, NaN);
}

export function assertVectorEqual(expected: Vector3, actual: Vector3): void {
  Assert.equal(expected.X, actual.X);
  Assert.equal(expected.Y, actual.Y);
  Assert.equal(expected.Z, actual.Z);
}

export class BaseSerializationTest {
  /** @metadata macro */
  protected serialize<T>(value: T, meta?: Modding.Many<SerializeMetadata<T>>): SerializedData {
    const serializer = getSerializer<T>(meta)!;
    return serializer.serialize(value);
  }
}


import path from "path";
import ts from "typescript";
import { TransformState } from "./transformState";
import fs from "fs";
import { isPathDescendantOf } from "../util/isPathDescendantOf";
import assert from "assert";

export const moduleResolutionCache = new Map<string, string | false>();

class FileSymbol {
	private fileSymbol: ts.Symbol;

	public constructor(public state: TransformState, public file: ts.SourceFile) {
		const fileSymbol = this.state.getSymbol(file);
		assert(fileSymbol, "Invalid file symbol");
		this.fileSymbol = fileSymbol;

		this.register();
	}

	get(name: string) {
		const exportSymbol = this.fileSymbol.exports?.get(name as ts.__String);
		assert(exportSymbol);

		return exportSymbol;
	}

	private register() {
		// for (const statement of this.file.statements) {
		// 	if (ts.isFunctionDeclaration(statement)) {
		// 		this.registerFunction(statement);
		// 	}
		// }
	}
}

export const RBXTS_SYMBOL_NAMES = {
	LuaTuple: "LuaTuple",
} as const;

const NOMINAL_LUA_TUPLE_NAME = "_nominal_LuaTuple";

export class SymbolProvider {
	public moduleFile: FileSymbol | undefined;
	public symbols = new Map<string, ts.Symbol>();

	public constructor(private state: TransformState) {
		this.lookupModule();

		for (const symbolName of Object.values(RBXTS_SYMBOL_NAMES)) {
			const symbol = state.typeChecker.resolveName(symbolName, undefined, ts.SymbolFlags.All, false);
			if (symbol) {
				this.symbols.set(symbolName, symbol);
			} else {
				throw `MacroManager could not find symbol for ${symbolName}`;
			}
		}

		const luaTupleTypeDec = this.symbols
			.get(RBXTS_SYMBOL_NAMES.LuaTuple)
			?.declarations?.find((v) => ts.isTypeAliasDeclaration(v));
		if (luaTupleTypeDec) {
			const nominalLuaTupleSymbol = state.typeChecker
				.getTypeAtLocation(luaTupleTypeDec)
				.getProperty(NOMINAL_LUA_TUPLE_NAME);
			if (nominalLuaTupleSymbol) {
				this.symbols.set(NOMINAL_LUA_TUPLE_NAME, nominalLuaTupleSymbol);
			}
		}
	}

	public isLuaTupleType(type: ts.Type): boolean {
		return type.getProperty(NOMINAL_LUA_TUPLE_NAME) === this.symbols.get(NOMINAL_LUA_TUPLE_NAME);
	}

	public getGlobalSymbolByNameOrThrow(typeChecker: ts.TypeChecker, name: string, meaning: ts.SymbolFlags): ts.Symbol {
		const symbol = typeChecker.resolveName(name, undefined, meaning, false);
		if (symbol) {
			return symbol;
		}
		throw `nooop`;
	}

	public isModule(file: ts.SourceFile): boolean {
		if (
			isPathDescendantOf(file.fileName, this.debugDir) &&
			!isPathDescendantOf(file.fileName, path.join(this.debugDir, "example"))
		) {
			return true;
		} else {
			return false;
		}
	}

	private lookupModule() {
		for (const file of this.state.program.getSourceFiles()) {
			if (this.isModule(file)) {
				this.moduleFile = new FileSymbol(this.state, file);
			}
		}
	}

	private resolveModuleDir(moduleName: string) {
		const modulePath = moduleResolutionCache.get(moduleName);
		if (modulePath !== undefined) return modulePath || undefined;

		const dummyFile = path.join(this.state.srcDir, "dummy.ts");
		const module = ts.resolveModuleName(moduleName, dummyFile, this.state.options, ts.sys);
		const resolvedModule = module.resolvedModule;
		if (resolvedModule) {
			const modulePath = fs.realpathSync(path.join(resolvedModule.resolvedFileName, "../"));
			moduleResolutionCache.set(moduleName, modulePath);
			return modulePath;
		}
		moduleResolutionCache.set(moduleName, false);
	}

	private typesDir = this.resolveModuleDir("@rbxts/types")!;
	private debugDir = this.resolveModuleDir("rbxts-transform-debug")!;
}


import ts from "typescript"
import { type Config, TransformState } from "./transforms/index.js"
import { transformFile } from "./transforms/file.js"

const transformer: (
	program: ts.Program,
	config?: Partial<Config> & { resolutionHost?: ts.ModuleResolutionHost },
) => ts.TransformerFactory<ts.SourceFile> = (program, config) => (context) => {
	const state = new TransformState(program, context, {
		jecsPackage: "@rbxts/jecs",
		silent: false,
		...(config ?? {}),
	})
	if (config?.resolutionHost) {
		state.resolutionHost = config.resolutionHost
	}
	return (sourceFile) => transformFile(state, sourceFile)
}
export default transformer


/// <reference types="vite/client" />
import { VirtualProject } from "roblox-ts"
import transformer from "../src/index.js"
import { resolve } from "path/posix"

export const compile = async (source: string): Promise<string> => {
	const project = new VirtualProject()
	project.tsTransformers.push((program) =>
		transformer(program, { resolutionHost: project["compilerHost"], silent: true }),
	)

	const files = import.meta.glob("../node_modules/@rbxts/**/{package.json,*.d.ts}", {
		eager: true,
		query: "?raw",
		import: "default",
	})
	for (const [path, content] of Object.entries(files)) {
		const isDefinition = path.endsWith(".d.ts")
		const absolutePath = path.replace("../", "/")
		project.vfs.writeFile(absolutePath, content as string)
		if (!isDefinition) {
			const pkgJson = JSON.parse(content as string)
			const pkgName = absolutePath.split("/").slice(-3, -1).join("/")
			const mainPath = resolve(`/${pkgName}`, pkgJson.main ?? "").substring(1)
			const typingsPath = resolve(`/${pkgName}`, pkgJson.types ?? pkgJson.typings ?? "index.d.ts").substring(1)
			project.setMapping(`/node_modules/${typingsPath}`, `/node_modules/${mainPath}`)
		}
	}

	project.vfs.writeFile(
		"/src/ecs.ts",
		`
		import { world as World } from "@rbxts/jecs"
		export const world = World()
		export const A = world.component<string>()
		export const B = world.component<number>()
		export const C = world.component<boolean>()
		type Unnameable = { readonly __unique: unique symbol }
		export const D = world.component<Unnameable>()
		export const tbl = {
			E: world.component<Unnameable>(),
		}
		`,
	)

	try {
		return project.compileSource(source + "\n;export {};")
	} catch (e) {
		if (typeof e === "object" && e !== null) {
			throw e.toString()
		}
		throw e
	}
}


local ServerScriptService = game:GetService("ServerScriptService")
local Decoder = require(ServerScriptService.Decoder.decoder)

return function(expect)
	return function(payload: buffer, expectedClassName: string, expectedValue: any)
		return function(_, done)
			local root = Decoder(payload)
			expect(root).toEqual(expect.anything())
			expect(typeof(root)).toEqual("Instance")

			local value = root:FindFirstChildWhichIsA(expectedClassName)
			expect(value).toEqual(expect.anything())
			expect(value.ClassName).toEqual(expectedClassName)
			expect(value.Value).toBe(expectedValue)

			done()
		end
	end
end


--!native
--!optimize 2

-- TODO: Ensure decoded output is sane and correct

local TYPE_ID = table.freeze({
	String = 0,
	Attributes = 1,
	Axes = 2,
	Bool = 3,
	BrickColor = 4,
	CFrame = 5,
	Color3 = 6,
	Color3uint8 = 7,
	ColorSequence = 8,
	Enum = 9,
	Faces = 10,
	Float32 = 11,
	Float64 = 12,
	Int32 = 13,
	MaterialColors = 14,
	NumberRange = 15,
	NumberSequence = 16,
	None = 17,
	DefaultPhysicalProperties = 18,
	CustomPhysicalProperties = 19,
	Ray = 20,
	Rect = 21,
	Ref = 22,
	Region3 = 23,
	Region3int16 = 24,
	SecurityCapabilities = 25,
	BinaryString = 26,
	Tags = 27,
	UDim = 28,
	UDim2 = 29,
	Vector2 = 30,
	Vector2int16 = 31,
	Vector3 = 32,
	Vector3int16 = 33,

	Font = 34,
})

-- thank you rojo developers: https://dom.rojo.space/binary.html#cframe (god bless)
local CFRAME_ID_LOOKUP_TABLE = table.freeze({
	[0x02] = CFrame.fromEulerAnglesYXZ(0, 0, 0),
	[0x03] = CFrame.fromEulerAnglesYXZ(math.rad(90), 0, 0),
	[0x05] = CFrame.fromEulerAnglesYXZ(0, math.rad(180), math.rad(180)),
	[0x06] = CFrame.fromEulerAnglesYXZ(math.rad(-90), 0, 0),
	[0x07] = CFrame.fromEulerAnglesYXZ(0, math.rad(180), math.rad(90)),
	[0x09] = CFrame.fromEulerAnglesYXZ(0, math.rad(90), math.rad(90)),
	[0x0a] = CFrame.fromEulerAnglesYXZ(0, 0, math.rad(90)),
	[0x0c] = CFrame.fromEulerAnglesYXZ(0, math.rad(-90), math.rad(90)),
	[0x0d] = CFrame.fromEulerAnglesYXZ(math.rad(-90), math.rad(-90), 0),
	[0x0e] = CFrame.fromEulerAnglesYXZ(math.rad(0), math.rad(-90), 0),
	[0x10] = CFrame.fromEulerAnglesYXZ(math.rad(90), math.rad(-90), 0),
	[0x11] = CFrame.fromEulerAnglesYXZ(math.rad(0), math.rad(90), 180),

	[0x14] = CFrame.fromEulerAnglesYXZ(0, math.rad(180), 0),
	[0x15] = CFrame.fromEulerAnglesYXZ(math.rad(-90), math.rad(-180), 0),
	[0x17] = CFrame.fromEulerAnglesYXZ(0, 0, math.rad(180)),
	[0x18] = CFrame.fromEulerAnglesYXZ(math.rad(90), math.rad(180), 0),
	[0x19] = CFrame.fromEulerAnglesYXZ(0, 0, math.rad(-90)),
	[0x1b] = CFrame.fromEulerAnglesYXZ(0, math.rad(-90), math.rad(-90)),
	[0x1c] = CFrame.fromEulerAnglesYXZ(0, math.rad(-180), math.rad(-90)),
	[0x1e] = CFrame.fromEulerAnglesYXZ(0, math.rad(90), math.rad(-90)),
	[0x1f] = CFrame.fromEulerAnglesYXZ(math.rad(90), math.rad(90), 0),
	[0x20] = CFrame.fromEulerAnglesYXZ(0, math.rad(90), 0),
	[0x22] = CFrame.fromEulerAnglesYXZ(math.rad(-90), math.rad(90), 0),
	[0x23] = CFrame.fromEulerAnglesYXZ(0, math.rad(-90), math.rad(180)),
})

local NewScript: (code: string, parent: Instance?) -> Script = NewScript
	or function(code, parent)
		local script = Instance.new("Script")
		script.Source = code
		script.Parent = parent

		return script
	end

local NewLocalScript: (code: string, parent: Instance?) -> LocalScript = NewLocalScript
	or function(code, parent)
		local script = Instance.new("LocalScript")
		script.Source = code
		script.Parent = parent

		return script
	end

local NewModuleScript: (code: string, parent: Instance?) -> ModuleScript = NewModuleScript
	or function(code, parent)
		local script = Instance.new("ModuleScript")
		script.Source = code
		script.Parent = parent

		return script
	end

local function decode(payloadBuffer: buffer)
	local nilParentedInstance = Instance.new("Folder", nil)

	local loc = 0
	local VARIANT_DECODER: { [number]: () -> any }
	local nextVariant

	local function nextNullstring(): string
		local start = loc
		while true do
			loc += 1
			if buffer.readu8(payloadBuffer, loc) == 0 then
				-- ensure we skip null byte
				loc += 1
				break
			end
		end

		-- ensure we skip null byte in decoded string
		return buffer.readstring(payloadBuffer, start, loc - start - 1)
	end

	VARIANT_DECODER = table.freeze({
		[TYPE_ID.String] = function()
			local varstringMetadata = buffer.readu8(payloadBuffer, loc)
			loc += 1
			local stringLength

			if varstringMetadata == 1 then
				-- u8
				stringLength = buffer.readu8(payloadBuffer, loc)
				loc += 1
			elseif varstringMetadata == 2 then
				-- u16
				stringLength = buffer.readu16(payloadBuffer, loc)
				loc += 2
			elseif varstringMetadata == 4 then
				-- u32
				stringLength = buffer.readu32(payloadBuffer, loc)
				loc += 4
			elseif varstringMetadata == 8 then
				error("u64 varstring is unsupported")
			elseif varstringMetadata == 16 then
				error("u128 varstring is unsupported")
			else
				error(`varstringMetadata value ({varstringMetadata}) is unsupported`)
			end

			loc += stringLength

			return buffer.readstring(payloadBuffer, loc - stringLength, stringLength)
		end,

		[TYPE_ID.BinaryString] = function()
			local stringLength = buffer.readu32(payloadBuffer, loc)
			loc += 4 + stringLength

			return buffer.readstring(payloadBuffer, loc - stringLength, stringLength)
		end,

		[TYPE_ID.None] = function()
			return nil
		end,

		[TYPE_ID.Ref] = function()
			return nextNullstring()
		end,

		[TYPE_ID.Enum] = function()
			local enumInternal = buffer.readu32(payloadBuffer, loc)
			loc += 4
			return enumInternal
		end,

		[TYPE_ID.Float32] = function()
			local float = buffer.readf32(payloadBuffer, loc)
			loc += 4
			return float
		end,

		[TYPE_ID.Float64] = function()
			local float = buffer.readf64(payloadBuffer, loc)
			loc += 8
			return float
		end,

		[TYPE_ID.Int32] = function()
			local int = buffer.readi32(payloadBuffer, loc)
			loc += 4
			return int
		end,

		[TYPE_ID.Bool] = function()
			local bool = buffer.readu8(payloadBuffer, loc)
			loc += 1
			return bool == 1
		end,

		[TYPE_ID.Tags] = function()
			-- length of encoded array
			local tagsLength = buffer.readu16(payloadBuffer, loc)
			loc += 2

			local tags = {}

			while tagsLength > 0 do
				local tag = nextNullstring()
				table.insert(tags, tag)

				tagsLength -= 1
			end

			return tags
		end,

		[TYPE_ID.Attributes] = function()
			local attributesLength = buffer.readu16(payloadBuffer, loc)
			loc += 2

			local attributeMap: { [string]: any } = {}
			while attributesLength > 0 do
				local attributeName = nextNullstring()
				attributeMap[attributeName] = nextVariant()
				-- print(attributeName, #attributeName, attributeMap[attributeName])

				attributesLength -= 1
			end

			return attributeMap
		end,

		[TYPE_ID.SecurityCapabilities] = function()
			-- SKIP: SecurityCapabilities is not writable in scripts
			loc += 8
		end,

		[TYPE_ID.BrickColor] = function()
			return BrickColor.new(nextNullstring() :: any)
		end,

		[TYPE_ID.UDim] = function()
			local offset, scale = buffer.readi32(payloadBuffer, loc), buffer.readf32(payloadBuffer, loc + 4)
			loc += 8

			return UDim.new(scale, offset)
		end,

		[TYPE_ID.UDim2] = function()
			local xOffset, yOffset, xScale, yScale =
				buffer.readi32(payloadBuffer, loc),
				buffer.readi32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8),
				buffer.readf32(payloadBuffer, loc + 12)

			loc += 16

			return UDim2.new(xScale, xOffset, yScale, yOffset)
		end,

		[TYPE_ID.Vector2] = function()
			local x, y = buffer.readf32(payloadBuffer, loc), buffer.readf32(payloadBuffer, loc + 4)
			loc += 8

			return Vector2.new(x, y)
		end,

		[TYPE_ID.Vector2int16] = function()
			local x, y = buffer.readi16(payloadBuffer, loc), buffer.readi16(payloadBuffer, loc + 2)
			loc += 4

			return Vector2int16.new(x, y)
		end,

		[TYPE_ID.Vector3] = function()
			local x, y, z =
				buffer.readf32(payloadBuffer, loc),
				buffer.readf32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8)
			loc += 12

			return Vector3.new(x, y, z)
		end,

		[TYPE_ID.Vector3int16] = function()
			local x, y, z =
				buffer.readi16(payloadBuffer, loc),
				buffer.readi16(payloadBuffer, loc + 2),
				buffer.readi16(payloadBuffer, loc + 4)
			loc += 6

			return Vector3int16.new(x, y, z)
		end,

		[TYPE_ID.CFrame] = function()
			local id = buffer.readu8(payloadBuffer, loc)
			loc += 1

			if id == 0 then
				-- all data is encoded
				local xx, xy, xz, yx, yy, yz, zx, zy, zz, positionX, positionY, positionZ =
					buffer.readf32(payloadBuffer, loc),
					buffer.readf32(payloadBuffer, loc + 4),
					buffer.readf32(payloadBuffer, loc + 8),
					buffer.readf32(payloadBuffer, loc + 12),
					buffer.readf32(payloadBuffer, loc + 16),
					buffer.readf32(payloadBuffer, loc + 20),
					buffer.readf32(payloadBuffer, loc + 24),
					buffer.readf32(payloadBuffer, loc + 28),
					buffer.readf32(payloadBuffer, loc + 32),
					buffer.readf32(payloadBuffer, loc + 36),
					buffer.readf32(payloadBuffer, loc + 40),
					buffer.readf32(payloadBuffer, loc + 44)

				loc += 48

				return CFrame.new(positionX, positionY, positionZ, xx, xy, xz, yx, yy, yz, zx, zy, zz)
			else
				local positionX, positionY, positionZ =
					buffer.readf32(payloadBuffer, loc),
					buffer.readf32(payloadBuffer, loc + 4),
					buffer.readf32(payloadBuffer, loc + 8)
				loc += 12

				return CFrame.new(positionX, positionY, positionZ) * CFRAME_ID_LOOKUP_TABLE[id]
			end
		end,

		[TYPE_ID.Color3] = function()
			local r, g, b =
				buffer.readf32(payloadBuffer, loc),
				buffer.readf32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8)

			loc += 12
			return Color3.new(r, g, b)
		end,

		[TYPE_ID.Color3uint8] = function()
			local r, g, b =
				buffer.readu8(payloadBuffer, loc), buffer.readu8(payloadBuffer, loc + 1), buffer.readu8(payloadBuffer, loc + 2)
			loc += 3

			return Color3.fromRGB(r, g, b)
		end,

		[TYPE_ID.Font] = function()
			local family = nextNullstring()
			local weight = buffer.readu16(payloadBuffer, loc)
			loc += 2
			local style = buffer.readu8(payloadBuffer, loc)
			loc += 1

			local weightEnum: Enum.FontWeight

			if weight == 100 then
				weightEnum = Enum.FontWeight.Thin
			elseif weight == 200 then
				weightEnum = Enum.FontWeight.ExtraLight
			elseif weight == 300 then
				weightEnum = Enum.FontWeight.Light
			elseif weight == 400 then
				weightEnum = Enum.FontWeight.Regular
			elseif weight == 500 then
				weightEnum = Enum.FontWeight.Medium
			elseif weight == 600 then
				weightEnum = Enum.FontWeight.SemiBold
			elseif weight == 700 then
				weightEnum = Enum.FontWeight.Bold
			elseif weight == 800 then
				weightEnum = Enum.FontWeight.ExtraBold
			elseif weight == 900 then
				weightEnum = Enum.FontWeight.Heavy
			else
				error(`font weight {weight} is not supported or is invalid`)
			end

			local styleEnum: Enum.FontStyle

			if style == 0 then
				styleEnum = Enum.FontStyle.Normal
			elseif style == 1 then
				styleEnum = Enum.FontStyle.Italic
			else
				error(`font style {style} is not supported or is invalid`)
			end

			return Font.new(family, weightEnum, styleEnum)
		end,

		[TYPE_ID.NumberRange] = function()
			local min, max = buffer.readf32(payloadBuffer, loc), buffer.readf32(payloadBuffer, loc + 4)
			loc += 8

			return NumberRange.new(min, max)
		end,

		[TYPE_ID.Rect] = function()
			local minX, minY, maxX, maxY =
				buffer.readf32(payloadBuffer, loc),
				buffer.readf32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8),
				buffer.readf32(payloadBuffer, loc + 12)

			loc += 16

			return Rect.new(minX, minY, maxX, maxY)
		end,

		[TYPE_ID.Axes] = function()
			local byte = buffer.readu8(payloadBuffer, loc)
			loc += 1
			-- bitflags
			-- const X = 1;
			-- const Y = 2;
			-- const Z = 4;
			local x = if bit32.extract(byte, 0, 1) == 1 then Enum.Axis.X else nil
			local y = if bit32.extract(byte, 1, 1) == 1 then Enum.Axis.Y else nil
			local z = if bit32.extract(byte, 2, 1) == 1 then Enum.Axis.Z else nil

			return Axes.new(x, y, z)
		end,

		[TYPE_ID.Faces] = function()
			local byte = buffer.readu8(payloadBuffer, loc)
			loc += 1
			-- bitflags
			-- const RIGHT = 1;
			-- const TOP = 2;
			-- const BACK = 4;
			-- const LEFT = 8;
			-- const BOTTOM = 16;
			-- const FRONT = 32;
			local right = if bit32.extract(byte, 0, 1) == 1 then Enum.NormalId.Right else nil
			local top = if bit32.extract(byte, 1, 1) == 1 then Enum.NormalId.Top else nil
			local back = if bit32.extract(byte, 2, 1) == 1 then Enum.NormalId.Back else nil
			local left = if bit32.extract(byte, 3, 1) == 1 then Enum.NormalId.Left else nil
			local bottom = if bit32.extract(byte, 4, 1) == 1 then Enum.NormalId.Bottom else nil
			local front = if bit32.extract(byte, 5, 1) == 1 then Enum.NormalId.Front else nil

			return Faces.new(right, top, back, left, bottom, front)
		end,

		[TYPE_ID.Region3] = function()
			-- We cannot test this: there are no properties to test this for.
			local minX, minY, minZ, maxX, maxY, maxZ =
				buffer.readf32(payloadBuffer, loc),
				buffer.readf32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8),
				buffer.readf32(payloadBuffer, loc + 12),
				buffer.readf32(payloadBuffer, loc + 16),
				buffer.readf32(payloadBuffer, loc + 20)

			loc += 24

			return Region3.new(Vector3.new(minX, minY, minZ), Vector3.new(maxX, maxY, maxZ))
		end,

		[TYPE_ID.Region3int16] = function()
			-- Tests cannot be implemented for the same reason as Region3
			local minX, minY, minZ, maxX, maxY, maxZ =
				buffer.readi16(payloadBuffer, loc),
				buffer.readi16(payloadBuffer, loc + 2),
				buffer.readi16(payloadBuffer, loc + 4),
				buffer.readi16(payloadBuffer, loc + 6),
				buffer.readi16(payloadBuffer, loc + 8),
				buffer.readi16(payloadBuffer, loc + 10)

			loc += 12
			return Region3int16.new(Vector3int16.new(minX, minY, minZ), Vector3int16.new(maxX, maxY, maxZ))
		end,

		[TYPE_ID.NumberSequence] = function()
			local numberSequenceLength = buffer.readu16(payloadBuffer, loc)
			loc += 2

			local keypoints: { NumberSequenceKeypoint } = {}

			while numberSequenceLength > 0 do
				local envelope, time, value =
					buffer.readf32(payloadBuffer, loc),
					buffer.readf32(payloadBuffer, loc + 4),
					buffer.readf32(payloadBuffer, loc + 8)
				loc += 12

				table.insert(keypoints, NumberSequenceKeypoint.new(time, value, envelope))

				numberSequenceLength -= 1
			end

			return NumberSequence.new(keypoints)
		end,

		[TYPE_ID.Ray] = function()
			local directionX, directionY, directionZ, originX, originY, originZ =
				buffer.readf32(payloadBuffer, loc),
				buffer.readf32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8),
				buffer.readf32(payloadBuffer, loc + 12),
				buffer.readf32(payloadBuffer, loc + 16),
				buffer.readf32(payloadBuffer, loc + 20)

			loc += 24

			return Ray.new(Vector3.new(originX, originY, originZ), Vector3.new(directionX, directionY, directionZ))
		end,

		[TYPE_ID.MaterialColors] = function()
			-- SKIP: Terrain.MaterialColors is not writable by scripts + we could use :SetMaterialColor(), but theres no reason to support that
			loc += 69
		end,

		[TYPE_ID.ColorSequence] = function()
			local keypointsLength = buffer.readu16(payloadBuffer, loc)
			loc += 2

			local keypoints: { ColorSequenceKeypoint } = {}

			while keypointsLength > 0 do
				local time, r, g, b =
					buffer.readf32(payloadBuffer, loc),
					buffer.readf32(payloadBuffer, loc + 4),
					buffer.readf32(payloadBuffer, loc + 8),
					buffer.readf32(payloadBuffer, loc + 12)

				table.insert(keypoints, ColorSequenceKeypoint.new(time, Color3.new(r, g, b)))

				loc += 16
				keypointsLength -= 1
			end

			return ColorSequence.new(keypoints)
		end,

		[TYPE_ID.DefaultPhysicalProperties] = function()
			-- SKIP: DefaultPhysicalProperties does not translate to Luau without a material
			return nil
		end,

		[TYPE_ID.CustomPhysicalProperties] = function()
			local density, elasticity, elasticityWeight, friction, frictionWeight =
				buffer.readf32(payloadBuffer, loc),
				buffer.readf32(payloadBuffer, loc + 4),
				buffer.readf32(payloadBuffer, loc + 8),
				buffer.readf32(payloadBuffer, loc + 12),
				buffer.readf32(payloadBuffer, loc + 16)

			loc += 20
			return PhysicalProperties.new(density, friction, elasticity, frictionWeight, elasticityWeight)
		end,
	})

	function nextVariant(expectedTypeIds: { number }?)
		-- 1. read type id
		-- 2. loc++;
		-- 3. call type id handler (which uses loc)

		local typeId = buffer.readu8(payloadBuffer, loc)
		loc += 1

		if expectedTypeIds and not table.find(expectedTypeIds, typeId) then
			error(`expected type id inside of array {table.concat(expectedTypeIds, ", ")}, got {typeId}`)
		end

		return assert(VARIANT_DECODER[typeId], "no variant decoder for type id " .. typeId)()
	end

	local rootReferent: string?
	local referentTree: { [string]: Instance } = {}

	-- late properties must be applied after the entire tree is decoded
	-- index signature: latePropertyMap[ref][propertyName] = propertyValue
	local latePropertiesMap: { [string]: { [string]: string } } = {}

	local function decodeInstance()
		local name: string = nextVariant({ TYPE_ID.String })
		local className: string = nextVariant({ TYPE_ID.String })
		local instanceReferent: string = nextVariant({ TYPE_ID.Ref })
		local parentReferent: string = nextVariant({ TYPE_ID.Ref, TYPE_ID.None })

		local propertiesLength = buffer.readu16(payloadBuffer, loc)
		local propertiesMap: { [string]: any } = {}
		loc += 2

		while propertiesLength > 0 do
			local propertyName = nextNullstring()

			local isTypeIdReferent = buffer.readu8(payloadBuffer, loc) == TYPE_ID.Ref
			if isTypeIdReferent then
				if not latePropertiesMap[instanceReferent] then
					latePropertiesMap[instanceReferent] = {}
				end

				latePropertiesMap[instanceReferent][propertyName] = nextVariant({ TYPE_ID.Ref })
			else
				propertiesMap[propertyName] = nextVariant()
			end

			-- print(propertyName)
			-- print(propertyName, propertiesMap[propertyName])

			propertiesLength -= 1
		end

		local instance: Instance
		if className == "Script" then
			instance = NewScript(propertiesMap.Source or "", nilParentedInstance)
		elseif className == "LocalScript" then
			instance = NewLocalScript(propertiesMap.Source or "", nilParentedInstance)
		elseif className == "ModuleScript" then
			instance = NewModuleScript(propertiesMap.Source or "", nilParentedInstance)
		elseif className == "DataModel" then
			instance = Instance.new("Model")
		else
			instance = Instance.new(className)
		end
		referentTree[instanceReferent] = instance

		instance.Name = name

		if propertiesMap.Attributes then
			for attributeName, value in propertiesMap.Attributes do
				instance:SetAttribute(attributeName, value)
			end

			propertiesMap.Attributes = nil
		end

		if propertiesMap.Tags then
			for _, tag in propertiesMap.Tags do
				instance:AddTag(tag)
			end

			propertiesMap.Tags = nil
		end

		for propertyName, propertyValue in propertiesMap do
			-- TODO: add custom handlers
			local success, error = pcall(function()
				instance[propertyName] = propertyValue
			end)

			if not success then
				-- warn(`failed setting property {propertyName} with value {propertyValue}; got error {error}`)
			end
		end

		if parentReferent ~= nil then
			instance.Parent = referentTree[parentReferent]
		else
			assert(rootReferent == nil, "there are multiple root referents in the hierarchy")
			rootReferent = instanceReferent
		end

		return instanceReferent
	end

	-- decode entire buffer
	while true do
		local decodedReferent = decodeInstance()
		-- print(`decoded referent {decodedReferent}{if rootReferent == decodedReferent then " [root]" else ""}`)

		if buffer.len(payloadBuffer) == loc then
			-- print("finished decoding payloadBuffer")
			break
		end
	end
	assert(rootReferent, "no root referent in hierarchy")

	for referent, propertyMap in latePropertiesMap do
		-- late property handling (referent handling)
		for propertyName, propertyValue in propertyMap do
			local success, error = pcall(function()
				referentTree[referent][propertyName] = referentTree[propertyValue]
			end)

			if not success then
				warn(
					`failed setting late property {propertyName} on referent {referent} with value {propertyValue}; got error {error}`
				)
			end
		end
	end

	return referentTree[rootReferent]
end

-- -- note you can also use zbase64 instead of base64 (zstd compress then base64 the buffer)
-- -- test.bin
-- local payloadBuffer: buffer

-- if game then
-- 	payloadBuffer = game:GetService("HttpService"):JSONDecode([[{"m":null,"t":"buffer","base64":""}]])
-- else
-- 	payloadBuffer = buffer.fromstring(require("@lune/fs").readFile("examples/attributes_and_tags.bin"))
-- end

return decode


local ServerScriptService = game:GetService("ServerScriptService")
local runCLI = require(ServerScriptService.DevPackages.Jest).runCLI

local processServiceExists, ProcessService = pcall(function()
	return game:GetService("ProcessService")
end)

local status, result = runCLI(ServerScriptService.Decoder, {
	verbose = true,
	ci = false,
}, { ServerScriptService.Decoder }):awaitStatus()

if status == "Rejected" then
	print(result)
end

if status == "Resolved" and result.results.numFailedTestSuites == 0 and result.results.numFailedTests == 0 then
	if processServiceExists then
		ProcessService:ExitAsync(0)
	end
end

if processServiceExists then
	ProcessService:ExitAsync(1)
end

return nil


--!native
--!optimize 2
--!strict

--[[
	Base122 - A space efficent alternative to base-64.
	>>> https://blog.kevinalbs.com/base122
]]

-- to create Base123, we comment out ampersand being illegal...
local kIllegals = {
	0, -- null
	10, -- newline
	13, -- carriage return
	34, -- double quote
	-- 38, -- SKIP! ampersand, without this being illegal we have base123
	92, -- backslash
}

local kShortened = 0b111 -- uses the illegal index to signify the last two-byte char encodes <= 7 bits.

local function getBufferSize(input: string)
	local decodedIndex = 0
	local bitOfByte = 0
	local encountered = 0

	for _, character: number in utf8.codes(input) do
		if character > 127 then
			local illegalIndex = bit32.band(bit32.rshift(character, 8), 7)
			encountered += if illegalIndex ~= kShortened then 1 else 0
		end

		encountered += 1

		while encountered ~= 0 do
			bitOfByte += 7
			if bitOfByte >= 8 then
				decodedIndex += 1
				bitOfByte -= 8
			end

			encountered -= 1
		end
	end

	return decodedIndex
end

-- Returns the decoded buffer.
local function decode(input: string): buffer
	-- 87.5% efficiency
	-- math.floor(string.len(input) * 7 / 8)?
	local decoded = buffer.create(getBufferSize(input))
	local decodedIndex = 0
	local currentByte = 0
	local bitOfByte = 0

	local function push7(byte: number)
		byte = bit32.lshift(byte, 1)
		currentByte = bit32.bor(currentByte, bit32.rshift(byte, bitOfByte))
		bitOfByte += 7
		if bitOfByte >= 8 then
			buffer.writeu8(decoded, decodedIndex, currentByte)
			decodedIndex += 1
			bitOfByte -= 8
			currentByte = bit32.band(bit32.lshift(byte, (7 - bitOfByte)), 255)
		end
	end

	for _, character in utf8.codes(input) do
		if character > 127 then
			local illegalIndex = bit32.band(bit32.rshift(character, 8), 7)
			if illegalIndex ~= kShortened then
				push7(kIllegals[illegalIndex + 1])
			end
			push7(bit32.band(character, 127))
		else
			push7(character)
		end
	end

	return decoded
end

local decoded = decode(
	"\194\128\32\32\24\16\198\134\3\66\1\16\80\44\24\202\135\3\98\1\8\72\38\20\199\133\66\113\64\100\52\27\14\7\35\97\121\195\130\206\145\73\4\82\49\28\80\41\21\199\165\66\105\56\94\48\24\76\70\51\33\84\108\55\28\14\39\35\89\112\122\62\31\80\8\20\18\202\136\69\35\17\105\4\74\41\22\76\38\83\73\117\2\69\36\83\42\21\42\101\58\97\50\90\45\87\11\85\114\125\64\97\49\24\108\70\43\25\78\104\52\90\77\54\99\53\211\175\56\28\46\39\27\81\106\118\59\94\15\23\83\109\120\125\63\31\112\8\12\198\135\4\66\97\80\120\68\38\21\11\70\35\49\104\124\66\35\18\73\101\18\89\52\94\49\25\77\38\115\73\108\122\63\32\80\104\84\58\37\22\77\39\84\42\53\42\93\50\91\46\87\108\22\27\21\78\105\53\91\45\119\11\77\106\119\60\94\111\87\124\6\7\5\67\98\49\56\108\62\35\19\74\101\115\25\211\182\63\33\81\105\52\122\77\46\91\47\88\108\118\91\61\102\119\61\95\112\56\60\46\31\19\75\102\115\122\29\30\87\47\89\109\119\59\126\15\15\75\103\116\122\125\94\127\71\103\117\123\126\63\63\111\120"
)

for i = 0, buffer.len(decoded) - 1 do
	assert(i == buffer.readu8(decoded, i), "failed for i: " .. i)
end

return decode


--!native
--!optimize 2
--!strict

-- to create Base123, we comment out ampersand being illegal...
local kIllegals = table.freeze({
	0, -- null
	10, -- newline
	13, -- carriage return
	34, -- double quote
	-- 38, -- SKIP! ampersand, without this being illegal we have base123
	92, -- backslash
})

-- local kShortened = 0b111 -- uses the illegal index to signify the last two-byte char encodes <= 7 bits.
local bit32 = bit32
local bit32_lshift = bit32.lshift
local bit32_rshift = bit32.rshift
local bit32_band = bit32.band

-- Returns the decoded buffer and the actual, usable length of the buffer.
local function decode(input: string): buffer
	-- 87.5% efficiency
	local decodedIndex = 0
	local bitOfByte = 0
	-- this is "encountered" in the O(n) first try
	local currentByte = 0

	for _, character: number in utf8.codes(input) do
		if character > 127 then
			local illegalIndex = bit32_band(bit32_rshift(character, 8), 7)
			currentByte += if illegalIndex ~= 0b111 then 1 else 0
		end

		currentByte += 1

		while currentByte ~= 0 do
			bitOfByte += 7
			if bitOfByte >= 8 then
				decodedIndex += 1
				bitOfByte -= 8
			end

			currentByte -= 1
		end
	end

	local decoded = buffer.create(decodedIndex)

	-- reset state from O(n) first try
	decodedIndex = 0
	bitOfByte = 0
	currentByte = 0

	local function push7(byte: number)
		byte = bit32_lshift(byte, 1)
		currentByte = bit32.bor(currentByte, bit32_rshift(byte, bitOfByte))
		bitOfByte += 7
		if bitOfByte >= 8 then
			buffer.writeu8(decoded, decodedIndex, currentByte)
			decodedIndex += 1
			bitOfByte -= 8
			currentByte = bit32_band(bit32_lshift(byte, (7 - bitOfByte)), 255)
		end
	end

	for _, character in utf8.codes(input) do
		if character > 127 then
			local illegalIndex = bit32_band(bit32_rshift(character, 8), 7)
			if illegalIndex ~= 7 then
				push7(kIllegals[illegalIndex + 1])
			end
			push7(bit32_band(character, 127))
		else
			push7(character)
		end
	end

	return decoded
end

local payloadBuffer =
	game:GetService("EncodingService"):DecompressBuffer(decode("%REPLACE_ME%"), Enum.CompressionAlgorithm.Zstd)


local a,b=table.freeze({0,10,13,34,92}),bit32 local c,d,e=b.lshift,b.rshift,b.band local f=function(f)local g,h,i=0,0,0 for j,k in utf8.codes(f)do if k>127 then local l=e(d(k,8),7)i+=if l~=0b111 then 1 else 0 end i+=1 while i~=0 do h+=7 if h>=8 then g+=1 h-=8 end i-=1 end end local j=buffer.create(g)g,h,i=0,0,0 local k=function(k)k=c(k,1)i=b.bor(i,d(k,h))h+=7 if h>=8 then buffer.writeu8(j,g,i)g+=1 h-=8 i=e(c(k,(7-h)),255)end end for l,m in utf8.codes(f)do if m>127 then local n=e(d(m,8),7)if n~=7 then k(a[n+1])end k(e(m,127))else k(m)end end return j end


--[[
Copyright © 2024 Stephen Leitnick

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- -----------------------------------------------------------------------------
--               Batched Yield-Safe Signal Implementation                     --
-- This is a Signal class which has effectively identical behavior to a       --
-- normal RBXScriptSignal, with the only difference being a couple extra      --
-- stack frames at the bottom of the stack trace when an error is thrown.     --
-- This implementation caches runner coroutines, so the ability to yield in   --
-- the signal handlers comes at minimal extra cost over a naive signal        --
-- implementation that either always or never spawns a thread.                --
--                                                                            --
-- License:                                                                   --
--   Licensed under the MIT license.                                          --
--                                                                            --
-- Authors:                                                                   --
--   stravant - July 31st, 2021 - Created the file.                           --
--   sleitnick - August 3rd, 2021 - Modified for Knit.                        --
-- -----------------------------------------------------------------------------
local task = require("@lune/task")

-- Signal types
export type Connection = {
	Disconnect: (self: Connection) -> (),
	Destroy: (self: Connection) -> (),
	Connected: boolean,
}

export type Signal<T...> = {
	Fire: (self: Signal<T...>, T...) -> (),
	FireDeferred: (self: Signal<T...>, T...) -> (),
	Connect: (self: Signal<T...>, fn: (T...) -> ()) -> Connection,
	Once: (self: Signal<T...>, fn: (T...) -> ()) -> Connection,
	DisconnectAll: (self: Signal<T...>) -> (),
	GetConnections: (self: Signal<T...>) -> { Connection },
	Destroy: (self: Signal<T...>) -> (),
	Wait: (self: Signal<T...>) -> T...,
}

-- The currently idle thread to run the next handler on
local freeRunnerThread = nil

-- Function which acquires the currently idle handler runner thread, runs the
-- function fn on it, and then releases the thread, returning it to being the
-- currently idle one.
-- If there was a currently idle runner thread already, that's okay, that old
-- one will just get thrown and eventually GCed.
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
	local acquiredRunnerThread = freeRunnerThread
	freeRunnerThread = nil
	fn(...)
	-- The handler finished running, this runner thread is free again.
	freeRunnerThread = acquiredRunnerThread
end

-- Coroutine runner that we create coroutines of. The coroutine can be
-- repeatedly resumed with functions to run followed by the argument to run
-- them with.
local function runEventHandlerInFreeThread(...)
	acquireRunnerThreadAndCallEventHandler(...)
	while true do
		acquireRunnerThreadAndCallEventHandler(coroutine.yield())
	end
end

--[=[
	@within Signal
	@interface SignalConnection
	.Connected boolean
	.Disconnect (SignalConnection) -> ()

	Represents a connection to a signal.
	```lua
	local connection = signal:Connect(function() end)
	print(connection.Connected) --> true
	connection:Disconnect()
	print(connection.Connected) --> false
	```
]=]

-- Connection class
local Connection = {}
Connection.__index = Connection

function Connection:Disconnect()
	if not self.Connected then
		return
	end
	self.Connected = false

	-- Unhook the node, but DON'T clear it. That way any fire calls that are
	-- currently sitting on this node will be able to iterate forwards off of
	-- it, but any subsequent fire calls will not hit it, and it will be GCed
	-- when no more fire calls are sitting on it.
	if self._signal._handlerListHead == self then
		self._signal._handlerListHead = self._next
	else
		local prev = self._signal._handlerListHead
		while prev and prev._next ~= self do
			prev = prev._next
		end
		if prev then
			prev._next = self._next
		end
	end
end

Connection.Destroy = Connection.Disconnect

-- Make Connection strict
setmetatable(Connection, {
	__index = function(_tb, key)
		error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2)
	end,
	__newindex = function(_tb, key, _value)
		error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2)
	end,
})

--[=[
	@within Signal
	@type ConnectionFn (...any) -> ()

	A function connected to a signal.
]=]

--[=[
	@class Signal

	A Signal is a data structure that allows events to be dispatched
	and observed.

	This implementation is a direct copy of the de facto standard, [GoodSignal](https://devforum.roblox.com/t/lua-signal-class-comparison-optimal-goodsignal-class/1387063),
	with some added methods and typings.

	For example:
	```lua
	local signal = Signal.new()

	-- Subscribe to a signal:
	signal:Connect(function(msg)
		print("Got message:", msg)
	end)

	-- Dispatch an event:
	signal:Fire("Hello world!")
	```
]=]
local Signal = {}
Signal.__index = Signal

--[=[
	Constructs a new Signal

	@return Signal
]=]
function Signal.new<T...>(): Signal<T...>
	local self = setmetatable({
		_handlerListHead = false,
		_proxyHandler = nil,
		_yieldedThreads = nil,
	}, Signal)

	return self
end

--[=[
	Constructs a new Signal that wraps around an RBXScriptSignal.

	@param rbxScriptSignal RBXScriptSignal -- Existing RBXScriptSignal to wrap
	@return Signal

	For example:
	```lua
	local signal = Signal.Wrap(workspace.ChildAdded)
	signal:Connect(function(part) print(part.Name .. " added") end)
	Instance.new("Part").Parent = workspace
	```
]=]
function Signal.Wrap<T...>(rbxScriptSignal: RBXScriptSignal): Signal<T...>
	assert(
		typeof(rbxScriptSignal) == "RBXScriptSignal",
		"Argument #1 to Signal.Wrap must be a RBXScriptSignal; got " .. typeof(rbxScriptSignal)
	)

	local signal = Signal.new()
	signal._proxyHandler = rbxScriptSignal:Connect(function(...)
		signal:Fire(...)
	end)

	return signal
end

--[=[
	Checks if the given object is a Signal.

	@param obj any -- Object to check
	@return boolean -- `true` if the object is a Signal.
]=]
function Signal.Is(obj: any): boolean
	return type(obj) == "table" and getmetatable(obj) == Signal
end

--[=[
	@param fn ConnectionFn
	@return SignalConnection

	Connects a function to the signal, which will be called anytime the signal is fired.
	```lua
	signal:Connect(function(msg, num)
		print(msg, num)
	end)

	signal:Fire("Hello", 25)
	```
]=]
function Signal:Connect(fn)
	local connection = setmetatable({
		Connected = true,
		_signal = self,
		_fn = fn,
		_next = false,
	}, Connection)

	if self._handlerListHead then
		connection._next = self._handlerListHead
	end

  self._handlerListHead = connection
	return connection
end

--[=[
	@deprecated v1.3.0 -- Use `Signal:Once` instead.
	@param fn ConnectionFn
	@return SignalConnection
]=]
function Signal:ConnectOnce(fn)
	return self:Once(fn)
end

--[=[
	@param fn ConnectionFn
	@return SignalConnection

	Connects a function to the signal, which will be called the next time the signal fires. Once
	the connection is triggered, it will disconnect itself.
	```lua
	signal:Once(function(msg, num)
		print(msg, num)
	end)

	signal:Fire("Hello", 25)
	signal:Fire("This message will not go through", 10)
	```
]=]
function Signal:Once(fn)
	local connection
	local done = false

	connection = self:Connect(function(...)
		if done then
			return
		end

		done = true
		connection:Disconnect()
		fn(...)
	end)

	return connection
end

function Signal:GetConnections()
	local items = {}

	local item = self._handlerListHead
	while item do
		table.insert(items, item)
		item = item._next
	end

	return items
end

-- Disconnect all handlers. Since we use a linked list it suffices to clear the
-- reference to the head handler.
--[=[
	Disconnects all connections from the signal.
	```lua
	signal:DisconnectAll()
	```
]=]
function Signal:DisconnectAll()
	local item = self._handlerListHead
	while item do
		item.Connected = false
		item = item._next
	end
	self._handlerListHead = false

	local yieldedThreads = rawget(self, "_yieldedThreads")
	if yieldedThreads then
		for thread in yieldedThreads do
			if coroutine.status(thread) == "suspended" then
				warn(debug.traceback(thread, "signal disconnected; yielded thread cancelled", 2))
				task.cancel(thread)
			end
		end
		table.clear(self._yieldedThreads)
	end
end

-- Signal:Fire(...) implemented by running the handler functions on the
-- coRunnerThread, and any time the resulting thread yielded without returning
-- to us, that means that it yielded to the Roblox scheduler and has been taken
-- over by Roblox scheduling, meaning we have to make a new coroutine runner.
--[=[
	@param ... any

	Fire the signal, which will call all of the connected functions with the given arguments.
	```lua
	signal:Fire("Hello")

	-- Any number of arguments can be fired:
	signal:Fire("Hello", 32, {Test = "Test"}, true)
	```
]=]
function Signal:Fire(...)
	local item = self._handlerListHead
	while item do
		if item.Connected then
			if not freeRunnerThread then
				freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
			end
			task.spawn(freeRunnerThread, item._fn, ...)
		end
		item = item._next
	end
end

--[=[
	@param ... any

	Same as `Fire`, but uses `task.defer` internally & doesn't take advantage of thread reuse.
	```lua
	signal:FireDeferred("Hello")
	```
]=]
function Signal:FireDeferred(...)
	local item = self._handlerListHead
	while item do
		local conn = item
		task.defer(function(...)
			if conn.Connected then
				conn._fn(...)
			end
		end, ...)
		item = item._next
	end
end

--[=[
	@return ... any
	@yields

	Yields the current thread until the signal is fired, and returns the arguments fired from the signal.
	Yielding the current thread is not always desirable. If the desire is to only capture the next event
	fired, using `Once` might be a better solution.
	```lua
	task.spawn(function()
		local msg, num = signal:Wait()
		print(msg, num) --> "Hello", 32
	end)
	signal:Fire("Hello", 32)
	```
]=]
function Signal:Wait()
	local yieldedThreads = rawget(self, "_yieldedThreads")
	if not yieldedThreads then
		yieldedThreads = {}
		rawset(self, "_yieldedThreads", yieldedThreads)
	end

	local thread = coroutine.running()
	yieldedThreads[thread] = true

	self:Once(function(...)
		yieldedThreads[thread] = nil
		task.spawn(thread, ...)
	end)

	return coroutine.yield()
end

--[=[
	Cleans up the signal.

	Technically, this is only necessary if the signal is created using
	`Signal.Wrap`. Connections should be properly GC'd once the signal
	is no longer referenced anywhere. However, it is still good practice
	to include ways to strictly clean up resources. Calling `Destroy`
	on a signal will also disconnect all connections immediately.
	```lua
	signal:Destroy()
	```
]=]
function Signal:Destroy()
	self:DisconnectAll()

	local proxyHandler = rawget(self, "_proxyHandler")
	if proxyHandler then
		proxyHandler:Disconnect()
	end
end

-- Make signal strict
setmetatable(Signal, {
	__index = function(_tb, key)
		error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2)
	end,
	__newindex = function(_tb, key, _value)
		error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2)
	end,
})

return table.freeze({
	new = Signal.new,
	Wrap = Signal.Wrap,
	Is = Signal.Is,
})


local roblox = require("@lune/roblox")
local fs = require("@lune/fs")
local luau = require("@lune/luau")
local process = require("@lune/process")
local task = require("@lune/task")

local signal = require("./mock-signal")

local testPlacePath = process.args[1]
local game = roblox.deserializePlace(fs.readFile(testPlacePath))
local threads: { thread } = {}

-- rUnit uses lemon signal
roblox.implementProperty("BindableEvent", "Event", function(get)
  return signal.new()
end)

local function tableJoin(...)
	local result = {}
	for i = 1, select("#", ...) do
		for k, v in select(i, ...) do
			result[k] = v
		end
	end
	return result
end

-- RuntimeLib uses :WaitForChild(), but tests don't need networking so :FindFirstChild() should be fine
roblox.implementMethod("Instance", "WaitForChild", function(self, ...)
	return self:FindFirstChild(...)
end)

local function memoize<T>(cache: { [string]: T }, key: string, getValue: () -> T): T
  local cached = cache[key]
  if cached then
    return cached
  end

  local value = getValue()
  cache[key] = value
  return value
end

-- Promise.lua indexes RunService.Heartbeat, but only uses it in Promise.defer and Promise.delay
local heartbeat = signal.new()
roblox.implementProperty("RunService", "Heartbeat", function()
	return heartbeat
end, function() end)

table.insert(threads, task.spawn(function()
	local lastFrame = os.clock();
	while true do
		task.wait();
		local dt = os.clock() - lastFrame
		heartbeat:Fire(dt)
	end
end))

roblox.implementMethod("RunService", "IsStudio", function()
	return true
end)

type LuneContext = "server" | "client" | "both"
local luneContext = "server"
roblox.implementMethod("RunService", "IsServer", function()
	return luneContext ~= "client"
end)

roblox.implementMethod("RunService", "IsClient", function()
	return luneContext ~= "server"
end)

roblox.implementMethod("RunService", "IsRunning", function()
	return false
end)

local localPlayer = roblox.Instance.new("Player")
roblox.implementMethod("Players", "GetPlayers", function(): { Player }
	return { localPlayer }
end)

roblox.implementMethod("DataModel", "IsLoaded", function()
	return true
end)

local onClientEvents = {}
roblox.implementProperty("RemoteEvent", "OnClientEvent", function(self)
  -- terrible but is fine for our purposes
	return memoize(onClientEvents, self:GetFullName(), function()
    return signal.new()
  end)
end)

local onServerEvents = {}
roblox.implementProperty("RemoteEvent", "OnServerEvent", function(self)
  return memoize(onServerEvents, self:GetFullName(), function()
    return signal.new()
  end)
end)

roblox.implementMethod("RemoteEvent", "FireServer", function(self, ...)
  self.OnServerEvent:Fire(localPlayer, ...)
end)

roblox.implementMethod("RemoteEvent", "FireClient", function(self, player, ...)
  self.OnClientEvent:Fire(...)
end)

roblox.implementMethod("RemoteEvent", "FireAllClients", function(self, ...)
  for _ in game.Players:GetPlayers() do
    self.OnClientEvent:Fire(...)
  end
end)

local unreliableOnClientEvents = {}
roblox.implementProperty("UnreliableRemoteEvent", "OnClientEvent", function(self)
	return memoize(unreliableOnClientEvents, self:GetFullName(), function()
    return signal.new()
  end)
end)

local unreliableOnServerEvents = {}
roblox.implementProperty("UnreliableRemoteEvent", "OnServerEvent", function(self)
	return memoize(unreliableOnServerEvents, self:GetFullName(), function()
    return signal.new()
  end)
end)

roblox.implementMethod("UnreliableRemoteEvent", "FireServer", function(self, ...)
  self.OnServerEvent:Fire(localPlayer, ...)
end)

roblox.implementMethod("UnreliableRemoteEvent", "FireClient", function(self, player, ...)
  self.OnClientEvent:Fire(...)
end)

roblox.implementMethod("UnreliableRemoteEvent", "FireAllClients", function(self, ...)
  for _ in game.Players:GetPlayers() do
    self.OnClientEvent:Fire(...)
  end
end)

roblox.implementMethod("HttpService", "JSONEncode", function()
	return nil
end)

local robloxRequire
local TS
local function setLuneContext(ctx: LuneContext): ()
  luneContext = ctx
end

local function runRobloxScript(script: Script | LocalScript | ModuleScript): any
	local callableFn = luau.load(luau.compile(script.Source), {
		debugName = script:GetFullName(),
		environment = tableJoin(roblox, {
			Instance = roblox.Instance,
			game = game,
			script = script,
			require = robloxRequire,
			tick = tick,
			task = task,
			debug = debug,
      TS = TS,
      setLuneContext = setLuneContext,
      localPlayer = localPlayer
		})
	})

	return callableFn()
end

local requireCache = {}

function robloxRequire(moduleScript: ModuleScript)
	-- the same script instance sometimes gives a different ref
	-- unsure why, but using :GetFullName() fixes this for now
	local scriptPath = moduleScript:GetFullName()
	local cached = requireCache[scriptPath]
	if cached then
		return table.unpack(cached)
	end

	local result = table.pack(runRobloxScript(moduleScript))
	requireCache[scriptPath] = result
	return table.unpack(result)
end

TS = robloxRequire(game:GetService("ReplicatedStorage"):WaitForChild("rbxts_include"):WaitForChild("RuntimeLib"))

runRobloxScript(game.ReplicatedStorage.Tests["run-tests"])
for _, thread in threads do
	task.cancel(thread)
end


import type { MessageEmitter } from "./message-emitter";
import type { BaseMessage, MessageCallback, FunctionMessageCallback } from "../structs";
import { RunService } from "@rbxts/services";
import { Error } from "../logging";

type Cleanup = () => void;

export abstract class ContextualEmitter<MessageData> {
  public abstract readonly context: "client" | "server";

  /**
   * Constructs a new ContextualEmitter.
   * @param master The master emitter to which messages are delegated.
   */
  public constructor(
    protected readonly master: MessageEmitter<MessageData>
  ) { }

  public abstract emit<K extends keyof MessageData>(player: (K & BaseMessage) | (Player | Player[]), message?: MessageData[K] | (K & BaseMessage), data?: MessageData[K] | boolean, unreliable?: boolean): void;
  public abstract setCallback<K extends keyof MessageData, R extends keyof MessageData>(
    message: K & BaseMessage,
    returnMessage: R & BaseMessage,
    callback: FunctionMessageCallback<MessageData[K], MessageData[R]>
  ): Cleanup;
  public abstract invoke<K extends keyof MessageData, R extends keyof MessageData>(
    message: K & BaseMessage,
    returnMessage: R & BaseMessage,
    player?: Player | MessageData[K],
    data?: MessageData[K] | boolean,
    unreliable?: boolean
  ): Promise<MessageData[R]>;

  /**
   * @returns A destructor function that disconnects the callback from the message
   */
  public on<K extends keyof MessageData>(
    message: K & BaseMessage,
    callback: MessageCallback<MessageData[K]>
  ): Cleanup {
    const isClient = this.context === "client";
    if (RunService.IsClient() && !isClient)
      return error(Error.NoServerListen);
    else if (RunService.IsServer() && isClient)
      return error(Error.NoClientListen);

    const callbacksMap = isClient ? this.master.clientCallbacks : this.master.serverCallbacks;
    if (!callbacksMap.has(message))
      callbacksMap.set(message, new Set);

    const callbacks: Set<MessageCallback> = callbacksMap.get(message)!;
    callbacks.add(callback as MessageCallback);
    callbacksMap.set(message, callbacks);
    return () => callbacks.delete(callback as MessageCallback);
  }

  /**
   * Disconnects the callback as soon as it is called for the first time
   *
   * @returns A destructor function that disconnects the callback from the message
   */
  public once<K extends keyof MessageData>(
    message: K & BaseMessage,
    callback: MessageCallback<MessageData[K]>
  ): Cleanup {
    const destructor = this.on(message, (player, data) => {
      destructor();
      (callback as MessageCallback)(player, data);
    });

    return destructor;
  }
}


import { ReplicatedStorage, RunService } from "@rbxts/services";

import { getAllPacketsWhich, isReliable, isUnreliable, shouldBatch } from "\./utility";
import type { BaseMessage, MessageEvent, PacketInfo, SerializedPacket } from "./structs";
import type { MessageEmitter } from "./emitters/message-emitter";

export type ServerQueuedMessageData<MessageData> = [keyof MessageData & BaseMessage, MessageData[keyof MessageData], boolean];
export type ClientQueuedMessageData<MessageData> = [Player | Player[], ...ServerQueuedMessageData<MessageData>];
export type QueuedMessageData<MessageData> = ClientQueuedMessageData<MessageData> | ServerQueuedMessageData<MessageData>;

declare let setLuneContext: (ctx: "server" | "client" | "both") => void;
setLuneContext ??= () => { };

let sendMessage: RemoteEvent<MessageEvent>;
{
  const name = "sendMessage";
  const existing = ReplicatedStorage.FindFirstChild(name);
  const remote = (existing ?? new Instance("RemoteEvent", ReplicatedStorage)) as RemoteEvent<MessageEvent>;
  if (existing === undefined)
    remote.Name = name;

  sendMessage = remote;
}
let sendUnreliableMessage: UnreliableRemoteEvent<MessageEvent>;
{
  const name = "sendUnreliableMessage";
  const existing = ReplicatedStorage.FindFirstChild(name);
  const remote = (existing ?? new Instance("UnreliableRemoteEvent", ReplicatedStorage)) as UnreliableRemoteEvent<MessageEvent>;
  if (existing === undefined)
    remote.Name = name;

  sendUnreliableMessage = remote;
}

export class Relayer<MessageData> {
  private serverQueue: ServerQueuedMessageData<MessageData>[] = [];
  private clientBroadcastQueue: ServerQueuedMessageData<MessageData>[] = [];
  private clientQueue: ClientQueuedMessageData<MessageData>[] = [];

  public constructor(
    private readonly emitter: MessageEmitter<MessageData>
  ) {
    setLuneContext("client");
    if (RunService.IsClient()) {
      this.emitter.trash.add(sendMessage.OnClientEvent.Connect(
        (...serializedPacket) => this.emitter.onRemoteFire(false, serializedPacket))
      );
      this.emitter.trash.add(sendUnreliableMessage.OnClientEvent.Connect(
        (...serializedPacket) => this.emitter.onRemoteFire(false, serializedPacket))
      );
    }

    setLuneContext("server");
    if (RunService.IsServer()) {
      this.emitter.trash.add(sendMessage.OnServerEvent.Connect(
        (player, ...serializedPacket) => this.emitter.onRemoteFire(true, serializedPacket as never, player))
      );
      this.emitter.trash.add(sendUnreliableMessage.OnServerEvent.Connect(
        (player, ...serializedPacket) => this.emitter.onRemoteFire(true, serializedPacket as never, player))
      );
    }

    let elapsed = 0;
    const { batchRemotes, batchRate } = this.emitter.options;
    if (!batchRemotes)
      return this;

    this.emitter.trash.add(RunService.Heartbeat.Connect(dt => {
      elapsed += dt;
      if (elapsed < batchRate) return;

      elapsed -= batchRate;
      this.relayAll();
    }));
  }

  public queueMessage<K extends keyof MessageData>(
    context: "client" | "server" | true,
    message: K & BaseMessage,
    data: QueuedMessageData<MessageData>
  ): void {
    const queue = context === "client"
      ? this.clientQueue
      : context === true
        ? this.clientBroadcastQueue
        : this.serverQueue;

    queue.push(data as never);
    if (!shouldBatch(message, this.emitter.options))
      this.relayAll();
  }

  /** Send all queued data across the network simultaneously */
  public relayAll(): void {
    if (RunService.IsClient())
      return this.relay(
        (...packets) => sendMessage.FireServer(...packets),
        (...packets) => sendUnreliableMessage.FireServer(...packets),
        this.serverQueue,
        () => this.serverQueue = []
      );

    this.relay(
      (...packets) => sendMessage.FireAllClients(...packets),
      (...packets) => sendUnreliableMessage.FireAllClients(...packets),
      this.clientBroadcastQueue,
      () => this.clientBroadcastQueue = []
    );

    const playerPacketInfos = new Map<Player, PacketInfo[]>;
    const addClientPacket = (player: Player, packetInfo: PacketInfo): void => {
      const packetInfos = playerPacketInfos.get(player) ?? [];
      packetInfos.push(packetInfo);
      playerPacketInfos.set(player, packetInfos);
    };

    for (const [player, message, data, unreliable] of this.clientQueue) {
      const packet = this.emitter.serdes.serializePacket(message, data);
      const info = { packet, unreliable };
      if (typeIs(player, "Instance"))
        addClientPacket(player, info);
      else
        for (const p of player)
          addClientPacket(p, info);
    }

    if (!this.clientQueue.isEmpty()) {
      for (const [player, packetInfos] of playerPacketInfos) {
        if (packetInfos.isEmpty()) continue;

        const unreliablePackets = getAllPacketsWhich(packetInfos, isUnreliable);
        const packets = getAllPacketsWhich(packetInfos, isReliable);
        if (!unreliablePackets.isEmpty())
          sendUnreliableMessage.FireClient(player, ...unreliablePackets);
        if (!packets.isEmpty())
          sendMessage.FireClient(player, ...packets);
      }

      this.clientQueue = [];
    }
  }

  private relay(send: MessageEvent, sendUnreliable: MessageEvent, queue: QueuedMessageData<MessageData>[], clearQueue: () => void): void {
    if (queue.isEmpty()) return;

    const packetInfos = queue.map<PacketInfo>(messageData => {
      let message: keyof MessageData & BaseMessage,
        data: MessageData[keyof MessageData],
        unreliable: boolean;

      if (typeIs(messageData[0], "Instance"))
        [, message, data, unreliable] = messageData as ClientQueuedMessageData<MessageData>;
      else
        [message, data, unreliable] = messageData as ServerQueuedMessageData<MessageData>;

      const packet = this.emitter.serdes.serializePacket(message, data);
      return { packet, unreliable };
    });

    const unreliablePackets = getAllPacketsWhich(packetInfos, isUnreliable);
    const packets = getAllPacketsWhich(packetInfos, isReliable);
    if (!unreliablePackets.isEmpty())
      sendUnreliable(...unreliablePackets);
    if (!packets.isEmpty())
      send(...packets);

    clearQueue();
  }
}


import type { Modding } from "@flamework/core";
import type { Serializer, SerializerMetadata } from "@rbxts/serio";
import createSerializer from "@rbxts/serio";

import { createMessageBuffer } from "./utility";
import type { BaseMessage, SerializedPacket } from "./structs";

export class Serdes<MessageData> {
  public serializers: Partial<Record<keyof MessageData, Serializer<MessageData[keyof MessageData]>>> = {};

  /** @metadata macro */
  public getSchema<Kind extends keyof MessageData>(message: Kind & BaseMessage, meta?: Modding.Many<SerializerMetadata<MessageData[Kind]>>): SerializerMetadata<MessageData[Kind]> {
    const serializer = this.getSerializer(message);
    if (serializer === undefined)
      throw `[@rbxts/tether]: Cannot get schema metadata for message '${message}', serializer not found`;

    return serializer.getSchema(meta);
  }

  public serializePacket<Kind extends keyof MessageData>(message: Kind & BaseMessage, data?: MessageData[Kind]): SerializedPacket {
    const serializer = this.getSerializer(message);
    const messageBuf = createMessageBuffer(message);
    if (serializer === undefined)
      return {
        messageBuf,
        buf: buffer.create(0),
        blobs: []
      };

    return { messageBuf, ...serializer.serialize(data as never) };
  }

  public deserializePacket<K extends keyof MessageData>(message: K & BaseMessage, serializedPacket: SerializedPacket): MessageData[K] | undefined {
    const serializer = this.getSerializer(message);
    return serializer?.deserialize(serializedPacket);
  }

  /** @metadata macro */
  public addSerializer<K extends keyof MessageData>(message: K & BaseMessage, meta?: Modding.Many<SerializerMetadata<MessageData[K]>>): void {
    this.serializers[message] = this.createMessageSerializer(meta) as never;
  }

  /** @metadata macro */
  public createMessageSerializer<Kind extends keyof MessageData>(meta?: Modding.Many<SerializerMetadata<MessageData[Kind]>>): Serializer<MessageData[Kind]> {
    return createSerializer<MessageData[Kind]>(meta);
  }

  public getSerializer<Kind extends keyof MessageData>(message: Kind & BaseMessage): Serializer<MessageData[Kind]> | undefined {
    return this.serializers[message] as never;
  }
}


import type { Modding } from "@flamework/core";
import type { SerializerMetadata, SerializedData, StripMeta } from "@rbxts/serio";

export type MessageCallback<T = unknown> = ServerMessageCallback<T> | ClientMessageCallback<T>;
export type FunctionMessageCallback<T = unknown, R = unknown> = ServerFunctionMessageCallback<T, R> | ClientFunctionMessageCallback<T, R>;
export type ClientMessageCallback<T = unknown> = (data: T) => void;
export type ClientFunctionMessageCallback<T = unknown, R = unknown> = (data: T) => R;
export type ServerMessageCallback<T = unknown> = (player: Player, data: T) => void;
export type ServerFunctionMessageCallback<T = unknown, R = unknown> = (player: Player, data: T) => R;
export type BaseMessage = number;

export interface PacketInfo {
  readonly packet: SerializedPacket;
  readonly unreliable: boolean;
}

export interface SerializedPacket extends SerializedData {
  readonly messageBuf: buffer;
}

export type MessageEvent = (...packets: SerializedPacket[]) => void;
export type Guard<T = unknown> = (value: unknown) => value is T;

export interface MessageMetadata<T> {
  readonly guard: Modding.Generic<StripMeta<T>, "guard">;
  readonly serializerMetadata: T extends undefined ? undefined : SerializerMetadata<T>;
}

export type MessageEmitterMetadata<MessageData> = {
  readonly [K in keyof MessageData as K]: MessageMetadata<MessageData[K]>;
};


import { TestRunner } from "@rbxts/runit";

const testRunner = new TestRunner(game.GetService("ReplicatedStorage").WaitForChild("Tests"));
testRunner.run({ colors: true }).await();


import Signal from "@rbxts/lemon-signal";

import { BaseAction } from "./base-action";
import { getRawInput, type RawInput } from "./common";

export abstract class AxisAction extends BaseAction {
  public readonly updated = this.trash.add(new Signal);
  public readonly axes: RawInput[];
  public readonly delta = new Vector3;
  public readonly position = new Vector3;

  public constructor(...axes: RawInput[]) {
    super();
    this.axes = axes;
  }

  /** @hidden */
  public handleInput(this: Writable<AxisAction>, input: InputObject, processed: boolean): void {
    if (this.processed !== processed) return;
    if (!this.axes.includes(getRawInput(input))) return;

    this.position = input.Position;
    this.delta = input.Delta;
    this.updated.Fire();
  }
}

export class AxisActionBuilder extends AxisAction {
  /** Sets the ID of the action */
  public setID(this: Writable<AxisActionBuilder>, id: string | number): AxisActionBuilder {
    this.id = id;
    return <AxisActionBuilder>this;
  }

  /** Sets whether the action is activated when gameProcessedEvent is true */
  public setProcessed(this: Writable<AxisActionBuilder>, processed: boolean): AxisActionBuilder {
    this.processed = processed;
    return <AxisActionBuilder>this;
  }
}


import { BaseID } from "@rbxts/id";
import Destroyable from "@rbxts/destroyable";

export abstract class BaseAction extends Destroyable implements BaseID<string | number> {
  public readonly id: string | number = "default";
  public readonly processed: boolean = false;
  public readonly callbacks: Callback[] = [];

  /** @hidden */
  public abstract handleInput(input: InputObject, processed: boolean): void;
}


import { UserInputService as InputService } from "@rbxts/services";
import Destroyable from "@rbxts/destroyable";

import { BaseAction } from "./base-action";
import { AxisAction } from "./axis-action";
import { gamepadInputs } from "./common";

type Maybe<T> = T | undefined;
type AbstractConstructor<T = object> = abstract new (...args: never[]) => T;

export class InputManager extends Destroyable {
  private readonly registeredActions = new Set<BaseAction>;

  public constructor(
    private readonly useAllGamepads = false
  ) {
    super();
    this.trash.add(() => {
      for (const action of this.registeredActions)
        this.unbind(action);
    });

    this.trash.add(InputService.InputBegan.Connect((input, processed) => this.handleInput(input, processed)));
    this.trash.add(InputService.InputEnded.Connect((input, processed) => this.handleInput(input, processed)));
    this.trash.add(InputService.InputChanged.Connect((input, processed) => this.handleInput(input, processed, true)));
  }

  public getActionByID<T extends BaseAction = BaseAction>(actionID: string | number, actionType?: AbstractConstructor<T>): Maybe<T> {
    return [...this.registeredActions]
      .filter((action): action is T => actionType !== undefined ? action instanceof actionType : true)
      .find(action => action.id === actionID);
  }

  public bind(action: BaseAction): InputManager {
    this.registeredActions.add(action);
    return this;
  }

  public unbind(actionID: string | number): InputManager
  public unbind(action: BaseAction): InputManager
  public unbind(actionSpecifier: string | number | BaseAction): InputManager {
    if (actionSpecifier instanceof BaseAction) {
      this.registeredActions.delete(actionSpecifier);
      return this;
    }

    const action = [...this.registeredActions].find(action => action.id === actionSpecifier);
    if (action === undefined)
      return this;

    this.registeredActions.delete(action);
    return this;
  }

  private handleInput(input: InputObject, processed: boolean, inputChanged = false): void {
    const inputType = input.UserInputType;
    if (this.isUsingUnknownGamepad(inputType)) return;

    for (const action of this.registeredActions) {
      if (inputChanged && !(action instanceof AxisAction)) continue;
      action.handleInput(input, processed);
    }
  }

  private isUsingUnknownGamepad(inputType: Enum.UserInputType): boolean {
    return gamepadInputs.includes(inputType) &&
      inputType !== Enum.UserInputType.Gamepad1 &&
      !this.useAllGamepads;
  }
}


import Signal from "@rbxts/lemon-signal";

import { BaseAction } from "./base-action";
import { getInputEnum, type RawInput } from "./common";

export abstract class BaseStandardAction extends BaseAction {
  public readonly activated = this.trash.add(new Signal);
  public readonly deactivated = this.trash.add(new Signal);
  public readonly isActive: boolean = false;

  protected activate(this: Writable<BaseStandardAction>): void {
    this.isActive = true;
    this.activated.Fire();
  }

  protected deactivate(this: Writable<BaseStandardAction>): void {
    this.isActive = false;
    this.deactivated.Fire();
  }
}

export abstract class StandardAction extends BaseStandardAction {
  public readonly rawInputs: RawInput[];
  public readonly inputQueueing: boolean = false;
  public readonly cooldown: number = 0;

  private lastPress = 0;
  private lastRelease = 0;
  private queuedPresses = 0;
  private queuedReleases = 0;

  public constructor(...rawInputs: RawInput[]) {
    super();
    this.rawInputs = rawInputs;
  }

  /** @hidden */
  public handleInput(input: InputObject, processed: boolean, isPress = input.UserInputState === Enum.UserInputState.Begin): void {
    if (this.processed !== processed) return;
    if (!this.supportsInput(input)) return;

    const coolingDown = this.cooldown > 0 && os.clock() - this[isPress ? "lastPress" : "lastRelease"] < this.cooldown;
    if (coolingDown) {
      if (this.inputQueueing) {
        const delay = this.cooldown * ++this[isPress ? "queuedPresses" : "queuedReleases"];
        task.delay(delay, () => {
          this.handleInput(input, processed, isPress);
          this[isPress ? "queuedPresses" : "queuedReleases"]--;
        });
      }

      return;
    }

    if (isPress)
      this.lastRelease = 0;

    this[isPress ? "lastPress" : "lastRelease"] = os.clock();
    isPress ? this.activate() : this.deactivate();
  }

  protected supportsInput(input: InputObject): boolean {
    return this.rawInputs.some(rawInput => this.rawInputMatches(rawInput, input));
  }

  protected rawInputMatches(rawInput: RawInput, input: InputObject): boolean {
    const inputEnum = getInputEnum(rawInput);
    return input.KeyCode === inputEnum || input.UserInputType === inputEnum;
  }
}

export class StandardActionBuilder extends StandardAction {
  /** Sets the ID of the action */
  public setID(this: Writable<StandardActionBuilder>, id: string | number): StandardActionBuilder {
    this.id = id;
    return <StandardActionBuilder>this;
  }

  /** Sets whether the action is activated when gameProcessedEvent is true */
  public setProcessed(this: Writable<StandardActionBuilder>, processed: boolean): StandardActionBuilder {
    this.processed = processed;
    return <StandardActionBuilder>this;
  }

  /** Sets whether inputs pressed while cooldown is active are queued to be activated when the cooldown is over */
  public setInputQueueing(this: Writable<StandardActionBuilder>, inputQueueing: boolean): StandardActionBuilder {
    this.inputQueueing = inputQueueing;
    return <StandardActionBuilder>this;
  }

  /** Sets a time to wait between each activation of the action */
  public setCooldown(this: Writable<StandardActionBuilder>, cooldown: number): StandardActionBuilder {
    this.cooldown = cooldown;
    return <StandardActionBuilder>this;
  }
}