import { useEffect } from "@rbxts/react";
import { useLatest } from "../use-latest";

interface EventListenerOptions {
	/**
	 * Whether the event should be connected or not. Defaults to `true`.
	 */
	connected?: boolean;
	/**
	 * Whether the event should be disconnected after the first invocation.
	 * Defaults to `false`.
	 */
	once?: boolean;
}

type EventLike<T extends Callback = Callback> =
	| { Connect(callback: T): ConnectionLike }
	| { connect(callback: T): ConnectionLike }
	| { subscribe(callback: T): ConnectionLike };

type ConnectionLike = { Disconnect(): void } | { disconnect(): void } | (() => void);

const connect = (event: EventLike, callback: Callback): ConnectionLike => {
	if (typeIs(event, "RBXScriptSignal")) {
		// With deferred events, a "hard disconnect" is necessary to avoid causing
		// state updates after a component unmounts. Use 'Connected' to check if
		// the connection is still valid before invoking the callback.
		// https://devforum.roblox.com/t/deferred-engine-events/2276564/99
		const connection = event.Connect((...args: unknown[]) => {
			if (connection.Connected) {
				return callback(...args);
			}
		});
		return connection;
	} else if ("Connect" in event) {
		return event.Connect(callback);
	} else if ("connect" in event) {
		return event.connect(callback);
	} else if ("subscribe" in event) {
		return event.subscribe(callback);
	} else {
		throw "Event-like object does not have a supported connect method.";
	}
};

const disconnect = (connection: ConnectionLike) => {
	if (typeIs(connection, "function")) {
		connection();
	} else if (typeIs(connection, "RBXScriptConnection") || "Disconnect" in connection) {
		connection.Disconnect();
	} else if ("disconnect" in connection) {
		connection.disconnect();
	} else {
		throw "Connection-like object does not have a supported disconnect method.";
	}
};

/**
 * Subscribes to an event-like object. The subscription is automatically
 * disconnected when the component unmounts.
 *
 * If the event or listener is `undefined`, the event will not be subscribed to,
 * and the subscription will be disconnected if it was previously connected.
 *
 * The listener is memoized, so it is safe to pass a callback that is recreated
 * on every render.
 *
 * @param event The event-like object to subscribe to.
 * @param listener The listener to subscribe with.
 * @param options Options for the subscription.
 */
export function useEventListener<T extends EventLike>(
	event?: T,
	listener?: T extends EventLike<infer U> ? U : never,
	options: EventListenerOptions = {},
) {
	const { once = false, connected = true } = options;

	const listenerRef = useLatest(listener);

	useEffect(() => {
		if (!event || !listener || !connected) {
			return;
		}

		let canDisconnect = true;

		const connection = connect(event, (...args: unknown[]) => {
			if (once) {
				disconnect(connection);
				canDisconnect = false;
			}
			listenerRef.current?.(...args);
		});

		return () => {
			if (canDisconnect) {
				disconnect(connection);
			}
		};
	}, [event, connected, listener !== undefined]);
}


import { useCallback, useEffect, useRef } from "@rbxts/react";
import { setInterval } from "@rbxts/set-timeout";
import { useLatestCallback } from "../use-latest-callback";

export interface UseIntervalOptions {
	/**
	 * Whether the callback should run immediately when the interval is set.
	 * Defaults to `false`.
	 */
	immediate?: boolean;
}

/**
 * Sets an interval that runs the callback function every `delay` seconds. If
 * `delay` is `undefined`, the interval is cleared. If the delay changes, the
 * interval is reset.
 * @param callback The callback function to run.
 * @param delay The delay in seconds between each interval.
 * @param options The options for the interval.
 * @returns A function that clears the interval.
 */
export function useInterval(callback: () => void, delay?: number, options: UseIntervalOptions = {}) {
	const { immediate = false } = options;

	const callbackMemo = useLatestCallback(callback);
	const cancel = useRef<() => void>();

	const clear = useCallback(() => {
		cancel.current?.();
	}, []);

	useEffect(() => {
		if (delay === undefined) {
			return;
		}
		if (immediate) {
			callbackMemo();
		}
		cancel.current = setInterval(callbackMemo, delay);
		return clear;
	}, [delay]);

	return clear;
}


import { InferEnumNames, useEffect, useMemo, useState } from "@rbxts/react";
import { ContextActionService, HttpService, UserInputService } from "@rbxts/services";
import { useEventListener } from "../use-event-listener";

/**
 * A single key code name.
 */
export type KeyCode = InferEnumNames<Enum.KeyCode>;

/**
 * A single key code or a combination of key codes.
 */
export type KeyCodes = KeyCode | `${KeyCode}+${string}` | KeyCode[];

export interface KeyPressOptions {
	/**
	 * Whether to bind a ContextActionService action to the key press. If `true`,
	 * the action will be bound with the lifecycle of the component. The action will
	 * sink the input, so the game will not process it.
	 */
	bindAction?: boolean;
	/**
	 * The action priority to use when binding the action. Defaults to
	 * `Enum.ContextActionPriority.High.Value`.
	 */
	actionPriority?: number;
	/**
	 * The action name to use when binding the action. Defaults to a random name.
	 */
	actionName?: string;
	/**
	 * The input types and key codes to listen for. Defaults to `Enum.UserInputType.Keyboard`
	 * and `Enum.UserInputType.Gamepad1`.
	 */
	actionInputTypes?: (Enum.UserInputType | Enum.KeyCode)[];
}

/**
 * Returns whether the passed key or shortcut is pressed. The hook expects one
 * or more key code, which can be:
 *
 * - A single key code `"W"`
 * - A combination of key codes `"W+Space"`
 * - An array of key codes `["W", "Space"]`
 *
 * Each combination is treated as its own shortcut. If passed more than one
 * combination, the hook will return `true` if any of the combinations are
 * pressed.
 *
 * @param keyCodeCombos The key code or combination of key codes to listen for.
 * @returns Whether the key or combination of keys is pressed.
 */
export function useKeyPress(
	keyCodeCombos: KeyCodes[],
	{
		bindAction = false,
		actionPriority = Enum.ContextActionPriority.High.Value,
		actionName = bindAction ? HttpService.GenerateGUID(false) : "",
		actionInputTypes = [Enum.UserInputType.Keyboard, Enum.UserInputType.Gamepad1],
	}: KeyPressOptions = {},
) {
	const [pressed, setPressed] = useState(false);

	const keyCombos = useMemo(() => {
		return keyCodeCombos.map((keyCodes): KeyCode[] => {
			if (typeIs(keyCodes, "string")) {
				return keyCodes.split("+") as KeyCode[];
			} else {
				return keyCodes;
			}
		});
	}, keyCodeCombos);

	const keySet = useMemo(() => {
		const keySet = new Set<KeyCode>();

		for (const keys of keyCombos) {
			for (const key of keys) {
				keySet.add(key);
			}
		}

		return keySet;
	}, keyCombos);

	const keysDown = useMemo(() => {
		return new Set<KeyCode>();
	}, keyCodeCombos);

	const updatePressed = () => {
		setPressed(keyCombos.some((keys) => keys.every((key) => keysDown.has(key))));
	};

	useEventListener(UserInputService.InputBegan, (input, gameProcessed) => {
		if (!gameProcessed && keySet.has(input.KeyCode.Name)) {
			keysDown.add(input.KeyCode.Name);
			updatePressed();
		}
	});

	useEventListener(UserInputService.InputEnded, (input) => {
		if (keySet.has(input.KeyCode.Name)) {
			keysDown.delete(input.KeyCode.Name);
			updatePressed();
		}
	});

	useEffect(() => {
		// Prevents the game from processing the key
		if (!bindAction) {
			return;
		}

		ContextActionService.BindActionAtPriority(
			actionName,
			(_, state, input) => {
				const valid = keySet.has(input.KeyCode.Name);

				if (!valid) {
					return Enum.ContextActionResult.Pass;
				}

				if (state === Enum.UserInputState.Begin) {
					keysDown.add(input.KeyCode.Name);
				} else if (state === Enum.UserInputState.End) {
					keysDown.delete(input.KeyCode.Name);
				}

				updatePressed();

				return Enum.ContextActionResult.Sink;
			},
			false,
			actionPriority,
			...actionInputTypes,
		);

		return () => {
			ContextActionService.UnbindAction(actionName);
		};
	}, [bindAction, actionName, actionPriority]);

	return pressed;
}


import { useCallback, useRef } from "@rbxts/react";

/**
 * Returns a memoized callback that wraps the latest version of the input
 * callback.
 * @param callback The callback to memoize.
 * @returns The memoized callback.
 */
export function useLatestCallback<T extends Callback>(callback: T): T {
	const callbackRef = useRef(callback);
	callbackRef.current = callback;

	return useCallback((...args: unknown[]) => {
		return callbackRef.current(...args);
	}, []) as T;
}


import { useMemo, useRef } from "@rbxts/react";
import { Predicate, isStrictEqual } from "../use-previous";

/**
 * Returns a mutable ref that points to the latest value of the input.
 *
 * Takes an optional `predicate` function as the second argument that receives
 * the previous and current value. If the predicate returns `false`, the values
 * are not equal, and the previous value is updated.
 *
 * @param value The value to track.
 * @returns A mutable reference to the value.
 */
export function useLatest<T>(value: T, predicate: Predicate<T> = isStrictEqual) {
	const ref = useRef(value);

	useMemo(() => {
		if (!predicate(ref.current, value)) {
			ref.current = value;
		}
	}, [value]);

	return ref;
}


import { useBinding, useEffect } from "@rbxts/react";
import { RunService } from "@rbxts/services";
import { useEventListener } from "../use-event-listener";

/**
 * Returns the lifetime of the component in seconds. Updates every frame on
 * the Heartbeat event.
 *
 * If the dependency array is provided, the lifetime timer will reset when
 * any of the dependencies change.
 *
 * @param dependencies An optional array of dependencies to reset the timer.
 * @returns A binding of the component's lifetime.
 */
export function useLifetime(dependencies: unknown[] = []) {
	const [lifetime, setLifetime] = useBinding(0);

	useEventListener(RunService.Heartbeat, (deltaTime) => {
		setLifetime(lifetime.getValue() + deltaTime);
	});

	useEffect(() => {
		setLifetime(0);
	}, dependencies);

	return lifetime;
}


import type { Binding } from "@rbxts/react";
import { useBinding, useEffect, useMemo } from "@rbxts/react";
import type { Motion, MotionGoal } from "@rbxts/ripple";
import { createMotion } from "@rbxts/ripple";
import { RunService } from "@rbxts/services";

const callbacks = new Set<(dt: number) => void>();
let connection: RBXScriptConnection | undefined;

function connect(callback: (dt: number) => void) {
	callbacks.add(callback);

	if (!connection) {
		connection = RunService.Heartbeat.Connect((dt) => {
			for (const callback of callbacks) {
				callback(dt);
			}
		});
	}
}

function disconnect(callback: (dt: number) => void) {
	callbacks.delete(callback);

	if (callbacks.isEmpty()) {
		connection?.Disconnect();
		connection = undefined;
	}
}

export function useMotion(initialValue: number): LuaTuple<[Binding<number>, Motion]>;
export function useMotion<T extends MotionGoal>(initialValue: T): LuaTuple<[Binding<T>, Motion<T>]>;
export function useMotion<T extends MotionGoal>(initialValue: T) {
	const motion = useMemo(() => {
		return createMotion(initialValue);
	}, []);

	const [binding, setValue] = useBinding(initialValue);

	useEffect(() => {
		const callback = (delta: number) => {
			const value = motion.step(delta);

			if (value !== binding.getValue()) {
				setValue(value);
			}
		};

		connect(callback);

		return () => disconnect(callback);
	}, []);

	return $tuple(binding, motion);
}


import { EffectCallback, useEffect } from "@rbxts/react";

/**
 * Runs a callback when the component is mounted.
 * @param callback The callback to run.
 */
export function useMountEffect(callback: EffectCallback) {
	useEffect(callback, []);
}


import { Binding, useBinding, useMemo } from "@rbxts/react";
import { UserInputService } from "@rbxts/services";
import { useEventListener } from "../use-event-listener";
import { useMountEffect } from "../use-mount-effect";

/**
 * Returns a binding to the mouse position.
 * @param listener Optional listener to be called when the mouse position changes.
 * @returns A binding to mouse position.
 */
export function useMouse(listener?: (mouse: Vector2) => void): Binding<Vector2> {
	const [mouse, setMouse] = useBinding(Vector2.one);

	useEventListener(UserInputService.InputChanged, (input) => {
		if (
			input.UserInputType === Enum.UserInputType.MouseMovement ||
			input.UserInputType === Enum.UserInputType.Touch
		) {
			setMouse(UserInputService.GetMouseLocation());
			listener?.(UserInputService.GetMouseLocation());
		}
	});

	useMemo(() => {
		setMouse(UserInputService.GetMouseLocation());
	}, []);

	useMountEffect(() => {
		listener?.(mouse.getValue());
	});

	return mouse;
}


import { useMemo, useRef } from "@rbxts/react";

export type Predicate<T> = (previous: T | undefined, current: T) => boolean;

export const isStrictEqual = (a: unknown, b: unknown) => a === b;

/**
 * Returns the most recent value from the previous render. Returns `undefined`
 * on the first render.
 *
 * Takes an optional `predicate` function as the second argument that receives
 * the previous and current value. If the predicate returns `false`, the values
 * are not equal, and the previous value is updated.
 *
 * @param value The value to return on the next render if it changes.
 * @param predicate Optional function to determine whether the value changed.
 * Defaults to a strict equality check (`===`).
 * @returns The previous value.
 */
export function usePrevious<T>(value: T, predicate: Predicate<T> = isStrictEqual): T | undefined {
	const previousRef = useRef<T>();
	const currentRef = useRef<T>();

	useMemo(() => {
		if (!predicate(currentRef.current, value)) {
			previousRef.current = currentRef.current;
			currentRef.current = value;
		}
	}, [value]);

	return previousRef.current;
}


import { useState } from "@rbxts/react";
import { useEventListener } from "../use-event-listener";
import { CollectionService } from "@rbxts/services";

/**
 * Wrapper around `CollectionService` that provides a list of `Instance` under the given `tag`.
 *
 * This list is updated as `Instance` are added and removed. You can also cast the `Instance` to the expected type.
 *
 * ```ts
 * const zombies = useTagged<ZombieModel>("zombie");
 * ```
 *
 * @param tag The `CollectionService` tag name to filter against.
 * @returns A stateful list of `Instance` matching the provided `tag`.
 * @template T An optional subtype of `Instance` to cast the tagged children to.
 */
export function useTagged<T extends Instance = Instance>(tag: string): readonly T[] {
	const [instances, setInstances] = useState(() => CollectionService.GetTagged(tag));

	useEventListener(CollectionService.GetInstanceAddedSignal(tag), (instance) => {
		setInstances((instances) => {
			const nextInstances = table.clone(instances);
			nextInstances.push(instance);
			return nextInstances;
		});
	});

	useEventListener(CollectionService.GetInstanceRemovedSignal(tag), (instance) => {
		setInstances((instances) => {
			const nextInstances = table.clone(instances);
			const index = nextInstances.indexOf(instance);
			nextInstances.remove(index);
			return nextInstances;
		});
	});

	return instances as T[];
}


import { useMemo } from "@rbxts/react";
import { Debounced, ThrottleOptions, throttle } from "@rbxts/set-timeout";
import { UseDebounceResult } from "../use-debounce-callback";
import { useLatest } from "../use-latest";
import { useUnmountEffect } from "../use-unmount-effect";

export interface UseThrottleOptions extends ThrottleOptions {
	/**
	 * The amount of time to wait before the first call.
	 */
	wait?: number;
}

/**
 * Creates a throttled function that only invokes `callback` at most once per
 * every `wait` seconds. The `callback` is invoked with the most recent arguments
 * provided to the throttled function. Subsequent calls to the throttled function
 * return the result of the last `callback` invocation.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `throttle` and `debounce`.
 *
 * @param callback The function to throttle.
 * @param options The options object.
 * @returns The new throttled function.
 */
export function useThrottleCallback<T extends Callback>(
	callback: T,
	options: UseThrottleOptions = {},
): UseDebounceResult<T> {
	const callbackRef = useLatest(callback);

	const throttled = useMemo(() => {
		return throttle(
			(...args: unknown[]) => {
				return callbackRef.current(...args);
			},
			options.wait,
			options,
		);
	}, []) as Debounced<T>;

	useUnmountEffect(() => {
		throttled.cancel();
	});

	return {
		run: throttled,
		cancel: throttled.cancel,
		flush: throttled.flush,
		pending: throttled.pending,
	};
}


import { useEffect } from "@rbxts/react";
import { UseThrottleOptions, useThrottleCallback } from "../use-throttle-callback";
import { useUpdate } from "../use-update";
import { useUpdateEffect } from "../use-update-effect";

/**
 * Creates a throttled effect that only runs at most once per every `wait`
 * seconds.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @param effect The effect to throttle.
 * @param dependencies The dependencies array.
 * @param options The options object.
 */
export function useThrottleEffect(
	effect: () => (() => void) | void,
	dependencies?: unknown[],
	options?: UseThrottleOptions,
) {
	const update = useUpdate();

	const { run } = useThrottleCallback(update, options);

	useEffect(() => {
		return run();
	}, dependencies);

	useUpdateEffect(effect, [update]);
}


import { Dispatch, SetStateAction, useState } from "@rbxts/react";
import { UseThrottleOptions, useThrottleCallback } from "../use-throttle-callback";

/**
 * Creates a throttled state that only updates at most once per every `wait`
 * seconds. Set to the most recently passed `state` after each interval.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @param value The value to throttle.
 * @param options The options object.
 * @returns The throttled value.
 */
export function useThrottleState<T>(
	initialState: T,
	options?: UseThrottleOptions,
): LuaTuple<[T, Dispatch<SetStateAction<T>>]> {
	const [state, setState] = useState(initialState);

	return $tuple(state, useThrottleCallback(setState, options).run);
}


import { useCallback, useEffect, useRef } from "@rbxts/react";
import { setTimeout } from "@rbxts/set-timeout";
import { useLatestCallback } from "../use-latest-callback";

/**
 * Sets a timeout that runs the callback function after `delay` seconds. If
 * `delay` is `undefined`, the timeout is cleared. If the delay changes, the
 * timeout is reset.
 * @param callback The callback function to run.
 * @param delay The delay in seconds before the timeout.
 * @returns A function that clears the timeout.
 */
export function useTimeout(callback: () => void, delay?: number) {
	const callbackMemo = useLatestCallback(callback);
	const cancel = useRef<() => void>();

	const clear = useCallback(() => {
		cancel.current?.();
	}, []);

	useEffect(() => {
		if (delay === undefined) {
			return;
		}
		cancel.current = setTimeout(callbackMemo, delay);
		return clear;
	}, [delay]);

	return clear;
}


import { Binding, useBinding, useCallback, useRef } from "@rbxts/react";
import { RunService } from "@rbxts/services";
import { useEventListener } from "../use-event-listener";

export interface Timer {
	/**
	 * A binding that represents the current value of the timer.
	 */
	readonly value: Binding<number>;
	/**
	 * Starts the timer if it is not already running.
	 */
	readonly start: () => void;
	/**
	 * Pauses the timer if it is running.
	 */
	readonly stop: () => void;
	/**
	 * Resets the timer to 0.
	 */
	readonly reset: () => void;
	/**
	 * Sets the timer to a specific value.
	 * @param value The value to set the timer to.
	 */
	readonly set: (value: number) => void;
}

/**
 * Creates a timer that can be used to track a value over time.
 * @param initialValue The initial value of the timer.
 * @returns A timer object.
 */
export function useTimer(initialValue = 0): Timer {
	const [value, setValue] = useBinding(initialValue);

	const started = useRef(true);

	useEventListener(RunService.Heartbeat, (deltaTime) => {
		if (started.current) {
			setValue(value.getValue() + deltaTime);
		}
	});

	const start = useCallback(() => {
		started.current = true;
	}, []);

	const stop = useCallback(() => {
		started.current = false;
	}, []);

	const reset = useCallback(() => {
		setValue(0);
	}, []);

	const set = useCallback((value: number) => {
		setValue(value);
	}, []);

	return { value, start, stop, reset, set };
}


import { useEffect } from "@rbxts/react";
import { useLatest } from "../use-latest";

/**
 * Calls the callback when the component unmounts.
 * @param callback The callback to call.
 */
export function useUnmountEffect(callback: () => void) {
	const callbackRef = useLatest(callback);

	useEffect(() => {
		return () => {
			callbackRef.current();
		};
	}, []);
}


import { useEffect, useRef } from "@rbxts/react";

/**
 * Runs a callback when the component is re-rendered. Does not run on the
 * first render.
 * @param effect The callback to run.
 * @param dependencies The dependencies to watch for changes.
 */
export function useUpdateEffect(effect: () => (() => void) | void, dependencies?: unknown[]) {
	const isMounted = useRef(false);

	useEffect(() => {
		if (isMounted.current) {
			return effect();
		} else {
			isMounted.current = true;
		}
	}, dependencies);
}


import { useCallback, useState } from "@rbxts/react";

/**
 * Returns a function that can be used to force a component to update. The
 * function is recreated on the next render if called. This makes it useful as
 * a dependency for other hooks.
 * @returns A function that forces a rerender.
 */
export function useUpdate() {
	const [state, setState] = useState({});

	return useCallback(() => {
		setState({});
	}, [state]);
}


import { useBinding, useEffect } from "@rbxts/react";
import { useCamera } from "../use-camera";

/**
 * Returns the current viewport size of the camera.
 * @param listener Optional listener to be called when the viewport changes.
 * @returns A binding to the viewport size.
 */
export function useViewport(listener?: (viewport: Vector2) => void) {
	const camera = useCamera();
	const [viewport, setViewport] = useBinding(camera.ViewportSize);

	useEffect(() => {
		const connection = camera.GetPropertyChangedSignal("ViewportSize").Connect(() => {
			setViewport(camera.ViewportSize);
			listener?.(camera.ViewportSize);
		});

		setViewport(camera.ViewportSize);
		listener?.(camera.ViewportSize);

		return () => {
			connection.Disconnect();
		};
	}, [camera]);

	return viewport;
}


import { Binding, createBinding, joinBindings } from "@rbxts/react";
import { lerp } from "./math";

export interface BindingApi<T> {
	subscribe: (callback: (newValue: T) => void) => () => void;
	update: (newValue: T) => void;
	getValue: () => T;
}

export interface Lerpable<T> {
	Lerp: (this: T, to: T, alpha: number) => T;
}

export type BindingOrValue<T> = Binding<T> | T;

type Bindable<T = unknown> = Binding<T> | NonNullable<T>;

type ComposeBindings<T extends Bindable[]> = {
	[K in keyof T]: T[K] extends Bindable<infer U> ? U : T[K];
};

type BindingCombiner<T extends Bindable[], U> = (...values: ComposeBindings<T>) => U;

/**
 * Returns whether the given value is a binding.
 * @param value The value to check.
 * @returns Whether the value is a binding.
 */
export function isBinding<T>(value: T | Binding<T>): value is Binding<T>;
export function isBinding<T = unknown>(value: unknown): value is Binding<T>;
export function isBinding(value: unknown): value is Binding<unknown> {
	return typeIs(value, "table") && "getValue" in value && "map" in value;
}

/**
 * Converts a value to a binding. If the given value is already a binding, it
 * will be returned as-is.
 * @param value The value to convert.
 * @returns The converted binding.
 */
export function toBinding<T>(value: T | Binding<T>): Binding<T> {
	if (isBinding(value)) {
		return value;
	} else {
		const [result] = createBinding(value);
		return result;
	}
}

/**
 * Returns the value of a binding. If the given value is not a binding, it will
 * be returned as-is.
 * @param binding The binding to get the value of.
 * @returns The value of the binding.
 */
export function getBindingValue<T>(binding: T | Binding<T>): T {
	if (isBinding(binding)) {
		return binding.getValue();
	} else {
		return binding;
	}
}

/**
 * Maps a binding to a new binding. If the given value is not a binding, it will
 * be passed to the mapper function and returned as a new binding.
 * @param binding The binding to map.
 * @param callback The mapper function.
 * @returns The mapped binding.
 */
export function mapBinding<T, U>(binding: T | Binding<T>, callback: (value: T) => U): Binding<U> {
	if (isBinding(binding)) {
		return binding.map(callback);
	} else {
		const [result] = createBinding(callback(binding as T));
		return result;
	}
}

/**
 * Joins a map of bindings into a single binding. If any of the given values
 * are not bindings, they will be wrapped in a new binding.
 * @param bindings The bindings to join.
 * @returns The joined binding.
 */
export function joinAnyBindings<T extends Readonly<Record<string, unknown>>>(
	bindings: T,
): Binding<{ [K in keyof T]: T[K] extends BindingOrValue<infer U> ? U : T[K] }>;
export function joinAnyBindings<T extends readonly unknown[]>(
	bindings: readonly [...T],
): Binding<{ [K in keyof T]: T[K] extends BindingOrValue<infer U> ? U : T[K] }>;
export function joinAnyBindings(bindings: object): Binding<unknown> {
	const bindingsToMap = {} as Record<string | number, Binding<unknown>>;

	for (const [k, v] of pairs(bindings)) {
		bindingsToMap[k as keyof object] = toBinding(v);
	}

	return joinBindings(bindingsToMap);
}

/**
 * Gets the internal API of a binding. This is a hacky way to get access to the
 * `BindingInternalApi` object of a binding, which is not exposed by React.
 * @param binding The binding to get the internal API of.
 * @returns The binding's API.
 */
export function getBindingApi<T>(binding: Binding<T>) {
	for (const [key, value] of pairs(binding)) {
		const name = tostring(key);

		if (name === "Symbol(BindingImpl)" || name.sub(1, 12) === "RoactBinding") {
			return value as unknown as BindingApi<T>;
		}
	}
}

/**
 * Returns a binding that lerps between two values using the given binding as
 * the alpha.
 * @param binding The binding to use as the alpha.
 * @param from The value to lerp from.
 * @param to The value to lerp to.
 * @returns A binding that lerps between two values.
 */
export function lerpBinding<T extends number | Lerpable<any>>(
	binding: Binding<number> | number,
	from: T,
	to: T,
): Binding<T> {
	return mapBinding(binding, (alpha) => {
		if (typeIs(from, "number")) {
			return lerp(from, to as number, alpha);
		} else {
			return from.Lerp(to, alpha);
		}
	});
}

/**
 * Composes multiple bindings or values together into a single binding.
 * Calls the combiner function with the values of the bindings when any
 * of the bindings change.
 * @param ...bindings A list of bindings or values.
 * @param combiner The function that maps the bindings to a new value.
 * @returns A binding that returns the result of the combiner.
 */
export function composeBindings<T extends Bindable[], U>(...bindings: [...T, BindingCombiner<T, U>]): Binding<U>;

export function composeBindings<T>(...values: [...Bindable[], BindingCombiner<Bindable[], T>]): Binding<T> {
	const combiner = values.pop() as BindingCombiner<Bindable[], T>;
	const bindings = values.map(toBinding);

	return joinBindings(bindings).map((bindings) => combiner(...bindings));
}


import { createRoot } from "@rbxts/react-roblox";
import React, { StrictMode } from "@rbxts/react";

/**
 * Returns a function that can be used as a Hoarcekat story. This function will
 * mount the given component to the target instance and unmount it when the
 * story is unmounted.
 * @param TestComponent The component to mount.
 * @param options Optional options to pass to `withHookDetection`.
 * @returns A Hoarcekat story.
 */
export function hoarcekat(TestComponent: React.FunctionComponent) {
	return (target: Instance) => {
		const root = createRoot(target);

		root.render(
			<StrictMode>
				<TestComponent />
			</StrictMode>,
		);

		return () => {
			root.unmount();
		};
	};
}


import path from "path";

export const INCLUDE_FOLDER_PATH = path.join(__dirname, "..", "include");
export const GENERATED_FOLDER_PATH = path.join(INCLUDE_FOLDER_PATH, "generated");
export const TSCONFIG_PATH = path.join(INCLUDE_FOLDER_PATH, "tsconfig.json");
export const CUSTOM_DEFITIONS_PATH = path.join(INCLUDE_FOLDER_PATH, "customDefinitions.d.ts");

/**
 * These classes are tagged as Creatable by the API Dump, probably because they are instantiable to CoreScripts.
 * Developers, however, cannot create these.
 */
export const CREATABLE_BLACKLIST = new Set([
	"UserSettings",
	"DebugSettings",
	"Studio",
	"GameSettings",
	"ParabolaAdornment",
	"LuaSettings",
	"PhysicsSettings",
	"Player",
	"DebuggerWatch",
	"Tween",
	"UserGameSettings",
]);

export const PLUGIN_ONLY_CLASSES = new Set([
	"ABTestService",
	"ChangeHistoryService",
	"CoreGui",
	"DataModelSession",
	"DebuggerBreakpoint",
	"DebuggerManager",
	"DebuggerWatch",
	"DebugSettings",
	"File",
	"GameSettings",
	"GlobalSettings",
	"LuaSettings",
	"MemStorageConnection",
	"MultipleDocumentInterfaceInstance",
	"NetworkPeer",
	"NetworkReplicator",
	"NetworkSettings",
	"PackageService",
	"PhysicsSettings",
	"Plugin",
	"PluginAction",
	"PluginDebugService",
	"PluginDragEvent",
	"PluginGui",
	"PluginGuiService",
	"PluginMenu",
	"PluginMouse",
	"PluginToolbar",
	"PluginToolbarButton",
	"RenderingTest",
	"RenderSettings",
	"RobloxPluginGuiService",
	"ScriptDebugger",
	"ScriptDocument",
	"ScriptEditorService",
	"Selection",
	"StatsItem",
	"Studio",
	"StudioData",
	"StudioService",
	"StudioTheme",
	"TaskScheduler",
	"VersionControlService",
]);

export const CLASS_BLACKLIST = new Set([
	// Classes which Roblox leverages internally/in the CoreScripts but serve no purpose to developers
	"AnalysticsSettings",
	"AppStorageService",
	"BinaryStringValue",
	"BrowserService",
	"ClusterPacketCache",
	"CookiesService",
	"CorePackages",
	"CoreScript",
	"CoreScriptSyncService",
	"CSGDictionaryService",
	"DraftsService",
	"FlagStandService",
	"FlyweightService",
	"FriendService",
	"Geometry",
	"GoogleAnalyticsConfiguration",
	"GuidRegistryService",
	"HSRDataContentProvider",
	"HttpRbxApiService",
	"HttpRequest",
	"KeyboardService",
	"LocalStorageService",
	"LuaWebService",
	"MemStorageService",
	"MeshContentProvider",
	"MouseService",
	"NonReplicatedCSGDictionaryService",
	"PartOperationAsset",
	"PermissionsService",
	"PhysicsPacketCache",
	"PlayerEmulatorService",
	"RobloxReplicatedStorage",
	"RuntimeScriptService",
	"SolidModelContentProvider",
	"SpawnerService",
	"StandalonePluginScripts",
	"StopWatchReporter",
	"ThirdPartyUserService",
	"TimerService",
	"TouchInputService",
	"UserStorageService",
	"VirtualInputManager",
	"Visit",

	// never implemented
	"AdvancedDragger",
	"LoginService",
	"NotificationService",
	"ScriptService",
	"Status",

	// super deprecated:
	"FunctionalTest",
	"PluginManager",
	"VirtualUser",

	// "BevelMesh",
	"CustomEvent",
	"CustomEventReceiver",
	// "CylinderMesh",
	// "DoubleConstrainedValue",
	"Flag",
	"FlagStand",
	// "FloorWire",
	// "Glue",
	"GuiMain",
	// "Hat",
	"Hint",
	// "Hole",
	"Hopper",
	"HopperBin",
	// "IntConstrainedValue",
	// "JointsService",
	"Message",
	// "MotorFeature",
	"PointsService",
	// "SelectionPartLasso",
	// "SelectionPointLasso",
	// "SkateboardPlatform",
	"Skin",

	"ReflectionMetadata",
	"ReflectionMetadataCallbacks",
	"ReflectionMetadataClass",
	"ReflectionMetadataClasses",
	"ReflectionMetadataEnum",
	"ReflectionMetadataEnumItem",
	"ReflectionMetadataEnums",
	"ReflectionMetadataEvents",
	"ReflectionMetadataFunctions",
	"ReflectionMetadataItem",
	"ReflectionMetadataMember",
	"ReflectionMetadataProperties",
	"ReflectionMetadataYieldFunctions",

	// unused
	"UGCValidationService",
	"RbxAnalyticsService",
]);

export const MEMBER_BLACKLIST = new Map([
	["Workspace", new Set(["FilteringEnabled"])],
	["CollectionService", new Set(["GetCollection"])],
	["Instance", new Set(["children", "Remove"])],
	["BodyGyro", new Set(["cframe"])],
	["BodyAngularVelocity", new Set(["angularvelocity"])],
	["BodyPosition", new Set(["lastForce"])],
	["DataStoreService", new Set(["GetDataFromEmptyScopeDataStoreAsyncTemporary"])],
	["Debris", new Set(["MaxItems"])],
	["LayerCollector", new Set(["GetLayoutNodeTree"])],
	["GuiBase3d", new Set(["Color"])],
	["Model", new Set(["move"])],
	["Players", new Set(["playerFromCharacter", "players"])],
	["ServiceProvider", new Set(["service"])],
	["DataModel", new Set(["lighting"])],
	["PackageLink", new Set(["SerializedDefaultAttributes"])],
]);

export const EXPECTED_EXTRA_MEMBERS = new Map([
	["Player", ["Name"]],
	["ValueBase", ["Value", "Changed"]],
	["DataStore", ["GetAsync", "IncrementAsync", "SetAsync", "UpdateAsync", "RemoveAsync"]],
	["OrderedDataStore", ["GetAsync", "IncrementAsync", "SetAsync", "UpdateAsync", "RemoveAsync"]],
]);


import Vide, { mount } from "@rbxts/vide";
import { Button } from "client/components/button";
import { px } from "client/composables/use-px";

export = (target: Instance) => {
	return mount(() => {
		return (
			<Button
				text="Hello, world!"
				anchor={new Vector2(0.5, 0.5)}
				position={new UDim2(0.5, 0, 0.5, 0)}
				size={() => new UDim2(0, px(200), 0, px(80))}
			/>
		);
	}, target);
};


import Vide from "@rbxts/vide";
import { Money } from "client/components/money";
import { TapHere } from "client/components/tap-here";
import { usePx } from "client/composables/use-px";

export function App() {
	usePx();

	return (
		<screengui>
			<Money />
			<TapHere />
		</screengui>
	);
}


import { Players } from "@rbxts/services";
import Vide, { mount } from "@rbxts/vide";

import { App } from "./app";

mount(() => <App />, Players.LocalPlayer.WaitForChild("PlayerGui"));


import Vide, { Derivable, source, spring } from "@rbxts/vide";
import { px } from "client/composables/use-px";

interface ButtonProps {
	onClick?: () => void;
	text?: Derivable<string>;
	anchor?: Derivable<Vector2>;
	position?: Derivable<UDim2>;
	size?: Derivable<UDim2>;
	native?: Vide.InstanceAttributes<TextButton>;
	children?: Vide.Node;
}

export function Button({ onClick, text, anchor, position, size, native, children }: ButtonProps) {
	const hovered = source(false);
	const pressed = source(false);

	const buttonSize = spring(() => {
		return hovered() && !pressed() ? new UDim2(1, px(14), 1, px(14)) : new UDim2(1, 0, 1, 0);
	}, 0.2);

	const buttonPosition = spring(() => {
		return pressed() ? new UDim2(0.5, 0, 0.5, px(10)) : new UDim2(0.5, 0, 0.5, 0);
	}, 0.25);

	return (
		<frame BackgroundTransparency={1} AnchorPoint={anchor} Position={position} Size={size}>
			<textbutton
				Activated={onClick}
				MouseEnter={() => hovered(true)}
				MouseLeave={() => {
					hovered(false);
					pressed(false);
				}}
				MouseButton1Down={() => pressed(true)}
				MouseButton1Up={() => pressed(false)}
				Text={text}
				TextSize={() => px(24)}
				AnchorPoint={new Vector2(0.5, 0.5)}
				Position={buttonPosition}
				Size={buttonSize}
				{...native}
			>
				{children}
			</textbutton>
		</frame>
	);
}


import Vide from "@rbxts/vide";
import { useMoney } from "client/composables/use-money";
import { px } from "client/composables/use-px";

export function Money() {
	const money = useMoney();

	return (
		<textbutton
			Text={() => `$${money()}`}
			TextSize={() => px(32)}
			AnchorPoint={new Vector2(1, 1)}
			Position={() => new UDim2(1, -px(20), 1, -px(20))}
			Size={() => new UDim2(0, px(200), 0, px(80))}
		>
			<uicorner CornerRadius={() => new UDim(0, px(20))} />
		</textbutton>
	);
}


import Vide from "@rbxts/vide";
import { px } from "client/composables/use-px";
import { remotes } from "shared/remotes";

import { Button } from "./button";

export function TapHere() {
	return (
		<Button
			onClick={() => remotes.tap()}
			text="Tap here!"
			anchor={new Vector2(1, 1)}
			position={() => new UDim2(1, -px(20), 1, -px(120))}
			size={() => new UDim2(0, px(170), 0, px(80))}
		>
			<uicorner CornerRadius={() => new UDim(0, px(20))} />
		</Button>
	);
}


import { cleanup } from "@rbxts/vide";

/**
 * Connects a callback to an event and disconnects it when the component is unmounted.
 *
 * @param event The event to connect to.
 * @param callback The callback to connect.
 */
export function useEvent<T extends Callback>(event: RBXScriptSignal<T>, callback: T) {
	const connection = event.Connect(callback);
	cleanup(connection);
	return connection;
}


import { Players } from "@rbxts/services";
import { useAtom } from "@rbxts/vide-charm";
import { getPlayerData } from "shared/store/datastore";

export function useMoney() {
	return useAtom(() => {
		const data = getPlayerData(Players.LocalPlayer.Name);
		return data ? data.money : 0;
	});
}


import { Workspace } from "@rbxts/services";
import { source } from "@rbxts/vide";

import { useEvent } from "./use-event";

const BASE_RESOLUTION = new Vector2(1280, 832);
const MIN_SCALE = 0.5;
const DOMINANT_AXIS = 0.5;

const scale = source(1);

/**
 * Assigns a call signature to an object.
 *
 * @param callback The function to assign.
 * @param object The object to assign the function to.
 * @returns A callable object.
 */
function callable<T extends Callback, U>(callback: T, object: U): T & U {
	return setmetatable(object as never, {
		__call: (_, ...args) => callback(...args),
	});
}

/**
 * Rounds and scales a number to the current `px` unit. Includes additional
 * methods for edge cases.
 *
 * @param value The number to scale.
 * @returns A number in scaled `px` units.
 */
export const px = callable((value: number) => math.round(value * scale()), {
	even: (value: number) => math.round(value * scale() * 0.5) * 2,
	scale: (value: number) => value * scale(),
	floor: (value: number) => math.floor(value * scale()),
	ceil: (value: number) => math.ceil(value * scale()),
});

/**
 * Scales the current `px` unit based on the current viewport size. Should be
 * called once when mounting the app.
 */
export function usePx() {
	const camera = Workspace.CurrentCamera!;

	const updateScale = () => {
		const width = math.log(camera.ViewportSize.X / BASE_RESOLUTION.X, 2);
		const height = math.log(camera.ViewportSize.Y / BASE_RESOLUTION.Y, 2);
		const centered = width + (height - width) * DOMINANT_AXIS;

		scale(math.max(2 ** centered, MIN_SCALE));
	};

	useEvent(camera.GetPropertyChangedSignal("ViewportSize"), () => {
		updateScale();
	});

	updateScale();
}


import { effect } from "@rbxts/charm";
import { createCollection } from "@rbxts/lapis";
import { Players } from "@rbxts/services";
import { t } from "@rbxts/t";
import { deletePlayerData, getPlayerData, PlayerData, setPlayerData } from "shared/store/datastore";

const defaultData: PlayerData = {
	money: 0,
};

const collection = createCollection<PlayerData>("dev-0", {
	defaultData,
	validate: t.interface({
		money: t.number,
	}),
});

function onPlayerAdded(player: Player) {
	loadPlayerData(player).catch((err) => {
		warn(`Failed to load document for player ${player.Name}: ${err}`);
		setPlayerData(player.Name, defaultData);
	});
}

function onPlayerRemoving(player: Player) {
	deletePlayerData(player.Name);
}

async function loadPlayerData(player: Player) {
	const document = await collection.load(`${player.UserId}`, [player.UserId]);

	if (!player.IsDescendantOf(Players)) {
		document.close();
		return;
	}

	const unsubscribe = effect(() => {
		const data = getPlayerData(player.Name);

		if (data) {
			document.write(data);
		}
	});

	setPlayerData(player.Name, document.read());

	Promise.fromEvent(Players.PlayerRemoving, (left) => player === left)
		.then(() => unsubscribe())
		.then(() => document.close());
}

Players.PlayerAdded.Connect(onPlayerAdded);
Players.PlayerRemoving.Connect(onPlayerRemoving);

for (const player of Players.GetPlayers()) {
	onPlayerAdded(player);
}


import { observe, subscribe } from "@rbxts/charm";
import { Players } from "@rbxts/services";
import { datastore, getPlayerData } from "shared/store/datastore";

observe(datastore.players, (_, name) => {
	const player = Players.FindFirstChild(name);

	if (!player) {
		return;
	}

	const leaderstats = new Instance("Folder");

	leaderstats.Name = "leaderstats";
	leaderstats.Parent = player;

	const money = new Instance("IntValue");

	money.Name = "💸 Money";
	money.Parent = leaderstats;

	const getMoney = () => {
		return getPlayerData(name)?.money ?? 0;
	};

	const unsubscribe = subscribe(getMoney, (value) => {
		money.Value = value;
	});

	return unsubscribe;
});


import { SyncPayload } from "@rbxts/charm-sync";
import { Client, createRemotes, remote, Server } from "@rbxts/remo";
import { GlobalAtoms } from "shared/store/sync/atoms";

export const remotes = createRemotes({
	sync: remote<Client, [payload: SyncPayload<GlobalAtoms>]>(),
	init: remote<Server>(),
	tap: remote<Server>(),
});


import { atom } from "@rbxts/charm";

export interface PlayerData {
	readonly money: number;
}

type PlayerDataMap = {
	readonly [K in string]?: PlayerData;
};

export const datastore = {
	players: atom<PlayerDataMap>({}),
};

export function getPlayerData(id: string) {
	return datastore.players()[id];
}

export function setPlayerData(id: string, playerData: PlayerData) {
	datastore.players((state) => ({
		...state,
		[id]: playerData,
	}));
}

export function deletePlayerData(id: string) {
	datastore.players((state) => ({
		...state,
		[id]: undefined,
	}));
}

export function updatePlayerData(id: string, updater: (data: PlayerData) => PlayerData) {
	datastore.players((state) => ({
		...state,
		[id]: state[id] && updater(state[id]),
	}));
}


import { client } from "@rbxts/charm-sync";
import { remotes } from "shared/remotes";

import { atoms } from "./atoms";

const syncer = client({ atoms });

remotes.sync.connect((payload) => {
	syncer.sync(payload);
});

remotes.init();


import { server } from "@rbxts/charm-sync";
import { remotes } from "shared/remotes";

import { atoms } from "./atoms";
import { filterPayload } from "./utils/filter-payload";

const syncer = server({ atoms });

syncer.connect((player, payload) => {
	remotes.sync(player, filterPayload(player, payload));
});

remotes.init.connect((player) => {
	syncer.hydrate(player);
});


import { SyncPayload } from "@rbxts/charm-sync";

import { GlobalAtoms } from "../atoms";

/**
 * Filters the payload to only include the player's data.
 *
 * @param player The player to send the payload to.
 * @param payload The payload to filter.
 * @returns A new payload that only includes the player's data.
 */
export function filterPayload(player: Player, payload: SyncPayload<GlobalAtoms>) {
	if (payload.type === "init") {
		return {
			...payload,
			data: {
				...payload.data,
				"datastore/players": {
					[player.Name]: payload.data["datastore/players"][player.Name],
				},
			},
		};
	}

	return {
		...payload,
		data: {
			...payload.data,
			"datastore/players": payload.data["datastore/players"] && {
				[player.Name]: payload.data["datastore/players"][player.Name],
			},
		},
	};
}


import { AtomMap } from "@rbxts/charm-sync";

type NestedAtomMap = {
	readonly [K in string]: AtomMap;
};

type FlattenNestedAtoms<T extends NestedAtomMap> = {
	readonly [K in keyof T as `${string & K}/${string & keyof T[K]}`]: T[K][Extract<keyof T[K], string>];
};

/**
 * Assigns unique prefixes to each atom and flattens them into a single map.
 * Should be passed to Charm's Sync API.
 *
 * @param maps The maps of atoms to flatten.
 * @returns The flattened map of atoms.
 */
export function flattenAtoms<T extends NestedAtomMap>(maps: T): FlattenNestedAtoms<T>;

export function flattenAtoms(maps: NestedAtomMap): FlattenNestedAtoms<NestedAtomMap> {
	const flattened = {} as Writable<FlattenNestedAtoms<NestedAtomMap>>;

	for (const [prefix, map] of pairs(maps)) {
		for (const [key, atom] of pairs(map)) {
			flattened[`${prefix}/${key}`] = atom;
		}
	}

	return flattened;
}


import Link from "@docusaurus/Link";
import useBaseUrl from "@docusaurus/useBaseUrl";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import CodeBlock from "@theme/CodeBlock";
import Layout from "@theme/Layout";
import React from "react";
import styles from "./styles.module.css";

const EXAMPLE_CODE = `
import { CollectionService } from "@rbxts/services";

for (const obj of CollectionService.GetTagged("Lava")) {
    if (obj.IsA("BasePart")) {
        obj.Touched.Connect(part =>
            part
                .Parent
                ?.FindFirstChildOfClass("Humanoid")
                ?.TakeDamage(100)
        );
    }
}
`.trim();

interface FeatureCardProps {
	title: string;
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	children: any;
}

const FeatureCard: React.FC<FeatureCardProps> = ({ title, children }) => (
	<div className={`col col--4 ${styles.featureWrapper}`}>
		<div className={`card ${styles.feature}`}>

{title}

{children}

);

export default () => {
	const context = useDocusaurusContext();
	const { siteConfig } = context;
	return (
		<Layout noFooter>
			<header className={`hero ${styles.heroBanner}`}>

					<h1 className={`hero__title ${styles.title}`}>{siteConfig.title}

						A{" "}

							TypeScript

						-to-

							Luau
						{" "}
						Compiler for{" "}

							Roblox

						<Link
							className={`button button--outline button--primary button--lg ${styles.getStarted}`}
							to={useBaseUrl("docs/")}
						>
							Get Started
						</Link>
						<Link
							className={`button button--outline button--secondary button--lg ${styles.getStarted}`}
							to={useBaseUrl("playground/")}
						>
							Playground
						</Link>

								<CodeBlock language="ts">{EXAMPLE_CODE}</CodeBlock>

							<FeatureCard title="Type Safety">
								With static types, you'll never run into errors caused by typos, indexing an undefined
								value, or passing the wrong type of value into a function ever again.
							</FeatureCard>
							<FeatureCard title="Community & Ecosystem">
								An active community with an ecosystem consisting of a growing number of community-made{" "}

									packages

								. Many popular modules for Roblox already have typings written for roblox-ts.
							</FeatureCard>
							<FeatureCard title="Unparalleled Tooling">
								Allows access to a large number of existing tools made for TypeScript. Use industry
								standard tools like{" "}

									ESLint

								,{" "}

									VSCode

								,{" "}

									Prettier

								, and more!
							</FeatureCard>

		</Layout>
	);
};


import { CollectionService } from "@rbxts/services";

for (const obj of CollectionService.GetTagged("Lava")) {
	if (obj.IsA("BasePart")) {
		obj.Touched.Connect(part => part.Parent?.FindFirstChildOfClass("Humanoid")?.TakeDamage(100));
	}
}


import React from "@rbxts/react";

interface GreetingProps {
	name: string;
}

class Greeting extends React.Component<GreetingProps> {
	public render(): React.Element {
		return (
			<textbutton
				Text={`Hello, ${this.props.name}`}
				TextSize={22}
				Font={Enum.Font.Gotham}
				Event={{ MouseButton1Click: () => print("Click!") }}
			/>
		);
	}
}

const greeting = <Greeting name="Player" />;


import type { OnInit, OnStart } from "@flamework/core";
import { Controller } from "@flamework/core";
import type { Logger } from "@rbxts/log";
import { Inspect } from "@rbxts/rbx-debug";
import { SoundService, TweenService } from "@rbxts/services";

import { USER_ID } from "client/constants";
import { store } from "client/store";
import SoundSystem from "shared/modules/3d-sound-system";
import Make from "shared/modules/make";
import type { PlayerSettings } from "shared/store/persistent";
import { selectPlayerSettings } from "shared/store/persistent";

export const enum SoundType {
	Music = "Music",
	SoundEffect = "SoundEffect",
}

interface PlaySoundOptions {
	attachToPoint?: BasePart;
	debugName?: string;
	sound: number;
	soundProperties?: Omit<Partial<InstanceProperties<Sound>>, "Parent">;
	soundType: SoundType;
}

/**
 * A controller for managing sound effects and music in the game. This
 * controller provides a way to play sounds and music with the ability to fade
 * in and out the volume of the sound.
 *
 * ```
 * const sound = soundController.createSound({ ... });.
 * soundController.play(sound);.
 *
 * sound:Destroy(); -- Destroy the sound when you're done with it
 * ```
 */
@Controller({})
export class SoundController implements OnInit, OnStart {
	private readonly soundGroups = new Map<SoundType, SoundGroup>();

	constructor(private readonly logger: Logger) {}

	/** @ignore */
	public onInit(): void {
		this.soundGroups.set(SoundType.Music, this.makeSoundGroup(SoundType.Music));
		this.soundGroups.set(SoundType.SoundEffect, this.makeSoundGroup(SoundType.SoundEffect));

		this.logger.Info("Setup SoundGroup instances");
	}

	/** @ignore */
	public onStart(): void {
		store.subscribe(selectPlayerSettings(USER_ID), (current) => {
			if (!current) {
				return;
			}

			this.onSettingsChanged(current);
		});
	}

	public createSound({
		attachToPoint,
		debugName,
		sound,
		soundProperties = {},
		soundType,
	}: PlaySoundOptions): Sound {
		const soundGroup = this.soundGroups.get(soundType);
		assert(soundGroup, `SoundGroup not found for SoundType ${soundType}`);

		const soundParent = attachToPoint ?? soundGroup;
		const soundObject = Make("Sound", {
			...soundProperties,
			Name: debugName ?? Inspect(sound),
			Parent: soundParent,
			SoundGroup: soundGroup,
			SoundId: `rbxassetid://${sound}`,
		});

		// Make it a 3D spatial sound if a point was declared
		if (attachToPoint) {
			SoundSystem.Attach(soundObject);
		}

		this.logger.Info(`Playing sound ${sound} of type ${soundType}`);

		return soundObject;
	}

	public play(soundObject: Sound, fadeInTime?: number): void {
		soundObject.Play();

		if (fadeInTime !== undefined) {
			this.fadeInSound(soundObject, fadeInTime);
		}
	}

	public fadeInSound(soundObject: Sound, fadeInTime: number): void {
		const desiredVolume = soundObject.Volume;
		soundObject.Volume = 0;

		const tweenInfo = new TweenInfo(
			fadeInTime,
			Enum.EasingStyle.Quad,
			Enum.EasingDirection.Out,
		);

		TweenService.Create(soundObject, tweenInfo, { Volume: desiredVolume }).Play();
	}

	private makeSoundGroup(soundType: SoundType): SoundGroup {
		// Make sure this SoundGroup doesn't already exist
		const existing = SoundService.FindFirstChild(soundType);
		if (existing?.IsA("SoundGroup") === true) {
			return existing;
		}

		return Make("SoundGroup", {
			Name: soundType,
			Parent: SoundService,
			Volume: 1,
		});
	}

	private onSettingsChanged(current: PlayerSettings): void {
		const musicGroup = this.soundGroups.get(SoundType.Music);
		assert(musicGroup, "Music SoundGroup not found");
		musicGroup.Volume = current.musicVolume;

		const sfxGroup = this.soundGroups.get(SoundType.SoundEffect);
		assert(sfxGroup, "SoundEffect SoundGroup not found");
		sfxGroup.Volume = current.sfxVolume;
	}
}


import { Players } from "@rbxts/services";

export const { LocalPlayer } = Players;

export const USER_ID = tostring(LocalPlayer.UserId);
export const USER_NAME = LocalPlayer.Name;

// eslint-disable-next-line ts/no-non-null-assertion, flawless/naming-convention -- Should always be present during usage.
export const PlayerGui = LocalPlayer.FindFirstChildWhichIsA("PlayerGui")!;


import type { OnStart } from "@flamework/core";
import { Controller } from "@flamework/core";
import type { Logger } from "@rbxts/log";
import { Error } from "@rbxts/luau-polyfill";
import Signal from "@rbxts/rbx-better-signal";
import { promiseTree } from "@rbxts/validate-tree";

import { LocalPlayer } from "client/constants";
import {
	CHARACTER_LOAD_TIMEOUT,
	type CharacterRig,
	CharacterSchema,
	onCharacterAdded,
} from "shared/util/player-util";

/**
 * A controller for managing the current character rig in the game. We verify
 * that the character rig is loaded and fully exists according to the schema
 * before we allow any other systems to interact with it. It is advised to use
 * this controller when interacting with specific parts of the character rig,
 * rather than directly accessing the character model through
 * `player.Character`.
 */
@Controller({})
export class CharacterController implements OnStart {
	private currentCharacter: CharacterRig | undefined;

	public readonly onCharacterAdded = new Signal<(character: CharacterRig) => void>();
	public readonly onCharacterRemoving = new Signal();

	constructor(private readonly logger: Logger) {}

	/** @ignore */
	public onStart(): void {
		onCharacterAdded(LocalPlayer, (character) => {
			this.characterAdded(character).catch((err) => {
				this.logger.Fatal(`Could not get character rig because:\n${err}`);
			});
		});
	}

	/**
	 * Gets the current character for the local player. This is the character
	 * that has been loaded and exists according to the character schema.
	 *
	 * @returns The current character rig if it exists.
	 */
	public getCurrentCharacter(): CharacterRig | undefined {
		return this.currentCharacter;
	}

	/**
	 * Ensures that a character model is loaded and exists according to the
	 * schema. If the character model is removed before it loads, or if it fails
	 * to load within the timeout, the promise will reject.
	 *
	 * @param model - The model to load the character rig from.
	 * @returns A promise that resolves when the character rig is loaded.
	 */
	private async characterAdded(model: Model): Promise<void> {
		const promise = promiseTree(model, CharacterSchema);

		// If our character fails to load, we want to cancel the promise
		const timeout = task.delay(CHARACTER_LOAD_TIMEOUT, () => {
			promise.cancel();
		});

		// If our character is removed before it loads, we want to cancel.
		const connection = model.AncestryChanged.Connect(() => {
			if (model.IsDescendantOf(game)) {
				return;
			}

			promise.cancel();
		});

		const [success, rig] = promise.await();
		task.cancel(timeout);
		connection.Disconnect();

		if (!success) {
			throw new Error("Character failed to load.");
		}

		this.listenForCharacterRemoving(model);
		this.onRigLoaded(rig);
	}

	/**
	 * Listens for the character model to be removed from the game.
	 *
	 * @param character - The character model to listen for removal on.
	 */
	private listenForCharacterRemoving(character: Model): void {
		const connection = character.AncestryChanged.Connect(() => {
			if (character.IsDescendantOf(game)) {
				return;
			}

			this.logger.Verbose("Character has been removed.");

			connection.Disconnect();
			this.currentCharacter = undefined;
			this.onCharacterRemoving.Fire();
		});
	}

	/**
	 * Called when the character rig has been fully loaded.
	 *
	 * @param rig - The character rig that was loaded.
	 */
	private onRigLoaded(rig: CharacterRig): void {
		this.logger.Debug("Loaded character rig.");
		this.currentCharacter = rig;
		this.onCharacterAdded.Fire(rig);
	}
}


import { Flamework, Modding } from "@flamework/core";
import type { Logger } from "@rbxts/log";
import Log from "@rbxts/log";

import { GAME_NAME } from "shared/constants";
import { setupLogger } from "shared/functions/setup-logger";

import { createApp, reactConfig } from "./ui/react-config";

function start(): void {
	reactConfig();
	setupLogger();

	Log.Info(`${GAME_NAME} client version: ${game.PlaceVersion}`);

	Modding.registerDependency<Logger>((ctor) => Log.ForContext(ctor));

	Flamework.addPaths("src/client");

	Log.Info("Flamework ignite!");
	Flamework.ignite();

	createApp().catch(() => {
		Log.Fatal("Failed to create React app!");
	});
}

start();


import type { ProducerMiddleware } from "@rbxts/reflex";
import { createBroadcastReceiver } from "@rbxts/reflex";

import { events } from "client/network";
import { $NODE_ENV } from "rbxts-transform-env";
import { IS_EDIT } from "shared/constants";
import { stateSerDes } from "shared/store";

/**
 * A middleware that listens for actions dispatched from the server and
 * dispatches them to the client store.
 *
 * @returns The middleware function.
 */
export function receiverMiddleware(): ProducerMiddleware {
	// Storybook support
	if ($NODE_ENV === "development" && IS_EDIT) {
		return () => (dispatch) => dispatch;
	}

	const receiver = createBroadcastReceiver({
		start: () => {
			events.store.start.fire();
		},
	});

	events.store.dispatch.connect((actions) => {
		receiver.dispatch(actions);
	});

	events.store.hydrate.connect((state) => {
		receiver.hydrate(stateSerDes.deserialize(state.buffer, state.blobs));
	});

	return receiver.middleware;
}


import React from "@rbxts/react";

import { Layer } from "./components/primitive";

export function App(): React.ReactNode {
	return (
		<>
			<Layer key="example-layer" />

			<Layer key="example-layer1" />
		</>
	);
}


import { useBindingListener, useCamera } from "@rbxts/pretty-react-hooks";
import React, { useState } from "@rbxts/react";
import { createPortal } from "@rbxts/react-roblox";

import type { BindingValue } from "types/util/react";

interface BackgroundBlurProps {
	/** The size of the blur effect. */
	BlurSize?: BindingValue<number>;
}

/**
 * Renders a background blur effect based on the provided `BlurSize`.
 *
 * @param props - The component props.
 * @returns The rendered background blur component.
 */
export function BackgroundBlur({ BlurSize }: Readonly<BackgroundBlurProps>): React.ReactNode {
	const camera = useCamera();
	const [isVisible, setIsVisible] = useState(false);

	useBindingListener(BlurSize, (size = 0) => {
		setIsVisible(size > 0);
	});

	return createPortal(<>{isVisible ? <blureffect Size={BlurSize ?? 0} /> : undefined}</>, camera);
}


import React, { useEffect, useState } from "@rbxts/react";
import { setTimeout } from "@rbxts/set-timeout";

interface DelayRenderProps extends React.PropsWithChildren {
	/** The delay (in seconds) before rendering the children component. */
	MountDelay?: number;
	/**
	 * Indicates whether the children component should be rendered. If true, the
	 * children component will be rendered after the MountDelay. If false, the
	 * children component will be unmounted after the UnmountDelay.
	 */
	ShouldRender: boolean;
	/** The delay (in seconds) before unmounting the children component. */
	UnmountDelay?: number;
}

/**
 * Renders the children component with a delay based on the provided MountDelay
 * and UnmountDelay values.
 *
 * This component can be useful for creating transitions or animations when
 * rendering or unmounting components, e.g, playing a fade-out animation before
 * unmounting a component.
 *
 * @example
 *
 * ```tsx
 * <DelayRender ShouldRender={true} UnmountDelay={1}>
 * 	<Text Text={"Hello World!"} />
 * </DelayRender>;
 * ```
 *
 * @param delayRenderProps - The properties of delay-render props.
 * @returns - The rendered children component.
 * @component
 */
export function DelayRender({
	MountDelay = 0,
	ShouldRender,
	UnmountDelay = 0,
	children,
}: Readonly<DelayRenderProps>): React.ReactNode {
	const [shouldRender, setShouldRender] = useState(false);

	useEffect(() => {
		const delay = ShouldRender ? MountDelay : UnmountDelay;
		return setTimeout(() => {
			setShouldRender(ShouldRender);
		}, delay);
	}, [MountDelay, ShouldRender, UnmountDelay]);

	return <>{shouldRender ? children : undefined}</>;
}


import Log from "@rbxts/log";
import type { ErrorInfo } from "@rbxts/react";
import React, { ReactComponent } from "@rbxts/react";

interface ErrorBoundaryProps extends React.PropsWithChildren {
	onFallback: (error: unknown) => React.ReactNode;
}

interface ErrorBoundaryState {
	hasError: boolean;
	message?: unknown;
}

/**
 * ErrorBoundary component that catches and handles errors in its child
 * components.
 *
 * @example
 *
 * ```tsx
 * <ErrorBoundary
 * 	fallback={(message) => <ErrorPage message={tostring(message)} />}
 * />;
 * ```
 *
 * @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
 */
@ReactComponent
export class ErrorBoundary extends React.Component<
	Readonly<ErrorBoundaryProps>,
	ErrorBoundaryState
> {
	public override readonly state: ErrorBoundaryState = {
		hasError: false,
	};

	public override componentDidCatch(err: unknown, errorInfo: ErrorInfo): void {
		Log.Warn(tostring(err), errorInfo.componentStack);

		this.setState({
			hasError: true,
			message: `${err} ${errorInfo.componentStack}`,
		});
	}

	public override render(): React.ReactNode {
		const { hasError, message } = this.state;
		const { onFallback, children } = this.props;

		if (hasError) {
			return onFallback(message);
		}

		return children;
	}
}


import React from "@rbxts/react";

import { ErrorBoundary } from "./error-boundary";
import { ErrorPage } from "./error-page";

type ErrorHandlerProps = React.PropsWithChildren;

export function ErrorHandler({ children }: Readonly<ErrorHandlerProps>): React.ReactNode {
	return (
		<ErrorBoundary
			onFallback={(err) => {
				return <ErrorPage Message={tostring(err)} />;
			}}
		>
			{children}
		</ErrorBoundary>
	);
}


import type React from "@rbxts/react";

interface ErrorPageProps {
	Message: string;
}

export function ErrorPage({ Message }: Readonly<ErrorPageProps>): React.ReactNode {
	warn(Message);

	return undefined;
}


import { useMountEffect } from "@rbxts/pretty-react-hooks";
import React, { useRef } from "@rbxts/react";

import Make from "shared/modules/make";

export interface ObjectViewportProps extends React.PropsWithChildren {
	/** Additional depth to push the camera back. */
	ExtraCameraDepth?: number;
	/** The native props to a viewport. */
	Native: React.InstanceProps<ViewportFrame>;
	/** The object to be displayed in the viewport. */
	Object: BasePart | Model;
}

function setDefaultCameraView(camera: Camera, model: Model, cameraDepth = 0): void {
	const [modelCf] = model.GetBoundingBox();

	const radius = model.GetExtentsSize().Magnitude / 2;
	const halfFov = math.rad(camera.FieldOfView) / 2;
	const depth = radius / math.tan(halfFov) + cameraDepth;

	// 1. Remove translation
	// 2. Move to model position
	// 3. Push camera back by depth in the original angle given
	camera.CFrame = camera.CFrame.sub(camera.CFrame.Position)
		.add(modelCf.Position)
		.add(camera.CFrame.Position.sub(modelCf.Position).Unit.mul(depth));
}

/**
 * Renders a viewport for displaying an object.
 *
 * @example
 *
 * ```tsx
 * <ObjectViewport
 * 	Native={{ Size: new UDim2(1, 0, 1, 0) }}
 * 	Object={new Part()}
 * />;
 * ```
 *
 * @param props - The object viewport component props.
 * @returns The rendered viewport.
 * @component
 */
export function ObjectViewport({
	ExtraCameraDepth,
	Native,
	Object,
	children,
}: Readonly<ObjectViewportProps>): React.ReactNode {
	// Setup the viewport after mounting when we have a ref to it
	const viewportRef = useRef<ViewportFrame>();
	const objectRef = useRef<BasePart | Model>(Object);

	useMountEffect(() => {
		const viewport = viewportRef.current;
		assert(viewport !== undefined, "Viewport is not defined");

		const model = (() => {
			const { current } = objectRef;
			if (!current.IsA("Model")) {
				return Make("Model", {
					Children: [current],
					PrimaryPart: current,
				});
			}

			return current;
		})();

		objectRef.current.Parent = viewport;

		const viewportCamera = new Instance("Camera");
		viewport.CurrentCamera = viewportCamera;
		setDefaultCameraView(viewportCamera, model, ExtraCameraDepth);
		viewportCamera.Parent = viewport;
	});

	return (
		<viewportframe ref={viewportRef} {...Native}>
			{children}
		</viewportframe>
	);
}


import React from "@rbxts/react";

import type { FrameProps } from "./frame";

export interface ButtonProps extends FrameProps<TextButton> {
	/** The default properties of a `TextButton` component. */
	Native?: Partial<React.InstanceProps<TextButton>>;
	/** A callback that is triggered when the button is clicked. */
	onClick?: () => void;
	/**
	 * A callback that is triggered when the mouse button is pressed down on the
	 * button.
	 */
	onMouseDown?: () => void;
	/** A callback that is triggered when the mouse enters the button. */
	onMouseEnter?: () => void;
	/** A callback that is triggered when the mouse leaves the button. */
	onMouseLeave?: () => void;
	/**
	 * A callback that is triggered when the mouse button is released on the
	 * button.
	 */
	onMouseUp?: () => void;
}

/**
 * Button component.
 *
 * @example
 *
 * ```tsx
 * <Button
 * 	CornerRadius={new UDim(0, 8)}
 * 	Native={{ Size: new UDim2(0, 100, 0, 100) }}
 * 	onClick={useCallback(() => {
 * 		print("Hello World!");
 * 	}, [])}
 * />;
 * ```
 *
 * Button is released on the button.
 *
 * @param buttonProps - The properties of the Button component.
 * @returns The rendered Button component.
 * @component
 *
 * @see https://create.roblox.com/docs/reference/engine/classes/TextButton
 */
export function Button({
	CornerRadius,
	Native,
	onClick,
	onMouseDown,
	onMouseEnter,
	onMouseLeave,
	onMouseUp,
	children,
}: Readonly<ButtonProps>): React.ReactNode {
	const event = {
		Activated: () => {
			onClick?.();
		},
		MouseButton1Down: () => {
			onMouseDown?.();
		},
		MouseButton1Up: () => {
			onMouseUp?.();
		},
		MouseEnter: () => {
			onMouseEnter?.();
		},
		MouseLeave: () => {
			onMouseLeave?.();
		},
		...Native?.Event,
	};

	return (
		<textbutton
			AnchorPoint={new Vector2(0.5, 0.5)}
			Event={event}
			Position={new UDim2(0.5, 0, 0.5, 0)}
			Text=""
			{...Native}
		>
			{CornerRadius ? <uicorner CornerRadius={CornerRadius} /> : undefined}
			{children}
		</textbutton>
	);
}


import React, { forwardRef } from "@rbxts/react";

import type { FrameProps } from "./frame";

export type CanvasGroupProps = FrameProps<CanvasGroup>;

/**
 * A wrapper around the `CanvasGroup` component, a GuiObject that renders
 * descendants as a group with color and transparency applied to the render
 * result.
 *
 * @example
 *
 * ```tsx
 * <CanvasGroup Native={{ Size: new UDim2(0, 100, 0, 100) }}>
 * ```
 *
 * @component
 *
 * @see https://developer.roblox.com/en-us/api-reference/class/CanvasGroup
 */
export const CanvasGroup = forwardRef(
	(props: Readonly<CanvasGroupProps>, ref: React.Ref<CanvasGroup>) => {
		const { CornerRadius, Native, children } = props;

		return (
			<canvasgroup
				ref={ref}
				AnchorPoint={new Vector2(0.5, 0.5)}
				BackgroundTransparency={1}
				BorderSizePixel={0}
				Position={new UDim2(0.5, 0, 0.5, 0)}
				{...Native}
			>
				{CornerRadius ? <uicorner key="corner" CornerRadius={CornerRadius} /> : undefined}
				{children}
			</canvasgroup>
		);
	},
);

CanvasGroup.displayName = "CanvasGroup";


import type { Ref } from "@rbxts/react";
import React, { forwardRef } from "@rbxts/react";

import type { BindingValue } from "types/util/react";

export interface FrameProps<T extends Instance = Frame> extends React.PropsWithChildren {
	/** An optional helper that rounds the corners of the frame. */
	CornerRadius?: BindingValue<UDim>;
	/** The default properties of the component. */
	Native?: Partial<React.InstanceProps<T>>;
}

/**
 * A wrapper around the `Frame` component, a GuiObject that renders as a plain
 * rectangle with no other content. If you intend to use this component as a
 * container for other components, consider using the `Group` component
 * instead.
 *
 * @example
 *
 * ```tsx
 * <Frame CornerRadius={new UDim(0, 8)} Native={{ Size: new UDim2(0, 100, 0, 100) }}>
 * ```
 *
 * @note A frame defaults to being centered in the parent container (anchor point and
 * position are set to 0.5).
 *
 * @component
 *
 * @see https://create.roblox.com/docs/reference/engine/classes/Frame
 */
export const Frame = forwardRef(
	({ CornerRadius, Native, children }: Readonly<FrameProps>, ref: Ref<Frame>) => {
		return (
			<frame
				ref={ref}
				AnchorPoint={new Vector2(0.5, 0.5)}
				Position={new UDim2(0.5, 0, 0.5, 0)}
				Size={new UDim2(1, 0, 1, 0)}
				{...Native}
			>
				{children}
				{CornerRadius ? <uicorner CornerRadius={CornerRadius} /> : undefined}
			</frame>
		);
	},
);

Frame.displayName = "Frame";


import React, { forwardRef } from "@rbxts/react";

interface GroupProps extends React.PropsWithChildren {
	/** All the default properties of a `Frame` component. */
	Native?: Partial<Omit<React.InstanceProps<Frame>, "BackgroundTransparency">>;
}

/**
 * A container for grouping multiple components together.
 *
 * @example
 *
 * ```tsx
 * <Group Native={{ Position: new UDim2(0, 0, 0, 0) }}>
 * 	<TextButton Text="Button 1" />
 * 	<TextButton Text="Button 2" />
 * </Group>;
 * ```
 *
 * @note A group defaults to being centered in the parent container (anchor point and
 * position are set to 0.5).
 *
 * @component
 */
export const Group = forwardRef(
	({ Native, children }: Readonly<GroupProps>, ref: React.Ref<Frame>) => {
		return (
			<frame
				ref={ref}
				AnchorPoint={new Vector2(0.5, 0.5)}
				BackgroundTransparency={1}
				Position={new UDim2(0.5, 0, 0.5, 0)}
				Size={new UDim2(1, 0, 1, 0)}
				{...Native}
			>
				{children}
			</frame>
		);
	},
);

Group.displayName = "Group";


import React, { forwardRef } from "@rbxts/react";

import type { BindingValue } from "types/util/react";
import type { AssetId } from "types/util/roblox";

import type { FrameProps } from "./frame";

export interface ImageProps extends FrameProps<ImageLabel> {
	/** The image to display. */
	Image: BindingValue<AssetId>;
}

/**
 * A component for displaying an image.
 *
 * @example
 *
 * ```tsx
 * <ImageLabel
 * 	Image="rbxassetid://1234567890"
 * 	Native={{
 * 		Size={new UDim2(0, 100, 0, 100)}
 * 	}}
 * />;
 * ```
 *
 * @component
 *
 * @see https://developer.roblox.com/en-us/api-reference/class/ImageLabel
 */
export const ImageLabel = forwardRef((props: Readonly<ImageProps>, ref: React.Ref<ImageLabel>) => {
	const { CornerRadius, Image, Native, children } = props;

	return (
		<imagelabel
			ref={ref}
			AnchorPoint={new Vector2(0.5, 0.5)}
			BackgroundTransparency={1}
			Image={Image}
			Position={new UDim2(0.5, 0, 0.5, 0)}
			Size={new UDim2(1, 0, 1, 0)}
			{...Native}
		>
			{CornerRadius ? <uicorner CornerRadius={CornerRadius} /> : undefined}
			{children}
		</imagelabel>
	);
});

ImageLabel.displayName = "ImageLabel";


import React from "@rbxts/react";

import { $NODE_ENV } from "rbxts-transform-env";
import { IS_EDIT } from "shared/constants";

import { UltraWideContainer } from "../ultra-wide-container";
import { Group } from "./group";

export interface LayerProps extends React.PropsWithChildren {
	/**
	 * Whether or not to constraint ultra wide monitors to 16:9.
	 *
	 * @default true
	 */
	ClampUltraWide?: boolean;
	/** The display order of the layer. */
	DisplayOrder?: number | undefined;
}

/**
 * Renders a collection of components under a screengui.
 *
 * If the game is running, the components are rendered under a `screengui`
 * object, otherwise they are rendered under a `Group` object while in edit mode
 * for storybook support.
 *
 * @example
 *
 * ```tsx
 * <Layer DisplayOrder={1}>
 * 	<TextButton Text="Button 1" />
 * 	<TextButton Text="Button 2" />
 * </Layer>;
 * ```
 *
 * @param props - The component props.
 * @returns The rendered Layer component.
 * @note By default, the `ClampUltraWide` property is set to `true`. This means
 * that the layer will be constrained to a 16:9 aspect ratio on ultra wide
 * monitors. If you want to disable this behavior, set the property to `false`.
 *
 * @component
 *
 * @see https://developer.roblox.com/en-us/api-reference/class/ScreenGui
 */
export function Layer({
	ClampUltraWide = true,
	DisplayOrder = 0,
	children,
}: Readonly<LayerProps>): React.ReactNode {
	return $NODE_ENV === "development" && IS_EDIT ? (
		<Group
			Native={{
				ZIndex: DisplayOrder,
			}}
		>
			{ClampUltraWide ? <UltraWideContainer>{children}</UltraWideContainer> : children}
		</Group>
	) : (
		<screengui
			DisplayOrder={DisplayOrder}
			IgnoreGuiInset={true}
			ResetOnSpawn={false}
			ZIndexBehavior="Sibling"
		>
			{ClampUltraWide ? <UltraWideContainer>{children}</UltraWideContainer> : children}
		</screengui>
	);
}


import type { PropsWithChildren } from "@rbxts/react";
import React, { forwardRef } from "@rbxts/react";

interface PaddingProps extends PropsWithChildren {
	Padding: number;
}

/**
 * A component for adding consistent padding around an element.
 *
 * @example
 *
 * ```tsx
 * <Padding Padding={10}>
 * ```
 *
 * @component
 *
 * @see https://developer.roblox.com/en-us/api-reference/class/UIPadding
 */
export const PaddingComponent = forwardRef(
	({ Padding, children }: Readonly<PaddingProps>, ref: React.Ref<UIPadding>) => {
		return (
			<uipadding
				key="Padding"
				ref={ref}
				PaddingBottom={new UDim(0, Padding)}
				PaddingLeft={new UDim(0, Padding)}
				PaddingRight={new UDim(0, Padding)}
				PaddingTop={new UDim(0, Padding)}
			>
				{children}
			</uipadding>
		);
	},
);

PaddingComponent.displayName = "PaddingComponent";


import React from "@rbxts/react";

import { useRem, useTheme } from "client/ui/hooks";

interface ScrollingFrameProps extends React.PropsWithChildren {
	/** The size of the canvas. */
	CanvasSize: Vector2;
	/** All the default properties of a `ScrollingFrame` component. */
	Native?: Partial<Omit<React.InstanceProps<ScrollingFrame>, "BackgroundTransparency">>;
}

/**
 * Renders a scrolling frame component.
 *
 * @example
 *
 * ```tsx
 * const [canvasSizeY, setCanvasSizeY] = useState(0);
 *
 * <ScrollingFrame
 * 	CanvasSize={canvasSize}
 * 	Native={{ Size: new UDim2(1, 0, 1, 0) }}
 * >
 * 	<uigridlayout
 * 		CellPadding={new UDim2(0, 0, 0, 5)}
 * 		CellSize={new UDim2(0, 100, 0, 100)}
 * 		Change={{
 * 			AbsoluteContentSize: (rbx): void => {
 * 				setCanvasSizeY(rbx.AbsoluteContentSize);
 * 			},
 * 		}}
 * 	/>
 * </ScrollingFrame>;
 * ```
 *
 * @param props - The props for the scrolling frame.
 * @returns The rendered scrolling frame component.
 * @component
 *
 * @see https://developer.roblox.com/en-us/api-reference/class/ScrollingFrame
 */
export function ScrollingFrame({
	CanvasSize,
	Native,
	children,
}: Readonly<ScrollingFrameProps>): React.ReactNode {
	const theme = useTheme();
	const rem = useRem();

	return (
		<scrollingframe
			AnchorPoint={new Vector2(0.5, 0.5)}
			BackgroundTransparency={1}
			BorderSizePixel={0}
			CanvasSize={UDim2.fromOffset(
				CanvasSize.X !== 0 ? CanvasSize.X + 5 : 0,
				CanvasSize.Y !== 0 ? CanvasSize.Y + 5 : 0,
			)}
			Position={new UDim2(0.5, 0, 0.5, 0)}
			ScrollBarImageColor3={theme.colors.secondary}
			ScrollBarThickness={rem(0.5)}
			{...Native}
		>
			{children}
		</scrollingframe>
	);
}


import React, { forwardRef } from "@rbxts/react";

import { useRem, useTheme } from "client/ui/hooks";
import type { BindingValue } from "types/util/react";

import type { FrameProps } from "./frame";

export interface TextLabelProps extends FrameProps<TextLabel> {
	/**
	 * The font of the text, defaults to the primary font specified by the
	 * default theme.
	 */
	Font?: BindingValue<Enum.Font> | undefined;
	/**
	 * The default properties of a `TextLabel` component, minus the ones
	 * specified in the TextProps.
	 */
	Native?: Partial<
		Omit<
			React.InstanceProps<TextLabel>,
			"Font" | "Text" | "TextColor3" | "TextColor" | "TextSize"
		>
	>;
	/** The text to display. */
	Text: BindingValue<string>;
	/** The color of the text. */
	TextColor?: BindingValue<Color3> | undefined;
	/** The size of the text. */
	TextSize?: BindingValue<number> | undefined;
}

/**
 * Renders a text label component.
 *
 * @example
 *
 * ```tsx
 * <TextLabel
 * 	Text={"Hello, world!"}
 * 	Native={{ Size: new UDim2(0, 100, 0, 50) }}
 * />;
 * ```
 *
 * @param textProps - The props for the TextLabel component.
 * @returns The rendered TextLabel component.
 * @component
 *
 * @see https://create.roblox.com/docs/reference/engine/classes/TextLabel
 */
export const TextLabel = forwardRef(
	(props: Readonly<TextLabelProps>, ref: React.Ref<TextLabel>) => {
		const { CornerRadius, Font, Native, Text, TextColor, TextSize, children } = props;

		const rem = useRem();
		const theme = useTheme();

		return (
			<textlabel
				ref={ref}
				AnchorPoint={new Vector2(0.5, 0.5)}
				BackgroundTransparency={1}
				Font={Font ?? theme.fonts.primary.regular}
				Position={new UDim2(0.5, 0, 0.5, 0)}
				Text={Text}
				TextColor3={TextColor ?? theme.colors.text.primary}
				TextSize={TextSize ?? rem(1)}
				{...Native}
			>
				{children}
				{CornerRadius ? <uicorner CornerRadius={CornerRadius} /> : undefined}
			</textlabel>
		);
	},
);

TextLabel.displayName = "TextLabel";


import React, { StrictMode } from "@rbxts/react";
import { ReflexProvider } from "@rbxts/react-reflex";
import type { Root } from "@rbxts/react-roblox";
import { createPortal, createRoot } from "@rbxts/react-roblox";

import { PlayerGui } from "client/constants";
import { store } from "client/store";

import type { RemProviderProps } from "../providers/rem-provider";
import { RemProvider } from "../providers/rem-provider";

interface MountProps extends RemProviderProps {
	/** The key for the UI component. */
	key: string;
}

/**
 * Mounts the UI component to the Roblox game client.
 *
 * @param props - The options for mounting the UI component.
 * @param props.baseRem - The base rem value for the UI component.
 * @param props.key - The key for the UI component.
 * @param props.remOverride - The rem override value for the UI component.
 * @param props.children - The children elements of the UI component.
 * @returns The root object representing the mounted UI component.
 */
export function mount({ baseRem, key, remOverride, children }: MountProps): Root {
	const root = createRoot(new Instance("Folder"));
	root.render(
		<StrictMode>
			<RemProvider key="rem-provider" baseRem={baseRem} remOverride={remOverride}>
				<ReflexProvider key="reflex-provider" producer={store}>
					{createPortal(children, PlayerGui, key)}
				</ReflexProvider>
			</RemProvider>
		</StrictMode>,
	);

	return root;
}


import type { FunctionComponent } from "@rbxts/react";
import React, { createElement } from "@rbxts/react";

export function profiler<P extends object>(
	name: string,
	render: FunctionComponent<P>,
): FunctionComponent<P> {
	return (props: P) => {
		debug.profilebegin(name);
		const result = render(props);
		debug.profileend();
		return result;
	};
}

function getName(callback: Callback): string {
	const [name = "Component"] = debug.info(callback, "n");
	return name;
}

/**
 * Profiles all components created using React.createElement. This function
 * wraps each component with a profiler to measure its performance. Typically,
 * this function is used for debugging and performance optimization, and should
 * not be used in production.
 */
export function profileAllComponents(): void {
	if (!_G.__PROFILE__) {
		return;
	}

	const profiledComponents = new Map<FunctionComponent, FunctionComponent>();

	React.createElement = ((...args: Parameters<typeof React.createElement>) => {
		const [component] = args;

		if (!typeIs(component, "function")) {
			return createElement(...args);
		}

		let profiledComponent = profiledComponents.get(component);

		if (!profiledComponent) {
			profiledComponent = profiler(getName(component), component);
			profiledComponents.set(component, profiledComponent);
		}

		args[0] = profiledComponent as never;

		return createElement(...args);
	}) as Callback;
}


import { useLatest } from "@rbxts/pretty-react-hooks";

function isEqualOrUndefined(previousValue: unknown, value: unknown): boolean {
	return previousValue === value || value === undefined;
}

/**
 * Returns the latest defined value.
 *
 * @param value - The value to use.
 * @param initialValue - The initial value to use if `value` is `undefined`.
 * @returns A non-nullable value.
 */
export function useDefined<T>(value: T | undefined, initialValue: T): T;
export function useDefined<T>(value: T, initialValue?: T): T;
export function useDefined<T>(value: T, initialValue?: T): T | undefined {
	return useLatest(value, isEqualOrUndefined).current ?? initialValue;
}


import { useViewport } from "@rbxts/pretty-react-hooks";
import { useEffect, useState } from "@rbxts/react";

type Orientation = "landscape" | "portrait";

export function useOrientation(): Orientation {
	const [orientation, setOrientation] = useState<Orientation>("landscape");

	const viewportBinding = useViewport((viewport) => {
		setOrientation(viewport.Y > viewport.X ? "portrait" : "landscape");
	});

	useEffect(() => {
		setOrientation(
			viewportBinding
				.map((viewport) => (viewport.Y > viewport.X ? "portrait" : "landscape"))
				.getValue(),
		);
	}, [viewportBinding]);

	return orientation;
}


import { useEventListener } from "@rbxts/pretty-react-hooks";
import { useState } from "@rbxts/react";
import { Players } from "@rbxts/services";

import { LocalPlayer } from "client/constants";

/**
 * Returns whether the local player has a premium membership or not. This will
 * update when the player's membership changes.
 *
 * @returns True if the local player has a premium membership.
 */
export function usePremium(): boolean {
	const [isPremium, setIsPremium] = useState(
		LocalPlayer.MembershipType === Enum.MembershipType.Premium,
	);

	useEventListener(Players.PlayerMembershipChanged, (player) => {
		if (player === LocalPlayer) {
			setIsPremium(player.MembershipType === Enum.MembershipType.Premium);
		}
	});

	return isPremium;
}


import { useCallback, useContext } from "@rbxts/react";

import { DEFAULT_REM, RemContext } from "../providers/rem-provider";

export interface RemOptions {
	maximum?: number;
	minimum?: number;
}

interface RemFunction {
	(value: number, mode?: RemScaleMode): number;
	(value: UDim2, mode?: RemScaleMode): UDim2;
	(value: UDim, mode?: RemScaleMode): UDim;
	(value: Vector2, mode?: RemScaleMode): Vector2;
}

type RemScaleMode = "pixel" | "unit";

const scaleFunctions = {
	number: (value: number, rem: number): number => value * rem,

	UDim: (value: UDim, rem: number): UDim => new UDim(value.Scale, value.Offset * rem),

	UDim2: (value: UDim2, rem: number): UDim2 => {
		return new UDim2(value.X.Scale, value.X.Offset * rem, value.Y.Scale, value.Y.Offset * rem);
	},

	Vector2: (value: Vector2, rem: number): Vector2 => new Vector2(value.X * rem, value.Y * rem),
};

function useRemContext({ maximum = math.huge, minimum = 0 }: RemOptions = {}): number {
	const rem = useContext(RemContext);
	return math.clamp(rem, minimum, maximum);
}

/**
 * Custom hook that provides a function for scaling values based on the rem
 * unit.
 *
 * @param options - Optional configuration options for the rem function.
 * @returns A function that can be used to scale values based on the rem unit.
 */
export function useRem(options?: RemOptions): RemFunction {
	const rem = useRemContext(options);

	function remFunction<T>(value: T, mode: RemScaleMode = "unit"): T {
		const scale = scaleFunctions[typeOf(value) as never] as
			| ((value: T, rem: number) => T)
			| undefined;
		if (scale) {
			return mode === "unit" ? scale(value, rem) : scale(value, rem / DEFAULT_REM);
		}

		return value;
	}

	return useCallback(remFunction, [rem]);
}


import type { UseSelectorHook } from "@rbxts/react-reflex";
import { useSelector } from "@rbxts/react-reflex";

import type { RootStore } from "client/store";

export const useRootSelector: UseSelectorHook<RootStore> = useSelector;


import type { UseProducerHook } from "@rbxts/react-reflex";
import { useProducer } from "@rbxts/react-reflex";

import type { RootStore } from "client/store";

export const useStore: UseProducerHook<RootStore> = useProducer;


import { useSelector } from "@rbxts/react-reflex";

import { selectPlayerTheme } from "client/store/theme";

import type { Theme } from "../themes/theme";

export function useTheme(): Theme {
	return useSelector(selectPlayerTheme);
}


import { map, useCamera, useDebounceState, useEventListener } from "@rbxts/pretty-react-hooks";
import React, { createContext, useCallback, useEffect } from "@rbxts/react";

export interface RemProviderProps extends React.PropsWithChildren {
	baseRem?: number | undefined;
	maximumRem?: number | undefined;
	minimumRem?: number | undefined;
	remOverride?: number | undefined;
}

export const DEFAULT_REM = 16;
export const MIN_REM = 8;

const BASE_RESOLUTION_WIDTH = 1920;
const BASE_RESOLUTION_HEIGHT = 1020;
const BASE_RESOLUTION = new Vector2(BASE_RESOLUTION_WIDTH, BASE_RESOLUTION_HEIGHT);

const ASPECT_RATIO_WIDTH = 19;
const ASPECT_RATIO_HEIGHT = 9;
const MAX_ASPECT_RATIO = ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT;

// Wide screens should not scale beyond iPhone aspect ratio
const SLOW_DOWN_SCALE = 0.25;

export const RemContext = createContext<number>(DEFAULT_REM);

RemContext.displayName = "RemContext";

export function RemProvider({
	baseRem = DEFAULT_REM,
	maximumRem = math.huge,
	minimumRem = MIN_REM,
	remOverride,
	children,
}: Readonly<RemProviderProps>): React.ReactNode {
	const camera = useCamera();
	const [rem, setRem] = useDebounceState(baseRem, { leading: true, wait: 0.2 });

	const update = useCallback((): number | void => {
		const viewport = camera.ViewportSize;

		if (remOverride !== undefined) {
			return remOverride;
		}

		const resolution = new Vector2(
			math.min(viewport.X, viewport.Y * MAX_ASPECT_RATIO),
			viewport.Y,
		);
		const scale = resolution.Magnitude / BASE_RESOLUTION.Magnitude;
		const isDesktop = resolution.X > resolution.Y || scale >= 1;

		// Portrait mode should downscale slower than landscape
		const factor = isDesktop ? scale : map(scale, 0, 1, SLOW_DOWN_SCALE, 1);

		setRem(math.clamp(math.round(baseRem * factor), minimumRem, maximumRem));
	}, [baseRem, camera, maximumRem, minimumRem, remOverride, setRem]);

	useEventListener(camera.GetPropertyChangedSignal("ViewportSize"), update);

	useEffect(() => {
		update();
	}, [baseRem, minimumRem, maximumRem, remOverride, update]);

	return <RemContext.Provider value={rem}>{children}</RemContext.Provider>;
}


import type { ProfilerOnRenderCallback } from "@rbxts/react";

import { $NODE_ENV } from "rbxts-transform-env";

export function reactConfig(): void {
	if ($NODE_ENV !== "development") {
		return;
	}

	_G.__DEV__ = true;
	_G.__PROFILE__ = false;

	// Avoid implicit React import before setting the __DEV__ flag
	void import("client/ui/functions/profiler").then(({ profileAllComponents }) => {
		profileAllComponents();
	});
}

export async function createApp(): Promise<void> {
	// Avoid implicit React import before setting the __DEV__ flag
	// eslint-disable-next-line sonar/no-dead-store, flawless/naming-convention -- False positive
	const React = await import("@rbxts/react");
	const { App } = await import("client/ui/app");
	const { mount } = await import("client/ui/functions");

	mount({ key: "app", children: <App /> });
}

export const onRenderProfiler: ProfilerOnRenderCallback = (
	...args: Parameters<ProfilerOnRenderCallback>
) => {
	const [id, phase, actualDuration, baseDuration, startTime, commitTime, interactions] = args;
	print(
		`id: ${id}, phase: ${phase}, actualDuration: ${actualDuration}, baseDuration: ${baseDuration}, startTime: ${startTime}, commitTime: ${commitTime}, interactions: ${interactions}`,
	);
};


import React from "@rbxts/react";

import { GAME_NAME } from "shared/constants";

interface Storybook {
	name: string;
	react?: typeof React;
	storyRoots: Array<Instance | undefined>;
}

export = identity<Storybook>({
	name: GAME_NAME,
	react: React,
	storyRoots: [script.Parent],
});


import type { OnInit, OnStart } from "@flamework/core";
import { Service } from "@flamework/core";
import type { Logger } from "@rbxts/log";
import { Error, Object } from "@rbxts/luau-polyfill";
import Signal from "@rbxts/rbx-better-signal";
import { MarketplaceService, Players } from "@rbxts/services";
import Sift from "@rbxts/sift";

import type { PlayerData } from "shared/store/persistent";
import { selectPlayerData, selectPlayerMtx } from "shared/store/persistent";
import { noYield } from "shared/util/no-yield";
import { GamePass, Product } from "types/enum/mtx";

import { events } from "./network";
import type { PlayerEntity } from "./player/player-entity";
import type { OnPlayerJoin, PlayerService } from "./player/player-service";
import { store } from "./store";

const NETWORK_RETRY_DELAY = 2;
const NETWORK_RETRY_ATTEMPTS = 10;

type ProductInfo = DeveloperProductInfo | GamePassProductInfo;

/**
 * A service for managing game passes and processing receipts.
 *
 * ```
 * this.mtxService.gamePassStatusChanged.Connect((playerEntity, gamePassId, isActive) => {
 *     if (!gamePassId === GamePasses.Example || !isActive) {
 *         return;
 *     }
 *
 *     // Do something with the game pass owned
 *     ...
 * });
 *
 * for (const pass of gamePasses) {
 *     if (this.mtxService.isGamePassActive(playerEntity, gamePassId)) {
 *         // Do something with the game pass owned
 *         ...
 *     }
 * }
 * ```
 */
@Service({})
export class MtxService implements OnInit, OnStart, OnPlayerJoin {
	private readonly productHandlers = new Map<
		Product,
		(playerEntity: PlayerEntity, productId: Product) => boolean
	>();
	private readonly productInfoCache = new Map<number, ProductInfo>();
	private readonly purchaseIdLog = 50;

	public readonly gamePassStatusChanged = new Signal<
		(playerEntity: PlayerEntity, gamePassId: GamePass, isActive: boolean) => void
	>();

	constructor(
		private readonly logger: Logger,
		private readonly playerService: PlayerService,
	) {}

	/** @ignore */
	public onInit(): void {
		MarketplaceService.PromptGamePassPurchaseFinished.Connect(
			this.playerService.withPlayerEntity((playerEntity, gamePassId, purchased) => {
				if (!purchased) {
					return;
				}

				this.grantGamePass(playerEntity, gamePassId);
			}),
		);

		MarketplaceService.ProcessReceipt = (...args): Enum.ProductPurchaseDecision => {
			const result = this.processReceipt(...args).expect();
			this.logger.Info(`ProcessReceipt result: ${result}`);
			return result;
		};
	}

	/** @ignore */
	public onStart(): void {
		events.mtx.setGamePassActive.connect(
			this.playerService.withPlayerEntity((playerEntity, gamePassId, active) => {
				this.setGamePassActive(playerEntity, gamePassId, active).catch((err) => {
					this.logger.Error(
						`Failed to set game pass ${gamePassId} active for ${playerEntity.userId}: ${err}`,
					);
				});
			}),
		);
	}

	/** @ignore */
	public onPlayerJoin(playerEntity: PlayerEntity): void {
		const { userId } = playerEntity;

		const gamePasses = store.getState(selectPlayerMtx(userId))?.gamePasses;
		if (gamePasses === undefined) {
			return;
		}

		const unownedGamePasses = Object.values(GamePass).filter(
			(gamePassId) => !gamePasses.has(gamePassId),
		);
		for (const gamePassId of unownedGamePasses) {
			this.checkForGamePassOwned(playerEntity, gamePassId)
				.then((owned) => {
					if (!owned) {
						return;
					}

					this.grantGamePass(playerEntity, tonumber(gamePassId));
				})
				.catch((err) => {
					this.logger.Warn(`Error checking game pass ${gamePassId}: ${err}`);
				});
		}

		for (const [id, gamePassData] of gamePasses) {
			this.notifyProductActive(playerEntity, id, gamePassData.active);
		}
	}

	/**
	 * Registers a handler for a specific product. The handler will be called
	 * when a player purchases the developer product.
	 *
	 * The handler should return true if the product was successfully processed,
	 * or false if there was an error. The handler will also return false if
	 * there is an error processing the product.
	 *
	 * @param productId - The ID of the product to register the handler for.
	 * @param handler - The callback handler to call when the product is
	 *   purchased. The handler should return true if the product was
	 *   successfully processed, or false if there was an error.
	 * @note Handlers should be registered before the player purchases the
	 *  product, and should never yield.
	 */
	public registerProductHandler(
		productId: Product,
		handler: (playerEntity: PlayerEntity, productId: Product) => boolean,
	): void {
		if (this.productHandlers.has(productId)) {
			this.logger.Error(`Handler already registered for product ${productId}`);
			return;
		}

		this.logger.Debug(`Registered handler for product ${productId}`);
		this.productHandlers.set(productId, handler);
	}

	/**
	 * Retrieves the product information for a given product or game pass.
	 *
	 * @param infoType - The type of information to retrieve ("Product" or
	 *   "GamePass").
	 * @param productId - The ID of the product or game pass.
	 * @returns A Promise that resolves to the product information, or undefined
	 *   if the information is not available.
	 */
	public async getProductInfo(
		infoType: Enum.InfoType,
		productId: number,
	): Promise<ProductInfo | undefined> {
		if (this.productInfoCache.has(productId)) {
			return this.productInfoCache.get(productId);
		}

		const productInfo = await Promise.retryWithDelay(
			async () => MarketplaceService.GetProductInfo(productId, infoType) as ProductInfo,
			NETWORK_RETRY_ATTEMPTS,
			NETWORK_RETRY_DELAY,
		).catch(() => {
			this.logger.Warn(`Failed to get price for product ${productId}`);
		});

		if (productInfo === undefined) {
			return undefined;
		}

		this.productInfoCache.set(productId, productInfo);

		return productInfo;
	}

	/**
	 * Checks if a game pass is active for a specific player. This method will
	 * return false if the game pass is not owned by the player.
	 *
	 * @param playerEntity - The player entity for whom to check the game pass.
	 * @param gamePassId - The ID of the game pass to check.
	 * @returns A boolean indicating whether the game pass is active or not.
	 */
	public isGamePassActive({ userId }: PlayerEntity, gamePassId: GamePass): boolean {
		return store.getState(selectPlayerMtx(userId))?.gamePasses.get(gamePassId)?.active ?? false;
	}

	private async checkForGamePassOwned(
		{ player, userId }: PlayerEntity,
		gamePassId: GamePass,
	): Promise<boolean> {
		// Ensure game passId is a valid game passes for our game
		if (!Object.values(GamePass).includes(gamePassId)) {
			throw new Error(`Invalid game pass id ${gamePassId}`);
		}

		const isGamePassOwned = store.getState(selectPlayerMtx(userId))?.gamePasses.has(gamePassId);
		if (isGamePassOwned === true) {
			return true;
		}

		return MarketplaceService.UserOwnsGamePassAsync(player.UserId, tonumber(gamePassId));
	}

	private grantProduct(
		playerEntity: PlayerEntity,
		productId: number,
		currencySpent: number,
	): boolean {
		const { userId } = playerEntity;

		const product = tostring(productId) as Product;

		// Ensure productId is a valid product for our game
		if (!Object.values(Product).includes(product)) {
			this.logger.Warn(
				`Player ${userId} attempted to purchased invalid product ${productId}`,
			);
			return false;
		}

		const handler = this.productHandlers.get(product);
		if (!handler) {
			this.logger.Fatal(`No handler for product ${product}`);
			return false;
		}

		const [success, result] = pcall(() => noYield(handler, playerEntity, product));
		if (!success || !result) {
			this.logger.Error(`Failed to process product ${product}`);
			return false;
		}

		this.logger.Info(`Player ${userId} purchased developer product ${productId}`);
		store.purchaseDeveloperProduct(userId, product, currencySpent);
		return true;
	}

	private async setGamePassActive(
		playerEntity: PlayerEntity,
		gamePassId: GamePass,
		active: boolean,
	): Promise<void> {
		await this.checkForGamePassOwned(playerEntity, gamePassId).then((owned) => {
			const { userId } = playerEntity;
			if (!owned) {
				this.logger.Warn(
					`Player ${userId} tried to activate not game pass ${gamePassId} that they do not own.`,
				);

				return;
			}

			store.setGamePassActive(userId, gamePassId, active);
			this.notifyProductActive(playerEntity, gamePassId, active);
		});
	}

	private notifyProductActive(
		playerEntity: PlayerEntity,
		productId: GamePass,
		isActive: boolean,
	): void {
		this.gamePassStatusChanged.Fire(playerEntity, productId, isActive);
	}

	private grantGamePass(playerEntity: PlayerEntity, gamePassId: number): void {
		const { userId } = playerEntity;
		const gamePass = tostring(gamePassId) as GamePass;

		// Ensure game passId is a valid game passes for our game
		if (!Object.values(GamePass).includes(gamePass)) {
			this.logger.Warn(
				`Player ${userId} attempted to purchased invalid game pass ${gamePassId}`,
			);
			return;
		}

		this.logger.Info(`Player ${userId} purchased game pass ${gamePassId}`);
		store.purchaseGamePass(userId, gamePass);
		this.notifyProductActive(playerEntity, gamePass, true);
	}

	private async processReceipt(receiptInfo: ReceiptInfo): Promise<Enum.ProductPurchaseDecision> {
		this.logger.Info(
			`Processing receipt ${receiptInfo.PurchaseId} for ${receiptInfo.PlayerId}`,
		);

		const player = Players.GetPlayerByUserId(receiptInfo.PlayerId);
		if (!player) {
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		const playerEntity = await this.playerService.getPlayerEntityAsync(player);
		if (!playerEntity) {
			this.logger.Error(`No entity for player ${player.UserId}, cannot process receipt`);
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		return this.purchaseIdCheck(playerEntity, receiptInfo);
	}

	private async purchaseIdCheck(
		playerEntity: PlayerEntity,
		{ CurrencySpent, ProductId, PurchaseId }: ReceiptInfo,
	): Promise<Enum.ProductPurchaseDecision> {
		const { document, userId } = playerEntity;

		if (document.read().mtx.receiptHistory.includes(PurchaseId)) {
			const [success] = document.save().await();
			if (!success) {
				return Enum.ProductPurchaseDecision.NotProcessedYet;
			}

			return Enum.ProductPurchaseDecision.PurchaseGranted;
		}

		const data = store.getState(selectPlayerData(userId));
		if (!data) {
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		if (!this.grantProduct(playerEntity, ProductId, CurrencySpent)) {
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		this.updateReceiptHistory(userId, data, PurchaseId);

		const [success] = document.save().await();
		if (!success) {
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		return Enum.ProductPurchaseDecision.PurchaseGranted;
	}

	private updateReceiptHistory(userId: string, data: PlayerData, purchaseId: string): void {
		const { receiptHistory } = data.mtx;

		let updatedReceiptHistory = Sift.Array.push(receiptHistory, purchaseId);
		if (updatedReceiptHistory.size() > this.purchaseIdLog) {
			updatedReceiptHistory = Sift.Array.shift(
				updatedReceiptHistory,
				updatedReceiptHistory.size() - this.purchaseIdLog + 1,
			);
		}

		store.updateReceiptHistory(userId, updatedReceiptHistory);
	}
}