import { Flamework, OnInit, OnStart, Service } from "@flamework/core";
import type { Collection, CollectionOptions, CollectionSchema, LapisConfig } from "@rbxts/lapis";
import { DataStoreService, ReplicatedStorage, RunService } from "@rbxts/services";
import DataStoreServiceMock from "server/mock-datastore";
import { DataStoreBrokenDataScope, DataStoreName } from "shared/schemas/data-store-name";
import { PlayerDataSchema } from "shared/schemas/player-data";
import { PlayerData } from "shared/schemas/player-data-types";
import { IS_STUDIO } from "shared/utilities/constants";

function FindLapisModule() {
	let result: ModuleScript | undefined;
	let found: Instance = ReplicatedStorage;

	while (!result) {
		found = found.FindFirstChild("lapis", true)!;
		if (!found) {
			throw "lapis module not found";
		}

		if (found.IsA("ModuleScript")) {
			result = found;
			break;
		}
	}

	return result;
}

@Service({})
export class DataStoreWrapperService implements OnStart, OnInit {
	private collection!: Collection<PlayerData["Save"]>;
	private storeForBrokenData?: DataStore;

	private IsEnableDataStoreAPI() {
		const [success] = pcall(() => DataStoreService.GetDataStore("__PS").GetAsync("-1"));
		return RunService.IsRunning() && success;
	}

	private initDataStoreForBrokenData() {
		const [success, result] = pcall(() => DataStoreService.GetDataStore(DataStoreName, DataStoreBrokenDataScope));
		if (success) {
			this.storeForBrokenData = result;
		}
	}

	public GetCollection() {
		return this.collection;
	}

	public onInit() {
		this.initDataStoreForBrokenData();

		let lapis: {
			createCollection: <T extends CollectionSchema, R extends boolean = true>(
				name: string,
				options: CollectionOptions<T, R>,
			) => Collection<T, R>;
			setConfig: (config: Partial<LapisConfig>) => void;
		};

		if (IS_STUDIO) {
			const module = FindLapisModule();
			const internalModule = module.FindFirstChild("Internal") as ModuleScript;

			// eslint-disable-next-line @typescript-eslint/no-require-imports
			const internalApi = require(internalModule) as {
				new: (value: boolean) => typeof import("@rbxts/lapis");
			};
			const internal = internalApi.new(false);
			lapis = {
				setConfig: ((config: unknown) => {
					internal.setConfig(config as never);
				}) as never,
				createCollection: ((name: unknown, config: unknown) => {
					return internal.createCollection(name as never, config as never);
				}) as never,
			};
		} else {
			lapis = import("@rbxts/lapis").expect();
		}

		lapis.setConfig({
			dataStoreService: this.IsEnableDataStoreAPI() ? DataStoreService : new DataStoreServiceMock(),
		});

		this.collection = lapis.createCollection(DataStoreName, {
			defaultData: PlayerDataSchema["Save"],
			validate: Flamework.createGuard(),
		});
	}

	public async SaveBrokenData(key: number, data: {}) {
		assert(this.storeForBrokenData, "Failed to save broken data, no data store found");
		data["Date" as never] = DateTime.now().FormatUniversalTime("LLL", "en-us") as never;
		this.storeForBrokenData.SetAsync(tostring(key), data);
	}

	public async LoadProfile(player: Player | number) {
		const userId = typeIs(player, "number") ? player : player.UserId;
		const document = await this.collection.load(tostring(userId), [userId]);

		if (typeIs(player, "Instance") && player.Parent === undefined) {
			document.close().catch(warn);
			error(`Failed to load profile for ${player.Name}`);
		}

		return document;
	}

	onStart() {}
}


/* eslint-disable @typescript-eslint/no-explicit-any */
import { Components } from "@flamework/components";
import { Flamework, Modding, OnStart, Service } from "@flamework/core";
import { Logger } from "@rbxts/log";
import Signal from "@rbxts/rbx-better-signal";
import { Players, StarterGui } from "@rbxts/services";
import { PlayerComponent } from "server/components/player-component";
import { Events, Functions } from "server/network";
import { WaitForEndTestMode } from "server/utility-for-tests/test-mode";
import { ActionConstructors } from "shared/decorators/constructor/action-decorator";
import { InjectType } from "shared/decorators/field/Inject-type";
import { Instantiate } from "shared/flamework-utils";
import { ActionSerializer } from "shared/network";
import { IS_STUDIO } from "shared/utilities/constants";
import { FailedProcessAction } from "shared/utilities/function-utilities";
import {
	ForeachStartedPlayers,
	GetPlayerComponent,
	PromisePlayerDisconnected,
	WaitPlayerComponent,
} from "shared/utilities/player";
import { IAction } from "types/IAction";
import { OnPlayerJoined, OnPlayerLeaved } from "types/player/player-events";
import("@rbxts/shared-components-flamework");

const validateActionData = Flamework.createGuard<IAction>();

@Service({})
export class PlayerService implements OnStart {
	@InjectType
	private components!: Components;

	@InjectType
	private logger!: Logger;

	private players = new Map<string, PlayerComponent>();
	private enableSignals?: Signal;
	private isEnabled = true;

	public readonly Events = Events;
	public readonly Functions = Functions;

	public AddPlayer(player: PlayerComponent) {
		this.players.set(tostring(player.UserId), player);
	}

	public RemovePlayer(player: PlayerComponent) {
		this.players.delete(tostring(player.UserId));
	}

	public GetPlayers() {
		return this.players;
	}

	public GetPlayer(userId: number) {
		const player = this.players.get(tostring(userId));
		assert(player, "Player not found");

		return player;
	}

	public TryGetPlayer(userId: number) {
		const player = this.players.get(tostring(userId));
		if (!player) return undefined;

		return player;
	}

	public async onStart() {
		this.clearStarterGUI();

		const processPlayer = async (player: Player) => {
			this.logger.Info(`Player ${player.Name} joined the server`);
			await WaitForEndTestMode();

			const component = this.components.addComponent<PlayerComponent>(player);

			PromisePlayerDisconnected(player).then(() => {
				component.TryDestroy();
			});
		};

		if (!IS_STUDIO) {
			Players.PlayerAdded.Connect(processPlayer);
			Players.GetPlayers().forEach(processPlayer);
		}

		this.connectNetworkFunctions();
		this.handlePlayersJoined();
		this.handlePlayersLeaved();
	}

	public SetEnabled(enabled: boolean) {
		this.isEnabled = enabled;
		if (enabled) {
			this.enableSignals?.Fire();
		}
	}

	private async waitForEnable() {
		if (this.isEnabled) return;
		(this.enableSignals ??= new Signal()).Wait();
	}

	private clearStarterGUI() {
		StarterGui.GetChildren().forEach((instance) => instance.Destroy());
	}

	private handlePlayersJoined() {
		const listeners = new Set<OnPlayerJoined>();

		Modding.onListenerAdded<OnPlayerJoined>((object) => listeners.add(object));
		Modding.onListenerRemoved<OnPlayerJoined>((object) => listeners.delete(object));

		PlayerComponent.onAdded((playerComponent) => {
			listeners.forEach((listener) => listener.OnPlayerJoined(playerComponent));
		});

		ForeachStartedPlayers((player) =>
			listeners.forEach((listener) => Promise.try(() => listener.OnPlayerJoined(player))),
		);
	}

	private handlePlayersLeaved() {
		const listeners = new Set<OnPlayerLeaved>();

		Modding.onListenerAdded<OnPlayerLeaved>((object) => listeners.add(object));
		Modding.onListenerRemoved<OnPlayerLeaved>((object) => listeners.delete(object));

		PlayerComponent.onRemoved((playerComponent) => {
			listeners.forEach((listener) => listener.OnPlayerLeaved(playerComponent));
		});
	}

	private connectNetworkFunctions() {
		if (IS_STUDIO) return;

		Functions.DoAction.setCallback(async (player, actionBuffer) => {
			const actionData = ActionSerializer.deserialize(actionBuffer.buffer, actionBuffer.blobs) as IAction;
			const playerComponent = GetPlayerComponent(player);
			const actionConstructor = ActionConstructors.get(actionData.Name);

			if (!actionConstructor) return FailedProcessAction("Action not found");
			if (!validateActionData(actionData)) return FailedProcessAction("Invalid action data");
			if (!playerComponent || !playerComponent.IsStatus("Started"))
				return FailedProcessAction("Player not initialized");

			const action = Instantiate(actionConstructor, [actionData.Data as never]);
			if (!action.validate()) return FailedProcessAction("Invalid action data");

			action.SetPlayerComponent(playerComponent);

			try {
				const result = action.DoAction();
				return Promise.is(result) ? await result : result;
			} catch (error) {
				warn(`[PlayerService: ${player.Name}] ${error} \n ${debug.traceback()}`);
			}

			return FailedProcessAction("Failed to process action");
		});

		Events.StartReplication.connect(async (player) => {
			await this.waitForEnable();

			const playerComponent = await WaitPlayerComponent(player);
			if (playerComponent.IsStatus("Started")) return;

			await playerComponent.WaitForStatus("WaitForStarting");
			playerComponent.StartReplication();
		});
	}
}


import { BaseComponent } from "@flamework/components";
import { Dependency, Flamework, Modding, OnInit, OnStart, Service } from "@flamework/core";
import Log, { Logger } from "@rbxts/log";
import { RunService } from "@rbxts/services";
import { PlayerComponent } from "server/components/player-component";
import { StartFlameworkUtils } from "shared/flamework-utils";
import { IS_STUDIO } from "shared/utilities/constants";
import { CreateIdGenerator } from "shared/utilities/function-utilities";
import { GetIdentifier } from "shared/utilities/object-utilities";
import { SetupLogger } from "shared/utilities/setup-logger";

let isStartingFlamework = false;

if (IS_STUDIO) {
	Modding.registerDependency<Logger>((ctor) => {
		SetupLogger();
		return Log.ForContext(ctor);
	});
}

const nextId = CreateIdGenerator(false);

export function createComponentStub<T extends new (...args: never[]) => BaseComponent>(
	constructor: T,
	instance: object,
	...dependencies: ConstructorParameters<T>
) {
	const [component, construct] = Modding.createDeferredDependency(constructor, { handle: (_, i) => dependencies[i] });
	component.instance = instance as never;
	construct();
	return component as InstanceType<T>;
}

const TryStartFlamework = () => {
	if (isStartingFlamework) return;
	isStartingFlamework = true;

	Flamework.addPaths("src/server");
	Flamework.addPaths("src/shared");

	StartFlameworkUtils();

	if (!RunService.IsRunMode()) {
		Flamework.ignite();
	}
	LoadServices();
};

export const LoadServices = () => {
	if (!RunService.IsRunMode()) {
		Modding.getDecorators<typeof Service>().forEach((value) => {
			pcall(() => {
				const instance = Dependency(GetIdentifier(value.object) as never) as OnStart & OnInit;
				if ("onStart" in instance) {
					instance.onStart();
				}

				if ("onInit" in instance) {
					instance.onInit();
				}
			});
		});
	}
};

const testPlayers = new Set<PlayerComponent>();

export function ClearAllTestPlayers() {
	testPlayers.forEach((player) => player.destroy());
	testPlayers.clear();
}

export async function CreateTestPlayer() {
	TryStartFlamework();
	const id = nextId();
	const player = createComponentStub(PlayerComponent, {
		Name: `TestPlayer${id}`,
		UserId: id,
	});

	testPlayers.add(player);
	await player.onStart();
	player.StartReplication();

	return player;
}


import Signal from "@rbxts/rbx-better-signal";
import { IS_STUDIO } from "shared/utilities/constants";

let isTestMode = IS_STUDIO;
const signal = new Signal();

export function IsTestMode() {
	return isTestMode;
}

export function SetTestMode(value: boolean) {
	isTestMode = value;

	if (!value) {
		signal.Fire();
	}
}

export async function WaitForEndTestMode() {
	if (!isTestMode) return;
	signal.Wait();
}


import Object from "@rbxts/object-utils";
import { PhysicsService, RunService } from "@rbxts/services";

export enum Collisions {
	Player = "Player",
}

if (RunService.IsServer()) {
	Object.keys(Collisions).forEach((key) => {
		PhysicsService.RegisterCollisionGroup(key);
	});

	PhysicsService.CollisionGroupSetCollidable(Collisions.Player, Collisions.Player, false);
}


import { BaseComponent, Component } from "@flamework/components";
import { CharacterRigR15 } from "@rbxts/character-promise";

@Component()
export class Character extends BaseComponent<{}, CharacterRigR15> {}


/* eslint-disable @typescript-eslint/no-explicit-any */
import { Constructor } from "@flamework/core/out/utility";
import Object from "@rbxts/object-utils";
import { $keys } from "rbxts-transformer-keys";
import { Configs, LuaConfigs } from "shared/game-data/configs";

export interface BaseConfig<T = object> {
	Constructors: Constructor<T>[];
	Instances: T[];
	MappedConstructors: Map<string, Constructor<T>>;
	MappedInstances: Map<string, T>;
}

export interface ConfigData {
	BaseClass: string;
}

type AbstractConstructorType<T> = T extends abstract new (...args: never[]) => infer R ? R : never;

export const TypedConfigs = Configs as unknown as Record<string, ConfigData & BaseConfig>;

Object.keys(Configs as never as typeof TypedConfigs).forEach((key) => {
	TypedConfigs[key].Constructors = [];
	TypedConfigs[key].Instances = [];
	TypedConfigs[key].MappedConstructors = new Map();
	TypedConfigs[key].MappedInstances = new Map();
});

$keys<LuaConfigs>().forEach((key) => {
	TypedConfigs[key] = {} as never;
	TypedConfigs[key].Instances = [];
	TypedConfigs[key].MappedInstances = new Map();
});

const GetBaseConstructorFromConfig = (data: { UniqueKey: "__TSConfig__"; Constructor: Constructor }) => {
	if ("UniqueKey" in data && data.UniqueKey === "__TSConfig__") {
		return data.Constructor;
	}

	return data;
};

const registerConfig = (ctor: Constructor) => {
	const config = new ctor();
	const key = Object.keys(Configs as never as typeof TypedConfigs).find(
		(value) => config instanceof (GetBaseConstructorFromConfig(Configs[value as never] as never) as Constructor),
	);

	assert(key, `Invalid config for ${ctor}`);
	assert(!TypedConfigs[key].MappedConstructors.has(`${ctor}`), `Duplicate config for ${ctor}`);

	TypedConfigs[key].Constructors.push(ctor);
	TypedConfigs[key].Instances.push(config);
	TypedConfigs[key].MappedConstructors.set(`${ctor}`, ctor);
	TypedConfigs[key].MappedInstances.set(`${ctor}`, config);

	const configData = Configs[key as never] as { UniqueKey: "__TSConfig__"; OnRegistered: Callback };
	if ("UniqueKey" in configData && configData.UniqueKey === "__TSConfig__") {
		configData.OnRegistered?.(ctor as never, config as never, TypedConfigs[key] as never);
	}
};

/** @metadata reflect identifier */
export const ConfigDecorator = (ctor: Constructor) => registerConfig(ctor);

export const GetConfigData = <
	T extends keyof typeof Configs,
	K extends T extends keyof LuaConfigs
		? keyof Omit<BaseConfig, "MappedConstructors" | "Constructors">
		: keyof BaseConfig,
>(
	configType: T,
	key: K,
) =>
	TypedConfigs[configType][key] as unknown as BaseConfig<
		(typeof Configs)[T] extends { Interface: infer R }
			? R
			: (typeof Configs)[T] extends { Constructor: infer C }
				? AbstractConstructorType<C>
				: T extends keyof LuaConfigs
					? (typeof Configs)[T]
					: AbstractConstructorType<(typeof Configs)[T]>
	>[K];


import { Reflect } from "@flamework/core";
import { Constructor } from "@flamework/core/out/utility";
import { Draft } from "@rbxts/immut/src/types-external";
import { PlayerData, playerData } from "shared/schemas/player-data-types";

/** @server */
export interface OnSendData {
	/** @hidden */
	OnSendData(data: Draft<playerData>, original: PlayerData): void;
}

/** @server */
export interface OnStartModule {
	/** @hidden */
	OnStartModule(): void;
}

/** @server */
export interface OnDestroyModule {
	/** @hidden */
	OnDestroyModule(): void;
}

export const PlayerModules = new Map<string, Constructor>();

/** @metadata reflect identifier flamework:implements */
export const PlayerModule = (
	config: {
		loadOrder?: number;
		IsDisableInTestMode?: boolean;
	} = {},
) => {
	return (object: Constructor) => {
		PlayerModules.set(`${object}`, object);
		Reflect.defineMetadata(object, "playerModule:config", config);
		Reflect.defineMetadata(object, "playerModule:loadOrder", config.loadOrder);
	};
};


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

export function OnlyClient(object: object, propertyName: string, description: TypedPropertyDescriptor<Callback>) {
	const method = description.value;

	description.value = function (this, ...args: unknown[]) {
		assert(RunService.IsClient(), `Method ${propertyName} can't be casted on server.`);
		return method(this, ...args);
	};

	return description;
}


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

export function OnlyServer(object: object, propertyName: string, description: TypedPropertyDescriptor<Callback>) {
	const method = description.value;

	description.value = function (this, ...args: unknown[]) {
		assert(RunService.IsServer(), `Method ${propertyName} can't be casted on client.`);
		return method(this, ...args);
	};

	return description;
}


import { Modding } from "@flamework/core";
import { Constructor } from "@flamework/core/out/utility";
import { RunService } from "@rbxts/services";
import { DependenciesContainer } from "./utilities/dependencies-container";
import { Event } from "./utilities/event";
import { CreateIdGenerator } from "./utilities/function-utilities";
import { GetIdentifier } from "./utilities/object-utilities";
import Signal from "@rbxts/rbx-better-signal";

export abstract class BaseEffect {
	public onStopped = new Event();
	protected isSingled = false;
	private updateConnection?: RBXScriptConnection;

	constructor(protected time?: number) {}

	public IsSingle() {
		return this.isSingled;
	}

	public AddTime(time: number) {
		if (this.time === undefined) return;
		this.time += time;
	}

	/**
	 * If this effect is applied to a character multiple times, this function is called when the existing effect is stacked.
	 * Returning false will cancel the stacking of the effect.
	 * @param otherEffect The existing effect of the same type.
	 * @returns Whether the current effect should be destroyed.
	 */
	public OnStack(otherEffect: this) {
		return true;
	}

	public OnStart() {
		if (this.time) {
			this.updateConnection = RunService.Heartbeat.Connect((dt) => {
				if (this.time! < 0) {
					this.OnStop();
					return;
				}
				this.time! -= dt;
			});
		}

		this.onStart();
	}

	public OnStop() {
		this.updateConnection?.Disconnect();
		this.onStopped.Fire();
		this.onStopped.Destroy();
		this.onStop();
	}

	protected abstract onStart(): void;
	protected abstract onStop(): void;
}

export class EffectController {
	public readonly OnAdded = new Signal<(effect: BaseEffect) => void>();
	private nextId = CreateIdGenerator(true);
	private effects = new Map<string, BaseEffect>();

	constructor(private dependencyContainer?: DependenciesContainer) {}

	/** @metadata macro */
	public AddEffect<T extends BaseEffect>(
		onConstruct?: (effect: T) => void,
		delay?: number,
		id?: Modding.Generic<T, "id">,
	) {
		assert(id);

		const [effectId, effect] = this.GetFirstEffect(id) ?? [];
		if (effectId && effect!.IsSingle()) return () => this.RemoveEffectById(effectId);

		const ctor = Modding.getObjectFromId(id) as Constructor<T>;
		const nextid = this.nextId();
		const instance = this.dependencyContainer
			? this.dependencyContainer.Instantiate(ctor, delay as never)
			: new ctor(delay as never);
		onConstruct?.(instance);

		if (effectId && effect && effect.OnStack(instance)) {
			this.RemoveEffectById(effectId!);
		}

		instance.onStopped.Connect(() => {
			this.effects.delete(nextid);
		});

		this.effects.set(nextid, instance);
		instance.OnStart();
		this.OnAdded.Fire(instance);

		return () => this.RemoveEffectById(nextid);
	}

	/** @metadata macro */
	public AddTempraryEffect<T extends BaseEffect>(
		delay: number,
		onConstruct?: (effect: T) => void,
		id?: Modding.Generic<T, "id">,
	) {
		assert(id);

		return this.AddEffect(onConstruct, delay, id);
	}

	/** @metadata macro */
	public GetEffects<T extends BaseEffect>(id?: Modding.Generic<T, "id">) {
		assert(id);

		const result = new Map<string, BaseEffect>();

		this.effects.forEach((value, id) => {
			const effectId = GetIdentifier(value);
			if (effectId !== id) return;

			result.set(id, value);
		});

		return result;
	}

	/** @metadata macro */
	public HaveEffect<T extends BaseEffect>(id?: Modding.Generic<T, "id">) {
		assert(id);

		for (const [_, effect] of this.effects) {
			const effectId = GetIdentifier(effect);
			if (effectId !== id) return;
			return true;
		}

		return false;
	}

	/** @metadata macro */
	public GetFirstEffect<T extends BaseEffect>(id?: Modding.Generic<T, "id">) {
		assert(id);

		for (const [_, effect] of this.effects) {
			const effectId = GetIdentifier(effect);
			if (effectId !== id) return;
			return [id, effect] as const;
		}

		return;
	}

	/** @metadata macro */
	public RemoveEffects<T extends BaseEffect>(id?: Modding.Generic<T, "id">) {
		const effects = this.GetEffects(id);
		for (const [id, effect] of effects) {
			effect.OnStop();
			this.effects.delete(id);
		}
	}

	public RemoveEffectById(id: string) {
		const effect = this.effects.get(id);
		if (!effect) return;

		effect.OnStop();
		this.effects.delete(id);
	}

	public Destroy() {
		this.effects.forEach((effect) => {
			effect.OnStop();
		});

		this.effects.clear();
	}
}


import { Networking } from "@flamework/networking";
import { Atom } from "@rbxts/charm";
import { createBinarySerializer } from "@rbxts/flamework-binary-serializer";
import { IAction } from "types/IAction";
import { PlayerData } from "./schemas/player-data-types";
import { GameData } from "./schemas/game-data-types";

interface ClientToServerEvents {
	StartReplication: () => void;
}

interface ServerToClientEvents {
	Dispatch(payload: {}): void;
	onCastVFX(effectName: string, ...args: unknown[]): void;
	onStopVFX(effectName: string, ...args: unknown[]): void;
	OnCreateItemActivator(activatorName: string, droppedItem?: Model): void;
	OnChangedStamina: Networking.Unreliable<(currentValue: number, lastReplicatedValue: number) => void>;
}

interface ClientToServerFunctions {
	DoAction(action: { buffer: buffer; blobs: defined[] }): unknown;
}

interface ServerToClientFunctions {}

export const GlobalEvents = Networking.createEvent<ClientToServerEvents, ServerToClientEvents>();
export const GlobalFunctions = Networking.createFunction<ClientToServerFunctions, ServerToClientFunctions>();

export const ClientEvents = GlobalEvents.createClient({});
export const ClientFunctions = GlobalFunctions.createClient({});

export const ActionSerializer = createBinarySerializer<Omit<IAction, "Data"> & { Data: unknown }>();

export type PlayerAtoms = {
	playerData: Atom<PlayerData>;
	gameData: Atom<GameData>;
};


import { Flamework } from "@flamework/core";
import Object from "@rbxts/object-utils";

/* eslint-disable prettier/prettier */
export const IS_ENABLED_MULTIPLE_PLACES = false;
export enum Place {
	Game = "Game",
}

export const SettedPlaces = new Set(Object.keys(Place));
export type PlaceNames = keyof typeof Place;

const id = game.PlaceId;
const placeNameGuard = Flamework.createGuard<Place>();
let cachedPlaceName: Place | undefined;

export const GetPlaceName = (): Place => {
	if (!IS_ENABLED_MULTIPLE_PLACES) return Place.Game;
	if (cachedPlaceName) return cachedPlaceName;

	return cachedPlaceName = Place.Game;
}


/* eslint-disable @typescript-eslint/no-unused-expressions */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Flamework, Modding } from "@flamework/core";
import { ReplicatedStorage } from "@rbxts/services";
import { t } from "@rbxts/t";
import { $keys } from "rbxts-transformer-keys";
import { TypedConfigs } from "shared/decorators/constructor/config-decorator";
import type { Configs, LuaConfigs } from "shared/game-data/configs";
import { Config } from "shared/game-data/constructors/Config";
import { DataStructure } from "shared/game-data/structure";
import { IS_CLIENT, IS_DEV, IS_STUDIO } from "shared/utilities/constants";
import { GetLogger } from "shared/utilities/setup-logger";
import { DeepReadonly } from "types/utility";

const compiledStructure = {} as DeepReadonly<DataStructure>;
export type DataCollection = DataStructure;

export class DataCollectionHandler {
	private logger = GetLogger();
	public static Instance: DataCollectionHandler | undefined;

	public static Init(referense: Instance | DataStructure) {
		this.Instance?.Destroy();
		this.Instance = new DataCollectionHandler();
		this.Instance.Start(referense);

		return () => {
			this.Instance?.Destroy();
			this.Instance = undefined;
		};
	}

	public GetStructure() {
		return compiledStructure;
	}

	private compileStructure(referense: Instance | DataStructure) {
		if (!typeIs(referense, "Instance")) {
			for (const [key, value] of pairs(referense)) {
				compiledStructure[key as never] = value as never;
			}
			return;
		}

		const readInstance = (instance: Instance) => {
			const fullPath = instance.GetFullName();
			const splitted = fullPath.split(".");
			const startPosition = splitted.indexOf(referense.Name);

			let pointer: object = compiledStructure;
			let object: unknown = {};
			object = instance.IsA("ModuleScript") ? require(instance) : object;

			for (const i of $range(startPosition + 1, splitted.size() - 1)) {
				const key = splitted[i];
				const oldPointer = pointer;
				pointer = pointer[key as never] ?? {};

				if (i === splitted.size() - 1) {
					oldPointer[key as never] = object as never;
					continue;
				}

				oldPointer[key as never] = pointer as never;
			}
		};

		const IterateInstance = (instance: Instance) => {
			instance.GetChildren().forEach((child) => {
				readInstance(child);
				if (child.IsA("ModuleScript")) return;
				IterateInstance(child);
			});
		};

		IterateInstance(referense);
	}

	private initLuaConfigs() {
		$keys<LuaConfigs>().forEach((key) => {
			const data = this.GetStructure().Configs[key] as never as { Name: string }[];
			this.logger.Debug(`Loaded ${key} config with ${data.size()} elements`);
			data.forEach((element) => {
				TypedConfigs[key].Instances.push(element);
				TypedConfigs[key].MappedInstances.set(element.Name, element);
			});
		});
	}

	private validateStructure() {
		if (IS_CLIENT) return;
		IS_DEV && print("Current structure: ", compiledStructure);

		const guard = Flamework.createGuard<DataStructure>() as unknown as t.checkWithMessage;
		const [success, errorMessage] = guard(compiledStructure);

		!success && this.logger.Error(errorMessage);
	}

	public Destroy() {
		for (const [key, value] of pairs(TypedConfigs)) {
			value.Constructors = [];
			value.Instances = [];
			value.MappedConstructors = new Map();
			value.MappedInstances = new Map();
		}
	}

	public RegisterConfig<C extends keyof Omit<typeof Configs, keyof Omit<typeof Configs, keyof LuaConfigs>>>(
		config: LuaConfigs[C],
		category: C,
	) {
		TypedConfigs[category].Instances.push(config);
		TypedConfigs[category].MappedInstances.set((config as Record<string, string>).Name, config);

		return () => {
			const configs = TypedConfigs[category];
			configs.Instances.remove(
				configs.Instances.findIndex(
					(instance) => (instance as Config).Name === (config as Record<string, string>).Name,
				),
			);
			configs.MappedInstances.delete((config as Record<string, string>).Name);
		};
	}

	public Start(referense: Instance | DataStructure) {
		this.compileStructure(referense);
		this.validateStructure();
		this.initLuaConfigs();
	}
}

Modding.registerDependency<DataCollectionHandler>(() => DataCollectionHandler.Instance);
Modding.registerDependency<DataCollection>(() => compiledStructure);

export function LoadGameDataFromReplicatedStorage() {
	const referense = ReplicatedStorage.FindFirstChild("GameData");

	if (!referense) {
		warn("Not found GameData instance in ReplicatedStorage. Loading default structure");
		return DataCollectionHandler.Init(import("shared/game-data/mock-data-structure").expect().DataStructureMock);
	}

	return DataCollectionHandler.Init(referense);
}

if (IS_STUDIO) {
	DataCollectionHandler.Init(import("shared/game-data/mock-data-structure").expect().DataStructureMock);
}


import Abbreviator from "@rbxts/abbreviate";

const Suffixs = [
	"K",
	"M",
	"B",
	"T",
	"Qd",
	"Qn",
	"Sx",
	"Sp",
	"O",
	"N",
	"De",
	"Ud",
	"DD",
	"tdD",
	"QnD",
	"SxD",
	"SpD",
	"OcD",
	"NvD",
	"VgN",
	"UvG",
	"DvG",
	"TvG",
	"QtV",
	"QnV",
	"SeV",
	"SpG",
	"OvG",
	"NvG",
	"TgN",
	"UtG",
	"DtG",
	"TsTg",
	"QtTg",
	"QnTg",
	"SsTg",
	"SpTg",
	"OcTg",
	"NoTg",
	"QdDr",
	"UnAg",
	"DuAg",
	"TeAg",
	"QdAg",
	"QnAG",
	"SxAg",
	"SpAg",
	"OcAg",
	"NvAg",
	"CT",
];

const abbreviator = new Abbreviator();
abbreviator.setSetting("decimalPlaces", 2);
abbreviator.setSetting("suffixTable", Suffixs);
abbreviator.setSetting("stripTrailingZeroes", true);

export const AbbreviateNumber = (number: number) => {
	if (number === math.huge) {
		return "∞";
	}

	return abbreviator.numberToString(number, true);
};

export const AbbreviateString = (str: string) => {
	return abbreviator.stringToNumber(str);
};


/* eslint-disable @typescript-eslint/no-explicit-any */
import { Atom, Molecule, atom, observe, subscribe } from "@rbxts/charm";
import { None, produce } from "@rbxts/immut";
import { Draft } from "@rbxts/immut/src/types-external";
import type { PlayerAtom } from "server/components/player-component";
import { PlayerData } from "shared/schemas/player-data-types";
import { OmitFirstParam, VoidCallback } from "types/utility";

type InferAtomState<T> = T extends Atom<infer S> ? S : T extends PlayerAtom ? PlayerData : never;

export const MutateAtom = <C extends Atom<any> | PlayerAtom>(
	atom: C,
	recipe: (
		draft: Draft<InferAtomState<C>>,
	) => typeof draft | void | undefined | (InferAtomState<C> extends undefined ? typeof None : never),
) => {
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	return atom(produce(atom(), recipe)) as C extends Atom<any> ? void : boolean;
};

export interface ReactAtom<S> {
	useSelector(): S;
	useSelector<R>(selector: (state: S) => R): R;
	useAtom: () => Atom<S>;
}

export interface AtomApi<S> {
	Mutate: (
		recipe: (
			draft: Draft<S>,
			original: S,
		) => typeof draft | void | undefined | (S extends undefined ? typeof None : never),
	) => void;
	Subscribe(listener: (state: S, prev: S) => void): () => void;
	Subscribe<R>(selector: (state: S) => R, listener: (state: R, prevState: R) => void): () => void;
	Observe<Item>(
		callback: (state: S) => readonly Item[],
		factory: (item: Item, index: number) => VoidCallback | void,
	): VoidCallback;

	Destroy: () => void;
}

export type WrappedAtom<S, A = {}> = ReadonlyWrappedAtom<S> &
	AtomApi<S> &
	ReactAtom<S> & { [K in keyof A]: OmitFirstParam<A[K]> } & Atom<S>;
export type ReadonlyWrappedAtom<S> = Omit<AtomApi<S>, "Mutate"> & ReactAtom<S> & Molecule<S>;

export const CreateAtom = <
	S,
	A extends Record<string, (draft: Draft<S>, ...args: any[]) => S> = Record<
		string,
		(draft: Draft<S>, ...args: any[]) => S
	>,
>(
	state: S,
	actions: A = {} as A,
) => {
	const newAtom = atom(state);
	const dispatches = {};
	const atomApi = {} as ReactAtom<S> & AtomApi<S>;
	const disconnects = new Set<VoidCallback>();

	// eslint-disable-next-line roblox-ts/no-array-pairs
	for (const [key, action] of pairs(actions)) {
		dispatches[key as never] = ((...args: unknown[]) => {
			const newState = produce(newAtom(), (draft) => (action as Callback)(draft, ...args)) as S;
			newAtom(newState);
			return newState;
		}) as never;
	}

	// API implementation
	atomApi.Subscribe = function (this, ...args: unknown[]) {
		if (args.size() === 1) {
			return subscribe(newAtom, args[0] as (state: S) => void);
		}

		const cleanup = subscribe(
			() => (args[0] as (state: S) => any)(newAtom()),
			(state: S, prev) => task.spawn(args[1] as never, state, prev),
		);

		disconnects.add(cleanup);
		return () => {
			cleanup();
			disconnects.delete(cleanup);
		};
	};

	atomApi.Observe = function (this, selector, factory) {
		const cleanup = observe(() => selector(newAtom()), factory);

		disconnects.add(cleanup);
		return () => {
			cleanup();
			disconnects.delete(cleanup);
		};
	};

	atomApi.Destroy = () => disconnects.forEach((fn) => fn());

	atomApi.Mutate = (recipe) => {
		const data = produce(newAtom(), (draft) => recipe(draft, newAtom()));
		//RepairDataFromDraft(data);
		newAtom(data);
	};

	setmetatable(atomApi, {
		__index: (_, index) => dispatches[index as never],
		__call: (_, ...args) => newAtom(...(args as [])),
	});

	return atomApi as WrappedAtom<S, A>;
};


local function add_indent(level)
    return string.rep("    ", level)
end

local function formatJson(json_str)
    local result = {}
    local level = 0
    local in_string = false

    for i = 1, #json_str do
        local char = json_str:sub(i, i)
        local prev_char = json_str:sub(i - 1, i - 1)

        if char == '"' and prev_char ~= '\\' then
            in_string = not in_string
            table.insert(result, char)
        elseif not in_string then
            if char == '{' or char == '[' then
                table.insert(result, char)
                level = level + 1
                table.insert(result, "\n" .. add_indent(level))
            elseif char == '}' or char == ']' then
                level = level - 1
                table.insert(result, "\n" .. add_indent(level) .. char)
            elseif char == ',' then
                table.insert(result, char)
                table.insert(result, "\n" .. add_indent(level))
            elseif char ~= ' ' and char ~= '\n' and char ~= '\t' then
                table.insert(result, char)
            end
        else
            table.insert(result, char)
        end
    end

    return table.concat(result)
end

return {
    formatJson = formatJson;
}


import { setInterval } from "@rbxts/set-timeout";
import type { PlayerComponent } from "server/components/player-component";
import { PlayerInfo } from "types/player/player-info";

export function ToPlayerInfo(player: Player | PlayerComponent): PlayerInfo {
	return {
		Name: player.Name,
		DisplayName: player.DisplayName,
		UserId: player.UserId,
	};
}

export function WaitForStops(instance: BasePart, minSpeed: number) {
	return new Promise<void>((resolve) => {
		const disconnect = setInterval(() => {
			if (instance.AssemblyLinearVelocity.Magnitude >= minSpeed) return;
			disconnect();
			resolve();
		}, 1);
	});
}

export function SetCollisionGroup(instance: Instance, group: string): void {
	for (const child of instance.GetDescendants()) {
		if (!child.IsA("BasePart")) continue;
		child.CollisionGroup = group;
	}
}


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

export function lerpStrict(a: number, b: number, t: number) {
	return math.clamp(lerp(a, b, t), math.min(a, b), math.max(a, b));
}

export function mapStrict(value: number, min: number, max: number, newMin: number, newMax: number) {
	if (min === max) {
		return newMin;
	}
	return lerpStrict(newMin, newMax, (value - min) / (max - min));
}

export function GetModelDiagonalLength(model: Model) {
	const size = model.GetExtentsSize();

	return math.sqrt(size.X ** 2 + size.Z ** 2);
}

export function RemoveYComponent(vector: Vector3) {
	return new Vector3(vector.X, 0, vector.Z);
}

export function MapNumberToRange(value: number, min1: number, max1: number, min2: number, max2: number): number {
	const normalizedValue = (value - min1) / (max1 - min1);
	return min2 + normalizedValue * (max2 - min2);
}

export function OnlyYOrientation(cframe: CFrame) {
	const [_, y] = cframe.ToOrientation();
	return CFrame.fromOrientation(0, y, 0);
}

export function NullifyZAxisOrientation(cframe: CFrame) {
	const [x, y] = cframe.ToOrientation();
	return CFrame.fromOrientation(x, y, 0);
}

export function OrientationToVector(cframe: CFrame) {
	return new Vector3(...cframe.ToOrientation());
}

export function OrientationFromVector(vec: Vector3) {
	return CFrame.fromOrientation(
		math.clamp(vec.X, -math.pi, math.pi),
		math.clamp(vec.Y, -math.pi, math.pi),
		math.clamp(vec.Z, -math.pi, math.pi),
	);
}


import { Components } from "@flamework/components/out/components";
import { Dependency } from "@flamework/core/out/flamework";
import { CharacterRigR15, promiseR15 } from "@rbxts/character-promise";
import { Players } from "@rbxts/services";
import type { PlayerComponent } from "server/components/player-component";
import type { PlayerService } from "server/services/player-service";
import { IS_SERVER, IS_STUDIO } from "./constants";

export async function PromisePlayerDisconnected(player: Player) {
	assert(player.IsDescendantOf(Players), "Player must be a descendant of Players");

	await Promise.fromEvent(Players.PlayerRemoving, (playerWhoLeft) => playerWhoLeft === player);
}

/** @server */
export const GetPlayerComponent = (player: Player) => {
	const components = Dependency<Components>();
	return components.getComponent<PlayerComponent>(player)!;
};

/** @server */
export const GetPlayerFromPart = (part: BasePart) => {
	const model = part.FindFirstAncestorOfClass("Model");
	if (!model) return;

	const player = Players.GetPlayerFromCharacter(model);
	if (!player) return;

	return GetPlayerComponent(player);
};

/** @server */
export const WaitPlayerComponent = (player: Player) => {
	const components = Dependency<Components>();
	return components.waitForComponent<PlayerComponent>(player);
};

/** @server */
export const WaitReadyPlayerComponent = async (player: Player) => {
	const component = await WaitPlayerComponent(player);
	await component.WaitForStatus("Started");

	return component;
};

/** @server */
export const IsInitedPlayer = (player: Player) => {
	const components = Dependency<Components>();
	return components.getComponent<PlayerComponent>(player)?.IsStatus("Started");
};

/** @server */
export const ForeachStartedPlayers = (callback: (player: PlayerComponent) => void) => {
	assert(IS_SERVER || IS_STUDIO, "ForeachStartedPlayers must be called on the server");
	Dependency<PlayerService>()
		.GetPlayers()
		.forEach((player) => {
			if (!player.IsStatus("Started")) return;
			callback(player);
		});
};

export const PromiseCharacterAdded = async (player: Player) => {
	assert(player.IsDescendantOf(Players), "Player must be a descendant of Players");

	return new Promise<CharacterRigR15>((resolve) => {
		const character = player.Character ?? player.CharacterAdded.Wait()[0];
		promiseR15(character).then(resolve);
	});
};

export const CharacterAddedWithValidate = (player: Player, connection: (character: CharacterRigR15) => void) => {
	return player.CharacterAdded.Connect((character) => {
		promiseR15(character).then(connection);
	});
};

export const GetCharactersFromHits = (hitsList: BasePart[], onlyHumanoidRootPart = false) => {
	type Character = Model & { Humanoid: Humanoid };
	const characters = new Set<Character>();

	hitsList.forEach((hit) => {
		if (onlyHumanoidRootPart && hit.Name !== "HumanoidRootPart") return;

		const character = hit.FindFirstAncestorOfClass("Model");
		const humanoid = character?.FindFirstChildOfClass("Humanoid");

		if (!character || characters.has(character as Character)) return;
		if (!humanoid || humanoid.Health <= 0) return;

		characters.add(character as Character);
	});

	return characters;
};

export const GetPlayersFromHits = (hitsList: BasePart[], onlyHumanoidRootPart = false) => {
	const players = new Set<Player>();

	hitsList.forEach((hit) => {
		if (onlyHumanoidRootPart && hit.Name !== "HumanoidRootPart") return;

		const character = hit.FindFirstAncestorOfClass("Model");
		const humanoid = character?.FindFirstChildOfClass("Humanoid");
		const player = Players.GetPlayerFromCharacter(character);

		if (!character || !player || players.has(player)) return;
		if (!humanoid || humanoid.Health <= 0) return;

		players.add(player);
	});

	return players;
};


local CLONE = "_clone"
local BASE = "_base"

local function RepairDataFromDraft<T>(draft: T): T?
	if typeof(draft) ~= "table" then
		return draft
	end

	if draft[CLONE] == nil and draft[BASE] == nil then
		for key, value in draft do
			local val = RepairDataFromDraft(value)
			if not val then continue end
			draft[key] = val
		end

		return draft
	end

	local final = draft[CLONE] or draft[BASE]

	for key, value in final do
		local val = RepairDataFromDraft(value)
		if not val then continue end
		final[key] = val
	end

	return final
end

return RepairDataFromDraft


import Log, { LogEvent, LogLevel, Logger } from "@rbxts/log";
import { IS_CLIENT, IS_DEV } from "./constants";
import { ILogEventSink } from "@rbxts/log/out/Core";
import { PlainTextMessageTemplateRenderer, MessageTemplateParser } from "@rbxts/message-templates";

const LOG_LEVEL = IS_DEV ? LogLevel.Debugging : LogLevel.Information;
const Environment = IS_CLIENT ? "Client" : "Server";

const STACK_TRACE_LEVEL_MODULE = 5;
const STACK_TRACE_LEVEL_FLAMEWORK = 4;

/**
 * Represents a log event sink that outputs log messages using the Roblox
 * logging functions.
 */
class LogEventSFTOutputSink implements ILogEventSink {
	public Emit(message: LogEvent): void {
		const template = new PlainTextMessageTemplateRenderer(MessageTemplateParser.GetTokens(message.Template));

		const tag = this.getLogLevelString(message.Level);
		const context = message.SourceContext ?? "Game";
		const messageResult = template.Render(message);
		const fileInfo = this.getFileInformation(context);

		const formattedMessage = `[${tag}] ${context} (${Environment}) - ${messageResult}` + fileInfo;

		if (message.Level >= LogLevel.Fatal) {
			error(formattedMessage);
		} else if (message.Level >= LogLevel.Warning) {
			warn(formattedMessage);
		} else {
			print(formattedMessage);
		}
	}

	private getLogLevelString(level: LogLevel): string {
		switch (level) {
			case LogLevel.Verbose: {
				return "VERBOSE";
			}
			case LogLevel.Debugging: {
				return "DEBUG";
			}
			case LogLevel.Information: {
				return "INFO";
			}
			case LogLevel.Warning: {
				return "WARN";
			}
			case LogLevel.Error: {
				return "ERROR";
			}
			case LogLevel.Fatal: {
				return "FATAL";
			}
		}
	}

	private getFileInformation(context: string): string {
		if (LOG_LEVEL > LogLevel.Verbose) {
			return "";
		}

		const source =
			context === "Game"
				? debug.info(STACK_TRACE_LEVEL_MODULE, "sl")
				: debug.info(STACK_TRACE_LEVEL_FLAMEWORK, "sl");
		const [file, line] = source;
		return ` (${file}:${line})`;
	}
}

let logger: Logger;

export function SetupLogger() {
	if (logger) return;
	logger = Logger.configure().WriteTo(new LogEventSFTOutputSink()).SetMinLogLevel(LOG_LEVEL).Create();
	Log.SetLogger(logger);
}

export function GetLogger() {
	SetupLogger();
	return logger;
}


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

interface SoundOption {
	parent?: Instance;
	destroyAfterPlay?: boolean;
	useOriginalSound?: boolean;
	soundGroup?: "Movement" | "BackgroundMusic" | "Ambient";
}

export function PlaySound(sound: Sound, options: SoundOption = {}) {
	options.destroyAfterPlay = options.destroyAfterPlay ?? false;
	options.useOriginalSound = options.useOriginalSound ?? false;

	const clone = options.useOriginalSound ? sound : sound.Clone();
	clone.Parent = options.parent ?? sound.Parent;
	clone.Play();

	if (options.soundGroup) {
		const group = SoundService.FindFirstChild("Main")?.FindFirstChild(options.soundGroup) as SoundGroup;
		clone.SoundGroup = group;
	}

	if (options.destroyAfterPlay) {
		clone.Ended.Once(() => {
			clone.Destroy();
		});
	}

	return clone;
}


import { MutableRefObject, RefCallback } from "@rbxts/react";

export function MultiplyUDim2(UDim: UDim2, Multiplier: number) {
	return new UDim2(
		UDim.X.Scale * Multiplier,
		UDim.X.Offset * Multiplier,
		UDim.Y.Scale * Multiplier,
		UDim.Y.Offset * Multiplier,
	);
}

export function CreateAssetUrl(id: string | number = "") {
	if (typeIs(id, "string")) return id;
	return `rbxassetid://${id}`;
}

export function CheckMappingNames(name: string, searchString: string) {
	return searchString.lower() === name.lower().sub(1, searchString.size());
}

export function combinePropsWithDefault<T extends object, D extends Partial<T>>(props: T, defaultProps: D) {
	// eslint-disable-next-line roblox-ts/no-array-pairs
	for (const [key, value] of pairs(defaultProps)) {
		if (props[key as never] === undefined) {
			props[key as never] = value as never;
		}
	}

	return props as T & D;
}

export function PickProps<O extends object, C extends keyof O>(props: O, propNames: readonly C[], ) {
	const newProps = {} as Pick<O, C>;

	for (const [key, value] of pairs(props)) {
		if (propNames.includes(key as C)) {
			newProps[key as never] = value as never;
		}
	}

	return newProps;
}

export function ExcludeProps<O extends object, C extends keyof O>(props: O, propNames: readonly C[]) {
	const newProps = table.clone(props) as Omit<O, C>;

	propNames.forEach((value, index) => {
		newProps[value as never] = undefined as never;
	});

	return newProps;
}


import fs from "node:fs/promises";
import { parsePnpmWorkspaceYaml } from "pnpm-workspace-yaml";

import { dependenciesMap } from "../src/cli/constants";

const names = new Set(["eslint", ...Object.values(dependenciesMap).flat()]);

const yaml = parsePnpmWorkspaceYaml(
	await fs.readFile(new URL("../pnpm-workspace.yaml", import.meta.url), "utf-8"),
).toJSON();
const catalogs = Object.values({
	default: yaml.catalog ?? {},
	...yaml.catalogs,
});

const versions = Object.fromEntries(
	Array.from(names)
		.map((name) => {
			const version = catalogs.map((catalog) => catalog[name]).find(Boolean);
			if (version === undefined) {
				throw new Error(`Package ${name} not found`);
			}

			return [name, version] as const;
		})
		.sort((a, b) => a[0].localeCompare(b[0])),
);

await fs.writeFile(
	new URL("../src/cli/constants-generated.ts", import.meta.url),
	`export const versionsMap = ${JSON.stringify(versions, null, 2)}`,
);


import type {
	OptionsHasRoblox,
	OptionsProjectType,
	OptionsStylistic,
	TypedFlatConfigItem,
} from "../types";
import { interopDefault } from "../utils";

export async function packageJson(
	options: OptionsHasRoblox & OptionsProjectType & OptionsStylistic = {},
): Promise<Array<TypedFlatConfigItem>> {
	const { roblox = true, stylistic = true, type = "game" } = options;

	const [jsoncEslintParser, pluginPackageJson] = await Promise.all([
		interopDefault(import("jsonc-eslint-parser")),
		interopDefault(import("eslint-plugin-package-json")),
	]);

	return [
		{
			name: "isentinel/package-json/setup",
			plugins: {
				"package-json": pluginPackageJson,
			},
		},
		{
			name: "isentinel/package-json",
			files: ["**/package.json"],
			languageOptions: {
				parser: jsoncEslintParser,
			},
			rules: {
				"package-json/no-empty-fields": "error",
				"package-json/no-redundant-files": "error",
				"package-json/no-redundant-publishConfig": "error",
				"package-json/repository-shorthand": "error",
				"package-json/require-type": "error",
				"package-json/restrict-private-properties": "error",
				"package-json/scripts-name-casing": "error",
				"package-json/sort-collections": "error",
				"package-json/specify-peers-locally": "error",
				"package-json/unique-dependencies": "error",
				"package-json/valid-author": "off",
				"package-json/valid-bin": "error",
				"package-json/valid-bundleDependencies": "error",
				"package-json/valid-config": "error",
				"package-json/valid-contributors": "error",
				"package-json/valid-cpu": "error",
				"package-json/valid-dependencies": "error",
				"package-json/valid-description": "error",
				"package-json/valid-devDependencies": "error",
				"package-json/valid-directories": "error",
				"package-json/valid-engines": "error",
				"package-json/valid-exports": "error",
				"package-json/valid-files": "error",
				"package-json/valid-homepage": "error",
				"package-json/valid-keywords": "error",
				"package-json/valid-license": "error",
				"package-json/valid-main": "error",
				"package-json/valid-man": "error",
				"package-json/valid-module": "error",
				"package-json/valid-name": "error",
				"package-json/valid-optionalDependencies": "error",
				"package-json/valid-os": "error",
				"package-json/valid-peerDependencies": "error",
				"package-json/valid-private": "error",
				"package-json/valid-publishConfig": "error",
				"package-json/valid-repository": "error",
				"package-json/valid-repository-directory": "error",
				"package-json/valid-scripts": "error",
				"package-json/valid-sideEffects": "error",
				"package-json/valid-type": "error",
				"package-json/valid-version": "error",
				"package-json/valid-workspaces": "error",

				...(stylistic !== false
					? {
							"package-json/bin-name-casing": "error",
							"package-json/exports-subpaths-style": [
								"error",
								{ prefer: "explicit" },
							],
							"package-json/order-properties": "error",
						}
					: {}),

				...(type === "package"
					? {
							"package-json/require-attribution": ["error", { ignorePrivate: true }],
							"package-json/require-bugs": ["error", { ignorePrivate: true }],
							"package-json/require-description": "error",
							"package-json/require-exports": ["error", { ignorePrivate: true }],
							"package-json/require-files": ["error", { ignorePrivate: true }],
							"package-json/require-homepage": ["error", { ignorePrivate: true }],
							"package-json/require-keywords": "error",
							"package-json/require-license": ["error", { ignorePrivate: true }],
							"package-json/require-name": "error",
							"package-json/require-repository": ["error", { ignorePrivate: true }],
							"package-json/require-types": ["error", { ignorePrivate: true }],
							"package-json/require-version": "error",
						}
					: {}),

				...(type === "package" && !roblox
					? {
							"package-json/require-engines": "error",
							"package-json/require-sideEffects": ["error", { ignorePrivate: true }],
						}
					: {}),
			},
		},
	];
}


import { findUp } from "find-up-simple";
import fs from "node:fs/promises";

import type { OptionsIsInEditor, OptionsPnpm, TypedFlatConfigItem } from "../types";
import { ensurePackages, interopDefault } from "../utils";

export async function pnpm(
	options: OptionsPnpm & Required<OptionsIsInEditor>,
): Promise<Array<TypedFlatConfigItem>> {
	await ensurePackages(["eslint-plugin-pnpm"]);

	const [pluginPnpm, yamlParser, jsoncParser] = await Promise.all([
		interopDefault(import("eslint-plugin-pnpm")),
		interopDefault(import("yaml-eslint-parser")),
		interopDefault(import("jsonc-eslint-parser")),
	]);

	const { catalogs = await detectCatalogUsage(), isInEditor } = options;

	return [
		{
			name: "isentinel/pnpm/setup",
			plugins: {
				pnpm: pluginPnpm,
			},
		},
		{
			name: "isentinel/pnpm/package-json",
			files: ["package.json", "**/package.json"],
			languageOptions: {
				parser: jsoncParser,
			},
			rules: {
				...(catalogs
					? {
							"pnpm/json-enforce-catalog": ["error", { autofix: !isInEditor }],
						}
					: {}),
				"pnpm/json-prefer-workspace-settings": ["error", { autofix: !isInEditor }],
				"pnpm/json-valid-catalog": ["error", { autofix: !isInEditor }],
			},
			settings: {
				pnpm: {
					ensureWorkspaceFile: true,
				},
			},
		},
		{
			name: "isentinel/pnpm/pnpm-workspace-yaml",
			files: ["pnpm-workspace.yaml"],
			languageOptions: {
				parser: yamlParser,
			},
			plugins: {
				pnpm: pluginPnpm,
			},
			rules: {
				"pnpm/yaml-enforce-settings": [
					"error",
					{
						settings: {
							catalogMode: "prefer",
							cleanupUnusedCatalogs: true,
							shellEmulator: true,
							// TODO: Currently our own config is breaking for
							// this making it a pain to use
							// trustPolicy: "no-downgrade",
						},
					},
				],
				"pnpm/yaml-no-duplicate-catalog-item": "error",
				"pnpm/yaml-no-unused-catalog-item": "error",
			},
		},
	];
}

async function detectCatalogUsage(): Promise<boolean> {
	const workspaceFile = await findUp("pnpm-workspace.yaml");
	if (workspaceFile === undefined) {
		return false;
	}

	const yaml = await fs.readFile(workspaceFile, "utf-8");
	return yaml.includes("catalog:") || yaml.includes("catalogs:");
}


import type PluginVitest from "@vitest/eslint-plugin";

import type PluginJest from "eslint-plugin-jest";

import { GLOB_TESTS } from "../globs";
import type {
	OptionsFiles,
	OptionsHasRoblox,
	OptionsIsInEditor,
	OptionsJest,
	OptionsOverrides,
	OptionsProjectType,
	OptionsStylistic,
	OptionsTestFramework,
	OptionsVitest,
	TypedFlatConfigItem,
} from "../types";
import { ensurePackages, interopDefault } from "../utils";

// Hold the references so we don't redeclare the plugins on each call
let pluginTest: typeof PluginJest | undefined;
let pluginJestExtended: any;
let pluginVitest: typeof PluginVitest | undefined;

export async function test(
	options: OptionsFiles &
		OptionsHasRoblox &
		OptionsIsInEditor &
		OptionsOverrides &
		OptionsProjectType &
		OptionsStylistic &
		OptionsTestFramework = {},
): Promise<Array<TypedFlatConfigItem>> {
	const {
		files = GLOB_TESTS,
		isInEditor = false,
		jest = false,
		overrides = {},
		roblox: isRoblox = true,
		stylistic = true,
		type = "game",
		vitest = false,
	} = options;

	const vitestOptions: OptionsVitest = typeof vitest === "object" ? vitest : {};
	const vitestEnabled = vitest === true || typeof vitest === "object";
	const jestOptions: OptionsJest = typeof jest === "object" ? jest : {};
	const jestEnabled = jest === true || typeof jest === "object";
	const enableJest = jestEnabled || (!vitestEnabled && (type === "game" || isRoblox));
	const enableVitest = vitestEnabled || (!jestEnabled && type === "package" && !isRoblox);

	const configs: Array<TypedFlatConfigItem> = [];

	if (enableJest) {
		await ensurePackages(["eslint-plugin-jest"]);
		const pluginJest = await interopDefault(import("eslint-plugin-jest"));

		const useJestExtended = jestOptions.extended === true;

		const jestExtendedPlugin = await (async () => {
			if (!useJestExtended) {
				return;
			}

			await ensurePackages(["eslint-plugin-jest-extended"]);
			// @ts-expect-error -- No types
			// eslint-disable-next-line ts/no-unsafe-return -- No types
			return interopDefault(import("eslint-plugin-jest-extended"));
		})();

		pluginTest ??= {
			...pluginJest,
		};

		pluginJestExtended ??= {
			...jestExtendedPlugin,
		};

		configs.push(
			{
				name: "isentinel/test/jest/setup",
				plugins: {
					test: pluginTest,
					...(useJestExtended ? { "jest-extended": pluginJestExtended } : {}),
				},
			},
			{
				name: "isentinel/test/jest/rules",
				files,
				rules: {
					"test/consistent-test-it": "error",
					"test/expect-expect": "warn",
					"test/max-expects": "error",
					"test/max-nested-describe": ["error", { max: 4 }],
					"test/no-alias-methods": "error",
					"test/no-commented-out-tests": "warn",
					"test/no-conditional-expect": "error",
					"test/no-conditional-in-test": [
						"error",
						{
							allowOptionalChaining: false,
						},
					],
					"test/no-disabled-tests": isInEditor ? "off" : "error",
					"test/no-done-callback": "error",
					"test/no-duplicate-hooks": "error",
					"test/no-error-equal": isRoblox ? "off" : "error",
					"test/no-export": "error",
					"test/no-focused-tests": isInEditor ? "off" : "error",
					"test/no-hooks": "error",
					"test/no-identical-title": "error",
					"test/no-standalone-expect": "error",
					"test/no-test-prefixes": "error",
					"test/no-test-return-statement": "error",
					"test/no-unnecessary-assertion": "error",
					"test/no-unneeded-async-expect-function": "error",
					"test/no-untyped-mock-factory": "error",
					"test/prefer-called-with": "warn",
					"test/prefer-comparison-matcher": "warn",
					"test/prefer-each": "warn",
					"test/prefer-ending-with-an-expect": "warn",
					"test/prefer-equality-matcher": "warn",
					"test/prefer-expect-assertions": "error",
					"test/prefer-hooks-in-order": "warn",
					"test/prefer-lowercase-title": "warn",
					"test/prefer-mock-promise-shorthand": "error",
					"test/prefer-mock-return-shorthand": "error",
					"test/prefer-spy-on": "warn",
					"test/prefer-strict-equal": "error",
					"test/prefer-to-be": "error",
					"test/prefer-to-contain": "error",
					"test/prefer-to-have-been-called": "error",
					"test/prefer-to-have-been-called-times": "error",
					"test/prefer-to-have-length": "error",
					"test/prefer-todo": "warn",
					"test/require-hook": "error",
					"test/require-to-throw-message": "warn",
					"test/require-top-level-describe": "error",
					"test/unbound-method": "error",
					"test/valid-describe-callback": "error",
					// Doesn't allow roblox deviations
					"test/valid-expect": isRoblox ? "off" : "error",
					"test/valid-expect-in-promise": "error",
					"test/valid-expect-with-promise": "error",
					"test/valid-title": [
						"error",
						{
							ignoreTypeOfDescribeName: true,
							mustMatch: {
								it: ["^should", 'Test title must start with "should"'],
							},
						},
					],

					...(useJestExtended
						? {
								"jest-extended/prefer-to-be-array": "error",
								"jest-extended/prefer-to-be-false": "error",
								"jest-extended/prefer-to-be-object": "error",
								"jest-extended/prefer-to-be-true": "error",
								"jest-extended/prefer-to-have-been-called-once": "error",
							}
						: {}),

					...(stylistic !== false
						? {
								"test/padding-around-all": "warn",
							}
						: {}),

					...overrides,
				},
				settings: {
					jest: {
						globalPackage: "@rbxts/jest-globals",
						version: 27,
					},
				},
			},
		);
	}

	if (enableVitest) {
		await ensurePackages(["@vitest/eslint-plugin"]);
		const vitestPlugin = await interopDefault(import("@vitest/eslint-plugin"));

		const useJestExtended = jestOptions.extended === true;

		const jestExtendedPlugin = await (async () => {
			if (!useJestExtended) {
				return;
			}

			await ensurePackages(["eslint-plugin-jest-extended"]);
			// @ts-expect-error -- No types
			// eslint-disable-next-line ts/no-unsafe-return -- No types
			return interopDefault(import("eslint-plugin-jest-extended"));
		})();

		pluginVitest ??= {
			...vitestPlugin,
		};

		pluginJestExtended ??= {
			...jestExtendedPlugin,
		};

		configs.push(
			{
				name: "isentinel/test/vitest/setup",
				plugins: {
					vitest: pluginVitest,
					...(useJestExtended ? { "jest-extended": pluginJestExtended } : {}),
				},
			},
			{
				name: "isentinel/test/vitest/rules",
				files,
				rules: {
					"vitest/consistent-each-for": [
						"error",
						{
							describe: "for",
							it: "for",
							suite: "for",
							test: "for",
						},
					],
					"vitest/consistent-test-filename": [
						"error",
						{
							pattern: ".*\\.spec\\.[tj]sx?$",
						},
					],
					"vitest/consistent-test-it": ["error", { fn: "it", withinDescribe: "it" }],
					"vitest/consistent-vitest-vi": "error",
					"vitest/expect-expect": "warn",
					"vitest/hoisted-apis-on-top": "error",
					"vitest/max-expects": "error",
					"vitest/max-nested-describe": ["error", { max: 4 }],
					"vitest/no-alias-methods": "error",
					"vitest/no-commented-out-tests": "warn",
					"vitest/no-conditional-expect": "error",
					"vitest/no-conditional-in-test": "error",
					"vitest/no-conditional-tests": "error",
					"vitest/no-disabled-tests": isInEditor ? "off" : "error",
					"vitest/no-duplicate-hooks": "error",
					"vitest/no-focused-tests": isInEditor ? "off" : "error",
					"vitest/no-hooks": "error",
					"vitest/no-identical-title": "error",
					"vitest/no-import-node-test": "error",
					"vitest/no-interpolation-in-snapshots": "error",
					"vitest/no-large-snapshots": "warn",
					"vitest/no-mocks-import": "error",
					"vitest/no-standalone-expect": "error",
					"vitest/no-test-prefixes": "error",
					"vitest/no-test-return-statement": "error",
					"vitest/no-unneeded-async-expect-function": "error",
					"vitest/prefer-called-exactly-once-with": "warn",
					"vitest/prefer-called-once": "error",
					"vitest/prefer-called-with": "error",
					"vitest/prefer-comparison-matcher": "warn",
					"vitest/prefer-describe-function-title": "warn",
					"vitest/prefer-each": "warn",
					"vitest/prefer-equality-matcher": "warn",
					"vitest/prefer-expect-assertions": "warn",
					"vitest/prefer-expect-resolves": "error",
					"vitest/prefer-expect-type-of": "error",
					"vitest/prefer-hooks-in-order": "error",
					"vitest/prefer-hooks-on-top": "error",
					"vitest/prefer-import-in-mock": "error",
					"vitest/prefer-importing-vitest-globals": "error",
					"vitest/prefer-lowercase-title": "error",
					"vitest/prefer-mock-promise-shorthand": "error",
					"vitest/prefer-mock-return-shorthand": "error",
					"vitest/prefer-snapshot-hint": "warn",
					"vitest/prefer-spy-on": "warn",
					"vitest/prefer-strict-boolean-matchers": "error",
					"vitest/prefer-strict-equal": "error",
					"vitest/prefer-to-be": "error",
					"vitest/prefer-to-be-object": "error",
					"vitest/prefer-to-contain": "error",
					"vitest/prefer-to-have-been-called-times": "error",
					"vitest/prefer-to-have-length": "error",
					"vitest/prefer-todo": "warn",
					"vitest/prefer-vi-mocked": "error",
					"vitest/require-awaited-expect-poll": "error",
					"vitest/require-local-test-context-for-concurrent-snapshots": "error",
					"vitest/require-mock-type-parameters": "error",
					"vitest/require-to-throw-message": "warn",
					"vitest/require-top-level-describe": "error",
					"vitest/valid-describe-callback": "error",
					"vitest/valid-expect": "error",
					"vitest/valid-expect-in-promise": "error",
					"vitest/valid-title": [
						"error",
						{
							ignoreTypeOfDescribeName: true,
							mustMatch: {
								it: ["^should", 'Test title must start with "should"'],
							},
						},
					],

					...(useJestExtended
						? {
								"jest-extended/prefer-to-be-array": "error",
								"jest-extended/prefer-to-be-false": "error",
								"jest-extended/prefer-to-be-object": "error",
								"jest-extended/prefer-to-be-true": "error",
								"jest-extended/prefer-to-have-been-called-once": "error",
							}
						: {}),

					...(stylistic !== false
						? {
								"vitest/padding-around-all": "warn",
							}
						: {}),

					...overrides,
				},
				settings: {
					vitest: {
						typecheck: vitestOptions.typecheck ?? false,
					},
				},
			},
		);
	}

	return configs;
}


local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")

local TestEZ = require(ReplicatedStorage.rbxts_include.node_modules["@rbxts"].testez.src)

TestEZ.TestBootstrap:run({
	ServerScriptService.TS.test,
	ReplicatedStorage.TS.test,
}, TestEZ.Reporters.TextReporter)


import { throttle } from "@rbxts/set-timeout";
import { store } from "client/store";
import { Alert, AlertScope, selectAlerts } from "client/store/alert";
import { palette } from "shared/constants/palette";

const defaultAlert: Alert = {
	id: 0,
	emoji: "✅",
	message: "Alert",
	color: palette.green,
	duration: 5,
	visible: true,
};

let nextAlertId = 0;

const scopedThrottles: Record<AlertScope, (callback: () => number) => number> = {
	money: throttle((callback) => callback(), 0.8),
	ranking: throttle((callback) => callback(), 0.8),
};

function sendAlertImmediate(patch: Partial<Alert>) {
	const alert: Alert = {
		...defaultAlert,
		...patch,
		id: nextAlertId++,
	};

	if (alert.scope) {
		dismissAlertsOfScope(alert.scope);
	}

	store.addAlert(alert);

	Promise.delay(alert.duration).then(() => {
		dismissAlert(alert.id);
	});

	return alert.id;
}

export function sendAlert(patch: Partial<Alert>) {
	if (!patch.scope) {
		return sendAlertImmediate(patch);
	}

	return scopedThrottles[patch.scope](() => {
		return sendAlertImmediate(patch);
	});
}

export async function dismissAlert(id: number) {
	store.setAlertVisible(id, false);

	return Promise.delay(0.25).then(() => {
		store.removeAlert(id);
		return id;
	});
}

function dismissAlertsOfScope(scope: string) {
	for (const alert of store.getState(selectAlerts)) {
		if (alert.scope === scope) {
			dismissAlert(alert.id);
		}
	}
}


import React from "@rbxts/react";

import { Alerts } from "../components/alerts";
import { Controller } from "../components/controller";
import { ErrorHandler } from "../components/error-handler";
import { Game } from "../components/game";
import { Menu } from "../components/menu";
import { Music } from "../components/music";
import { Preloader } from "../components/preloader";
import { Stats } from "../components/stats";
import { Layer } from "../components/ui/layer";
import { Voice } from "../components/voice";
import { World } from "../components/world";

export function App() {
	return (
		<ErrorHandler>
			<Music />
			<Preloader />
			<Voice />

			<Layer>
				<Controller />
				<World />
				<Game />
			</Layer>

			<Layer>
				<Menu />
				<Stats />
			</Layer>

			<Layer>
				<Alerts />
			</Layer>
		</ErrorHandler>
	);
}


import "./react-config";

import React, { StrictMode } from "@rbxts/react";
import { createPortal, createRoot } from "@rbxts/react-roblox";
import { Players } from "@rbxts/services";
import { RootProvider } from "client/providers/root-provider";

import { App } from "./app";

const root = createRoot(new Instance("Folder"));
const target = Players.LocalPlayer.WaitForChild("PlayerGui");

root.render(
	createPortal(
		<StrictMode>
			<RootProvider>
				<App />
			</RootProvider>
		</StrictMode>,
		target,
	),
);


import { backend } from "@rbxts/react-devtools-core";
import ReactGlobals from "@rbxts/react-globals";
import { RunService } from "@rbxts/services";

if (RunService.IsStudio()) {
	ReactGlobals.__DEV__ = true;
	ReactGlobals.__PROFILE__ = true;

	backend.connectToDevtools();
}


import { lerpBinding, useEventListener } from "@rbxts/pretty-react-hooks";
import React, { useBinding } from "@rbxts/react";
import { RunService } from "@rbxts/services";
import { CanvasGroup } from "client/components/ui/canvas-group";
import { Frame } from "client/components/ui/frame";
import { useRem } from "client/hooks";
import { palette } from "shared/constants/palette";
import { darken } from "shared/utils/color-utils";

interface AlertTimerProps {
	readonly duration: number;
	readonly color: Color3;
	readonly colorSecondary?: Color3;
	readonly transparency: React.Binding<number>;
}

export function AlertTimer({ duration, color, colorSecondary = color, transparency }: AlertTimerProps) {
	const rem = useRem();
	const [progress, setProgress] = useBinding(0);

	const colorFrom = darken(color, 0.2);
	const colorTo = darken(colorSecondary, 0.2);

	useEventListener(RunService.Heartbeat, (deltaTime) => {
		setProgress(math.clamp(progress.getValue() + deltaTime / duration, 0, 1));
	});

	return (
		<CanvasGroup backgroundTransparency={1} cornerRadius={new UDim(0, rem(1))} size={new UDim2(1, 0, 1, 0)}>
			<uigradient Color={new ColorSequence(colorFrom, colorTo)} />

			<Frame
				backgroundColor={palette.white}
				backgroundTransparency={transparency}
				anchorPoint={new Vector2(0, 1)}
				size={lerpBinding(progress, new UDim2(1, 0, 0, rem(0.35)), new UDim2(0, 0, 0, rem(0.35)))}
				position={new UDim2(0, 0, 1, 0)}
			/>
		</CanvasGroup>
	);
}


import React from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { selectAlerts } from "client/store/alert";

import { Alert } from "./alert";

export function Alerts() {
	const alerts = useSelector(selectAlerts);

	return (
		<>
			{alerts.map((alert, index) => (
				<Alert key={alert.id} alert={alert} index={index} />
			))}
		</>
	);
}


import { useThrottleCallback } from "@rbxts/pretty-react-hooks";
import React, { useEffect } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { useInputDevice, useStore } from "client/hooks";
import { REMOTE_TICK, WORLD_TICK } from "shared/constants/core";
import { remotes } from "shared/remotes";
import { selectLocalSnake } from "shared/store/snakes";

import { Gamepad } from "./controllers/gamepad";
import { Mouse } from "./controllers/mouse";
import { Touch } from "./controllers/touch";
import { useToggleTouchControls } from "./utils/use-toggle-touch-controls";

export function Controller() {
	const store = useStore();
	const device = useInputDevice();
	const snake = useSelector(selectLocalSnake);

	useToggleTouchControls(snake !== undefined);

	const updateAngle = useThrottleCallback(
		(angle: number) => {
			remotes.snake.move.fire(angle);
			store.setWorldInputAngle(angle);
		},
		{ wait: REMOTE_TICK, leading: true, trailing: true },
	);

	const setBoost = useThrottleCallback(
		(boost: boolean) => {
			remotes.snake.boost.fire(boost);
		},
		{ wait: WORLD_TICK, leading: true, trailing: true },
	);

	useEffect(() => {
		if (snake) {
			store.setWorldInputAngle(0);
		}
	}, [!snake]);

	if (!snake) {
		return <></>;
	}

	return (
		<>
			{device === "keyboard" && <Mouse updateAngle={updateAngle.run} setBoost={setBoost.run} />}
			{device === "touch" && <Touch updateAngle={updateAngle.run} setBoost={setBoost.run} />}
			{device === "gamepad" && <Gamepad updateAngle={updateAngle.run} setBoost={setBoost.run} />}
		</>
	);
}


import { useEventListener } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { UserInputService } from "@rbxts/services";

interface GamepadProps {
	readonly updateAngle: (angle: number) => void;
	readonly setBoost: (boost: boolean) => void;
}

const BOOST_KEYS = new ReadonlySet<Enum.KeyCode>([Enum.KeyCode.ButtonR2, Enum.KeyCode.ButtonL2, Enum.KeyCode.ButtonA]);
const THUMBSTICK_KEYS = new ReadonlySet<Enum.KeyCode>([Enum.KeyCode.Thumbstick1, Enum.KeyCode.Thumbstick2]);
const THUMBSTICK_DEADZONE = 0.3;

export function Gamepad({ updateAngle, setBoost }: GamepadProps) {
	useEventListener(UserInputService.InputBegan, (input) => {
		if (input.UserInputType === Enum.UserInputType.Gamepad1 && BOOST_KEYS.has(input.KeyCode)) {
			setBoost(true);
		}
	});

	useEventListener(UserInputService.InputEnded, (input) => {
		if (input.UserInputType === Enum.UserInputType.Gamepad1 && BOOST_KEYS.has(input.KeyCode)) {
			setBoost(false);
		}
	});

	useEventListener(UserInputService.InputChanged, (input) => {
		if (
			input.UserInputType === Enum.UserInputType.Gamepad1 &&
			THUMBSTICK_KEYS.has(input.KeyCode) &&
			input.Position.Magnitude > THUMBSTICK_DEADZONE
		) {
			const angle = math.atan2(-input.Position.Y, input.Position.X);
			updateAngle(angle);
		}
	});

	return <></>;
}


import React, { useEffect, useState } from "@rbxts/react";
import { InputCapture } from "client/components/ui/input-capture";
import { lerpRadians } from "shared/utils/math-utils";

interface MouseProps {
	readonly updateAngle: (angle: number) => void;
	readonly setBoost: (boost: boolean) => void;
}

const KEY_CODES = new ReadonlySet<Enum.UserInputType | Enum.KeyCode>([
	Enum.UserInputType.MouseButton1,
	Enum.KeyCode.Space,
	Enum.KeyCode.LeftShift,
]);

const MOVE_DIRECTIONS = new ReadonlyMap<Enum.KeyCode, number>([
	[Enum.KeyCode.D, 0],
	[Enum.KeyCode.W, math.rad(-90)],
	[Enum.KeyCode.A, math.rad(-180)],
	[Enum.KeyCode.S, math.rad(90)],
	[Enum.KeyCode.Right, 0],
	[Enum.KeyCode.Up, math.rad(-90)],
	[Enum.KeyCode.Left, math.rad(-180)],
	[Enum.KeyCode.Down, math.rad(90)],
]);

export function Mouse({ updateAngle, setBoost }: MouseProps) {
	const [keysDown, setKeysDown] = useState<Enum.KeyCode[]>([]);

	useEffect(() => {
		const averageAngle = keysDown.reduce<number | undefined>((acc, key) => {
			const angle = MOVE_DIRECTIONS.get(key)!;
			return lerpRadians(acc ?? angle, angle, 0.5);
		}, undefined);

		if (averageAngle !== undefined) {
			updateAngle(averageAngle);
		}
	}, [keysDown]);

	return (
		<InputCapture
			onInputChanged={(frame, input) => {
				if (input.UserInputType !== Enum.UserInputType.MouseMovement || !keysDown.isEmpty()) {
					return;
				}

				const mouse = new Vector2(input.Position.X, input.Position.Y);
				const direction = mouse.sub(frame.AbsolutePosition).sub(frame.AbsoluteSize.div(2));
				const angle = math.atan2(direction.Y, direction.X);

				updateAngle(angle);
			}}
			onInputBegan={(_, input) => {
				if (KEY_CODES.has(input.KeyCode) || KEY_CODES.has(input.UserInputType)) {
					setBoost(true);
				} else if (MOVE_DIRECTIONS.has(input.KeyCode) && !keysDown.includes(input.KeyCode)) {
					setKeysDown((keysDown) => [input.KeyCode, ...keysDown]);
				}
			}}
			onInputEnded={(_, input) => {
				if (KEY_CODES.has(input.KeyCode) || KEY_CODES.has(input.UserInputType)) {
					setBoost(false);
				} else if (MOVE_DIRECTIONS.has(input.KeyCode)) {
					setKeysDown((keysDown) => keysDown.filter((key) => key !== input.KeyCode));
				}
			}}
		/>
	);
}


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

import { useTouchMove } from "../utils/use-touch-move";

interface TouchProps {
	readonly updateAngle: (angle: number) => void;
	readonly setBoost: (boost: boolean) => void;
}

export function Touch({ updateAngle, setBoost }: TouchProps) {
	const [direction, jumping] = useTouchMove();

	useEffect(() => {
		if (direction !== Vector2.zero) {
			const angle = math.atan2(direction.Y, direction.X);
			updateAngle(angle);
		}
	}, [direction]);

	useEffect(() => {
		setBoost(jumping);
	}, [jumping]);

	return <></>;
}


import { useEffect } from "@rbxts/react";
import { UserInputService } from "@rbxts/services";

export function useToggleTouchControls(visible: boolean) {
	useEffect(() => {
		// todo: use a non-deprecated method
		UserInputService.ModalEnabled = !visible;
	}, [visible]);
}


import { useCamera, useEventListener } from "@rbxts/pretty-react-hooks";
import { useRef, useState } from "@rbxts/react";
import { Players, UserInputService } from "@rbxts/services";
import { useCharacter } from "client/hooks";

/**
 * Returns the direction the player is moving in and whether
 * or not they are jumping.
 */
export function useTouchMove() {
	const camera = useCamera();
	const character = useCharacter(Players.LocalPlayer);
	const humanoid = character?.Humanoid;

	const touchPosition = useRef(Vector2.zero);
	const [touchInput, setTouchInput] = useState<InputObject>();
	const [touchStart, setTouchStart] = useState(Vector2.zero);
	const [direction, setDirection] = useState(Vector2.zero);
	const [jumping, setJumping] = useState(false);

	const getSide = (input: InputObject): "left" | "right" => {
		return input.Position.X < camera.ViewportSize.X / 2 ? "left" : "right";
	};

	useEventListener(UserInputService.TouchStarted, (input) => {
		if (getSide(input) === "left") {
			const position = new Vector2(input.Position.X, input.Position.Y);
			setTouchInput(input);
			setTouchStart(position);
			touchPosition.current = position;
		}
	});

	useEventListener(UserInputService.TouchEnded, (input) => {
		if (input === touchInput) {
			setTouchInput(undefined);
		}

		if (getSide(input) === "right") {
			setJumping(false);
		}
	});

	useEventListener(UserInputService.TouchMoved, (input) => {
		if (input === touchInput) {
			touchPosition.current = new Vector2(input.Position.X, input.Position.Y);
		}
	});

	useEventListener(humanoid?.GetPropertyChangedSignal("MoveDirection"), () => {
		if (humanoid && humanoid.MoveDirection !== Vector3.zero) {
			const delta = touchPosition.current.sub(touchStart);
			const direction = delta !== Vector2.zero ? delta.Unit : Vector2.zero;
			setDirection(direction);
		}
	});

	useEventListener(UserInputService.JumpRequest, () => {
		setJumping(true);
	});

	return [direction, jumping] as const;
}


import React from "@rbxts/react";
import { ErrorBoundary } from "client/components/ui/error-boundary";

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

export function ErrorHandler({ children }: React.PropsWithChildren) {
	return (
		<ErrorBoundary
			fallback={(message) => {
				return <ErrorPage message={message} />;
			}}
		>
			{children}
		</ErrorBoundary>
	);
}


import React, { useEffect } from "@rbxts/react";
import { TeleportService } from "@rbxts/services";
import { Frame } from "client/components/ui/frame";
import { Group } from "client/components/ui/group";
import { Layer } from "client/components/ui/layer";
import { PrimaryButton } from "client/components/ui/primary-button";
import { Text } from "client/components/ui/text";
import { TextField } from "client/components/ui/text-field";
import { fonts } from "client/constants/fonts";
import { useRem } from "client/hooks";
import { playSound, sounds } from "shared/assets";
import { palette } from "shared/constants/palette";

interface ErrorPageProps {
	readonly message: unknown;
}

export function ErrorPage({ message }: ErrorPageProps) {
	const rem = useRem();

	let index = 0;

	useEffect(() => {
		playSound(sounds.alert_bad);
	}, []);

	return (
		<Layer>
			<Frame backgroundColor={palette.crust} size={new UDim2(1, 0, 1, 0)}>
				<uilistlayout
					FillDirection="Vertical"
					VerticalAlignment="Center"
					HorizontalAlignment="Center"
					SortOrder="LayoutOrder"
				/>

				<Text text="🐍" textSize={rem(5)} size={new UDim2(0, rem(5), 0, rem(5))} layoutOrder={index++} />

				<Text
					font={fonts.inter.bold}
					text="Oh Noes!"
					textColor={palette.text}
					textSize={rem(3)}
					textAutoResize="XY"
					layoutOrder={index++}
				/>

				<Group size={new UDim2(0, 0, 0, rem(2))} layoutOrder={index++} />

				<Text
					font={fonts.inter.regular}
					text="Something went wrong, and we were unable to recover."
					textColor={palette.text}
					textSize={rem(1.5)}
					textAutoResize="XY"
					layoutOrder={index++}
				/>

				<Group size={new UDim2(0, 0, 0, rem(0.5))} layoutOrder={index++} />

				<Text
					font={fonts.inter.regular}
					text="Please send this error to the developers, and try reconnecting:"
					textColor={palette.text}
					textSize={rem(1.5)}
					textAutoResize="XY"
					layoutOrder={index++}
				/>

				<Group size={new UDim2(0, 0, 0, rem(2))} layoutOrder={index++} />

				<TextField
					clearTextOnFocus={false}
					textEditable={false}
					font={fonts.robotoMono.regular}
					text={`${message}`}
					textColor={palette.red}
					textSize={rem(1.5)}
					textAutoResize="XY"
					textXAlignment="Left"
					maxVisibleGraphemes={512}
					backgroundColor={palette.base}
					backgroundTransparency={0}
					cornerRadius={new UDim(0, rem(1.5))}
					layoutOrder={index++}
				>
					<uipadding
						PaddingLeft={new UDim(0, rem(2))}
						PaddingRight={new UDim(0, rem(2))}
						PaddingTop={new UDim(0, rem(2))}
						PaddingBottom={new UDim(0, rem(2))}
					/>

					<uistroke Color={palette.red} Transparency={0.3} Thickness={1} ApplyStrokeMode="Border" />
				</TextField>

				<Group size={new UDim2(0, 0, 0, rem(2))} layoutOrder={index++} />

				<PrimaryButton
					onClick={async () => TeleportService.TeleportToPlaceInstance(game.PlaceId, game.JobId)}
					overlayGradient={new ColorSequence(palette.blue, palette.mauve)}
					size={new UDim2(0, rem(12), 0, rem(5))}
					layoutOrder={index++}
				>
					<Text
						font={fonts.inter.medium}
						text="Reconnect →"
						textColor={palette.base}
						textSize={rem(1.5)}
						size={new UDim2(1, 0, 1, 0)}
					/>
				</PrimaryButton>
			</Frame>
		</Layer>
	);
}


import { useEffect, useState } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { setTimeout } from "@rbxts/set-timeout";
import { selectTopSnake } from "shared/store/snakes";

/**
 * Returns the current leader's state. If the ID changed, this value
 * will be debounced to prevent jitter and excess updates.
 */
export function useLeader() {
	const currentLeader = useSelector(selectTopSnake);
	const [leader, setLeader] = useState(currentLeader);

	useEffect(() => {
		if (currentLeader?.id === leader?.id) {
			setLeader(currentLeader);
		}
	}, [currentLeader]);

	useEffect(() => {
		if (currentLeader?.id !== leader?.id) {
			return setTimeout(() => setLeader(currentLeader), 0.5);
		}
	}, [currentLeader?.id]);

	return leader;
}


import { map, useInterval } from "@rbxts/pretty-react-hooks";
import React, { Element, useState } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { CanvasGroup } from "client/components/ui/canvas-group";
import { useDefined, useStore } from "client/hooks";
import { selectSnakeFromWorldSubject } from "client/store/world";
import { selectSnakesById, selectTopSnake } from "shared/store/snakes";

import { MinimapCursor } from "./minimap-cursor";
import { MinimapTracer } from "./minimap-tracer";
import { isValidPlayer, normalizeToWorldBounds, useFriendsInServer } from "./utils";

export function MinimapNodes() {
	const store = useStore();
	const snake = useDefined(useSelector(selectSnakeFromWorldSubject));
	const friends = useFriendsInServer();

	const [nodes, setNodes] = useState<Element[]>([]);

	const update = () => {
		const nodes: Element[] = [];

		// this doesn't need useSelector so we can avoid unneeded re-renders
		const snakes = store.getState(selectSnakesById);
		const topSnake = store.getState(selectTopSnake);

		for (const [, snake] of pairs(snakes)) {
			const size = snake.tracers.size();
			const step = math.floor(map(size, 0, 100, 2, 10));
			let previous = snake.head;

			const isPlayer = isValidPlayer(snake.id);
			const isFriend = friends.includes(snake.id);
			const isLeader = topSnake?.id === snake.id;

			for (const index of $range(0, size - 1, step)) {
				const tracer = snake.tracers[index];

				nodes.push(
					<MinimapTracer
						key={`${snake.id}-${index}`}
						from={normalizeToWorldBounds(previous)}
						to={normalizeToWorldBounds(tracer)}
						isPlayer={isPlayer}
						isFriend={isFriend}
						isLeader={isLeader}
					/>,
				);

				previous = tracer;
			}
		}

		setNodes(nodes);
	};

	useInterval(update, 2, { immediate: true });

	return (
		<>
			<CanvasGroup
				groupTransparency={0.5}
				backgroundTransparency={1}
				cornerRadius={new UDim(1, 0)}
				size={new UDim2(1, 0, 1, 0)}
			>
				{nodes}
			</CanvasGroup>

			{snake && <MinimapCursor point={normalizeToWorldBounds(snake.head)} angle={snake.angle} />}
		</>
	);
}


import React, { memo } from "@rbxts/react";
import { Image } from "client/components/ui/image";
import { images } from "shared/assets";
import { palette } from "shared/constants/palette";

import { useMinimapRem } from "./utils";

interface MinimapTracerProps {
	readonly from: Vector2;
	readonly to: Vector2;
	readonly isPlayer: boolean;
	readonly isFriend: boolean;
	readonly isLeader: boolean;
}

function MinimapTracerComponent({ from, to, isPlayer, isFriend, isLeader }: MinimapTracerProps) {
	const rem = useMinimapRem();
	const center = from.add(to).div(2);
	const length = from.sub(to).Magnitude;
	const color = isLeader
		? palette.yellow
		: isFriend
			? palette.sapphire
			: isPlayer
				? palette.lavender
				: palette.surface2;

	return (
		<Image
			image={images.ui.circle}
			imageColor={color}
			scaleType="Slice"
			sliceCenter={new Rect(128, 128, 128, 128)}
			anchorPoint={new Vector2(0.5, 0.5)}
			size={new UDim2(length, rem(0.2), 0, rem(0.2))}
			position={new UDim2(center.X, 0, center.Y, 0)}
			rotation={math.deg(math.atan2(to.Y - from.Y, to.X - from.X))}
		/>
	);
}

export const MinimapTracer = memo(MinimapTracerComponent);


import React from "@rbxts/react";
import { Frame } from "client/components/ui/frame";
import { Group } from "client/components/ui/group";
import { Image } from "client/components/ui/image";
import { Shadow } from "client/components/ui/shadow";
import { useInputDevice } from "client/hooks";
import { RemProvider } from "client/providers/rem-provider";
import { images } from "shared/assets";
import { palette } from "shared/constants/palette";

import { MinimapNodes } from "./minimap-nodes";
import { MINIMUM_MINIMAP_REM, useMinimapRem } from "./utils";

export function Minimap() {
	const rem = useMinimapRem();
	const touch = useInputDevice() === "touch";

	const { anchorPoint, position } = touch
		? { anchorPoint: new Vector2(1, 0.5), position: new UDim2(1, -rem(3.5), 0.5, 0) }
		: { anchorPoint: new Vector2(1, 1), position: new UDim2(1, -rem(4), 1, -rem(4)) };

	return (
		<RemProvider minimumRem={MINIMUM_MINIMAP_REM}>
			<Group anchorPoint={anchorPoint} size={new UDim2(0, rem(10), 0, rem(10))} position={position}>
				<Shadow
					shadowColor={palette.black}
					shadowSize={rem(5)}
					shadowPosition={rem(1.5)}
					shadowTransparency={0}
				/>

				<Frame backgroundColor={palette.white} cornerRadius={new UDim(1, 0)} size={new UDim2(1, 0, 1, 0)}>
					<uigradient
						Color={new ColorSequence(palette.crust, palette.mantle)}
						Transparency={new NumberSequence(0.3, 0.1)}
						Rotation={-45}
					/>
					<uistroke Color={palette.lavender} Transparency={0.9} Thickness={rem(0.25)} />
				</Frame>

				<Frame
					backgroundTransparency={1}
					cornerRadius={new UDim(1, 0)}
					size={new UDim2(1, -2, 1, -2)}
					position={new UDim2(0, 1, 0, 1)}
				>
					<uistroke Color={palette.text} Transparency={0.85} Thickness={rem(0.05)} />
				</Frame>

				<Image
					image={images.ui.map_crosshair}
					anchorPoint={new Vector2(0.5, 0.5)}
					size={new UDim2(0, rem(1), 0, rem(1))}
					position={new UDim2(0.5, 0, 0.5, 0)}
				/>

				<MinimapNodes />
			</Group>
		</RemProvider>
	);
}


import { map, useInterval } from "@rbxts/pretty-react-hooks";
import { useState } from "@rbxts/react";
import { Players } from "@rbxts/services";
import { useRem } from "client/hooks";
import { USER_ID, WORLD_BOUNDS } from "shared/constants/core";

export const MINIMUM_MINIMAP_REM = 10;

export function useMinimapRem() {
	return useRem({ minimum: MINIMUM_MINIMAP_REM });
}

export function useFriendsInServer() {
	const [friends, setFriends] = useState<string[]>([]);

	const updateFriendship = async (player: Player) => {
		if (!friends.includes(player.Name) && player.IsFriendsWith(USER_ID)) {
			setFriends((friends) => [...friends, player.Name]);
		}
	};

	const updateFriends = async () => {
		const processes = Players.GetPlayers().map(updateFriendship);
		return Promise.allSettled(processes);
	};

	useInterval(updateFriends, 10, { immediate: true });

	return friends;
}

export function isValidPlayer(name: string) {
	return Players.FindFirstChild(name)?.IsA("Player") || false;
}

export function normalizeToWorldBounds(vector: Vector2) {
	return new Vector2(
		map(vector.X, -WORLD_BOUNDS, WORLD_BOUNDS, 0, 1),
		map(vector.Y, -WORLD_BOUNDS, WORLD_BOUNDS, 0, 1),
	);
}


import React from "@rbxts/react";
import { Text } from "client/components/ui/text";
import { fonts } from "client/constants/fonts";
import { useRem } from "client/hooks";
import { palette } from "shared/constants/palette";

export function HomeFooter() {
	const rem = useRem();

	return (
		<Text
			richText
			font={fonts.inter.medium}
			text={`Made with <font transparency="0">❤</font> by <font transparency="0" color="#${palette.offwhite.ToHex()}">littensy</font>`}
			textColor={palette.text}
			textTransparency={0.5}
			textSize={rem(1.25)}
			textXAlignment="Center"
			textYAlignment="Bottom"
			position={new UDim2(0.5, 0, 1, -rem(3))}
		/>
	);
}


import { useTimer } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { Image } from "client/components/ui/image";
import { useRem } from "client/hooks";
import { images } from "shared/assets";

import { gradientPinched } from "./utils";

interface HomeTitleProps {
	readonly position: UDim2;
}

export function HomeTitle({ position }: HomeTitleProps) {
	const rem = useRem();
	const timer = useTimer();
	const rotation = timer.value.map((t) => (t * 45) % 360);

	return (
		<Image
			image={images.ui.menu_title}
			scaleType="Fit"
			anchorPoint={new Vector2(0.5, 0.5)}
			size={new UDim2(0, rem(30), 0, rem(20))}
			position={position}
		>
			<uiaspectratioconstraint AspectRatio={1014 / 544} />
			<uigradient Color={gradientPinched} Rotation={rotation} />
		</Image>
	);
}


import React from "@rbxts/react";
import { Text } from "client/components/ui/text";
import { fonts } from "client/constants/fonts";
import { useRem } from "client/hooks";
import { IS_PROD } from "shared/constants/core";
import { palette } from "shared/constants/palette";

interface HomeVersionProps {
	readonly position: UDim2;
}

const DIVIDER = `  <font transparency="0.75">—</font>  `;
const REPO = "littensy/slither";
const MODE = IS_PROD ? "production" : "development";

export function HomeVersion({ position }: HomeVersionProps) {
	const rem = useRem();

	return (
		<Text
			richText
			font={fonts.inter.medium}
			text={REPO + DIVIDER + MODE}
			textSize={rem(1.25)}
			textColor={palette.text}
			textTransparency={0.5}
			textXAlignment="Center"
			textYAlignment="Top"
			position={position}
		/>
	);
}


import React from "@rbxts/react";
import { Group } from "client/components/ui/group";
import { useRem } from "client/hooks";

import { HomeFooter } from "./home-footer";
import { HomeTitle } from "./home-title";
import { HomeVersion } from "./home-version";
import { MuteButton } from "./mute-button";
import { PlayButton } from "./play-button";
import { SpectateButton } from "./spectate-button";

export function Home() {
	const rem = useRem();

	return (
		<>
			<HomeTitle position={new UDim2(0.5, 0, 0.4, rem(-3))} />
			<HomeVersion position={new UDim2(0.5, 0, 0.5, rem(6.5))} />
			<HomeFooter />

			<PlayButton
				anchorPoint={new Vector2(0.5, 0.5)}
				size={new UDim2(0, rem(18), 0, rem(4.5))}
				position={new UDim2(0.5, 0, 0.5, rem(2))}
			/>

			<Group anchorPoint={new Vector2(1, 1)} size={new UDim2()} position={new UDim2(1, rem(-3), 1, rem(-3))}>
				<uilistlayout
					Padding={new UDim(0, rem(1))}
					VerticalAlignment="Bottom"
					HorizontalAlignment="Right"
					FillDirection="Horizontal"
				/>
				<SpectateButton />
				<MuteButton />
			</Group>
		</>
	);
}


import React from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { PrimaryButton } from "client/components/ui/primary-button";
import { Text } from "client/components/ui/text";
import { fonts } from "client/constants/fonts";
import { useRem, useStore } from "client/hooks";
import { selectMusicEnabled } from "client/store/menu";
import { palette } from "shared/constants/palette";

export function MuteButton() {
	const rem = useRem();
	const store = useStore();
	const musicEnabled = useSelector(selectMusicEnabled);

	return (
		<PrimaryButton
			onClick={() => store.setMenuMusic(!musicEnabled)}
			overlayGradient={new ColorSequence(musicEnabled ? palette.text : palette.maroon)}
			size={new UDim2(0, rem(4), 0, rem(4))}
		>
			<Text
				font={fonts.inter.medium}
				text={musicEnabled ? "🔊" : "🔇"}
				textSize={rem(2)}
				size={new UDim2(1, 0, 1, 0)}
			/>
		</PrimaryButton>
	);
}


import { useThrottleCallback } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { PrimaryButton } from "client/components/ui/primary-button";
import { Text } from "client/components/ui/text";
import { fonts } from "client/constants/fonts";
import { useRem, useStore } from "client/hooks";
import { selectWorldSpectating } from "client/store/world";
import { palette } from "shared/constants/palette";
import { cycleNextSnake } from "shared/store/snakes";

export function SpectateButton() {
	const rem = useRem();
	const store = useStore();
	const spectating = useSelector(selectWorldSpectating);

	const onClick = useThrottleCallback(
		() => {
			store.setWorldSpectating(store.getState(cycleNextSnake(spectating)));
		},
		{ wait: 0.5, trailing: false },
	);

	return (
		<PrimaryButton
			onClick={onClick.run}
			overlayGradient={new ColorSequence(palette.text)}
			size={new UDim2(0, rem(4), 0, rem(4))}
		>
			<Text font={fonts.inter.medium} text="🎥" textSize={rem(2)} size={new UDim2(1, 0, 1, 0)} />
		</PrimaryButton>
	);
}


import React, { useEffect } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { useStore } from "client/hooks";
import { selectHasLocalSnake } from "shared/store/snakes";

import { Home } from "./home";
import { MenuContainer } from "./menu-container";
import { MenuVignette } from "./menu-vignette";
import { Navigation } from "./navigation";
import { Skins } from "./skins";
import { Support } from "./support";

export function Menu() {
	const store = useStore();
	const spawned = useSelector(selectHasLocalSnake);

	useEffect(() => {
		store.setMenuOpen(!spawned);
	}, [spawned]);

	return (
		<>
			<MenuVignette />

			<MenuContainer>
				<Navigation />
			</MenuContainer>

			<MenuContainer page="home">
				<Home />
			</MenuContainer>

			<MenuContainer page="support">
				<Support />
			</MenuContainer>

			<MenuContainer page="skins">
				<Skins />
			</MenuContainer>
		</>
	);
}


import React from "@rbxts/react";
import { Group } from "client/components/ui/group";
import { useRem } from "client/hooks";
import { images } from "shared/assets";
import { palette } from "shared/constants/palette";

import { Destination } from "./destination";
import { Indicator } from "./indicator";

export function Navigation() {
	const rem = useRem();

	let index = 0;

	return (
		<Group size={new UDim2(1, 0, 0, rem(7.5))}>
			<Indicator colors={[palette.red, palette.mauve, palette.blue]} order={["support", "home", "skins"]} />

			<Group size={new UDim2(1, 0, 0, rem(5))} position={new UDim2(0, 0, 0, rem(3))}>
				<uilistlayout
					SortOrder="LayoutOrder"
					FillDirection="Horizontal"
					VerticalAlignment="Center"
					HorizontalAlignment="Center"
					Padding={new UDim(0, rem(1))}
				/>

				<Destination
					page="support"
					label="Support"
					color={palette.red}
					icon={images.ui.nav_heart}
					iconAlt={images.ui.nav_heart_alt}
					order={index++}
				/>

				<Destination
					page="home"
					label="Home"
					color={palette.mauve}
					icon={images.ui.nav_home}
					iconAlt={images.ui.nav_home_alt}
					order={index++}
				/>

				<Destination
					page="skins"
					label="Skins"
					color={palette.blue}
					icon={images.ui.nav_skins}
					iconAlt={images.ui.nav_skins_alt}
					order={index++}
				/>
			</Group>
		</Group>
	);
}


import React, { useEffect } from "@rbxts/react";
import { useSelector, useSelectorCreator } from "@rbxts/react-reflex";
import { Group } from "client/components/ui/group";
import { useRem, useStore } from "client/hooks";
import { selectMenuCurrentSkin } from "client/store/menu";
import { USER_NAME } from "shared/constants/core";
import { snakeSkins } from "shared/constants/skins";
import { RANDOM_SKIN, selectCurrentPlayerSkin, selectPlayerSkins } from "shared/store/saves";

import { SkinCard } from "./skin-card";
import { DIRECTIONS } from "./utils";

const SKIN_LIST = [RANDOM_SKIN, ...snakeSkins.map((skin) => skin.id)];
const SKIN_LENGTH = SKIN_LIST.size();

export function SkinCarousel() {
	const rem = useRem();
	const store = useStore();

	const skinInventory = useSelectorCreator(selectPlayerSkins, USER_NAME) || [];
	const equippedSkin = useSelectorCreator(selectCurrentPlayerSkin, USER_NAME) ?? RANDOM_SKIN;
	const currentSkin = useSelector(selectMenuCurrentSkin);

	const currentIndex = SKIN_LIST.indexOf(currentSkin);

	useEffect(() => {
		if (currentSkin === RANDOM_SKIN) {
			store.setMenuSkin(equippedSkin);
		}
	}, []);

	return (
		<Group size={new UDim2(1, 0, 1, -rem(3))}>
			{DIRECTIONS.map((direction) => {
				const index = (currentIndex + direction) % SKIN_LENGTH;
				const skin = SKIN_LIST[index] ?? RANDOM_SKIN;

				return (
					<SkinCard
						key={skin}
						id={skin}
						index={direction}
						active={skin === currentSkin}
						shuffle={skin === RANDOM_SKIN ? skinInventory : undefined}
						onClick={() => {
							store.setMenuSkin(skin);
						}}
					/>
				);
			})}
		</Group>
	);
}


import React from "@rbxts/react";

import { ActButton } from "./act-button";
import { SkinCarousel } from "./skin-carousel";

export function Skins() {
	return (
		<>
			<ActButton />
			<SkinCarousel />
		</>
	);
}


import { useInterval } from "@rbxts/pretty-react-hooks";
import { useState } from "@rbxts/react";
import { getSnakeSkin, SnakeSkin } from "shared/constants/skins";
import { darken } from "shared/utils/color-utils";

export interface SnakePalette {
	readonly skin: SnakeSkin;
	readonly primary: Color3;
	readonly secondary: Color3;
}

export const DIRECTIONS = [-3, -2, -1, 0, 1, 2, 3];
export const DIRECTIONS_TO_HIDE = [-3, 3];

export function usePalette(id: string, shuffle?: readonly string[]): SnakePalette {
	const [skin, setSkin] = useState(getSnakeSkin(id));

	useInterval(() => {
		if (shuffle && !shuffle.isEmpty()) {
			const skinId = shuffle[math.random(0, shuffle.size() - 1)];
			setSkin(getSnakeSkin(skinId));
		}
	}, 1);

	return {
		skin,
		primary: skin.primary || darken(skin.tint[0], 0.5, 0.4),
		secondary: skin.secondary || darken(skin.tint[0], 0.7, 0.4),
	};
}


import React from "@rbxts/react";
import { Text } from "client/components/ui/text";
import { fonts } from "client/constants/fonts";
import { useRem } from "client/hooks";
import { palette } from "shared/constants/palette";

const colorize = (text: string, color: Color3) => {
	return `<font transparency="0" color="#${color.ToHex()}">${text}</font>`;
};

export function SupportFooter() {
	const rem = useRem();

	return (
		<Text
			richText
			font={fonts.inter.medium}
			text={[
				"Your support helps us build",
				[
					colorize("m", palette.red),
					colorize("a", palette.peach),
					colorize("g", palette.yellow),
					colorize("i", palette.green),
					colorize("c", palette.sapphire),
					colorize("a", palette.blue),
					colorize("l", palette.mauve),
				].join(""),
				'experiences ✨',
			].join(" ")}
			textColor={palette.text}
			textTransparency={0.5}
			textSize={rem(1.25)}
			textXAlignment="Center"
			textYAlignment="Bottom"
			position={new UDim2(0.5, 0, 1, -rem(3))}
		/>
	);
}


import React from "@rbxts/react";
import { MarketplaceService, Players } from "@rbxts/services";
import { sendAlert } from "client/alerts";
import { PrimaryButton } from "client/components/ui/primary-button";
import { Text } from "client/components/ui/text";
import { useRem } from "client/hooks";
import { palette } from "shared/constants/palette";

export function SupportPremium() {
	const rem = useRem();
	const promptPremiumPurchase = async () => {
		if (Players.LocalPlayer.MembershipType === Enum.MembershipType.Premium) {
			sendAlert({
				emoji: "💎",
				message:
					"You get 20% more money because of Premium benefits!",
				color: palette.sapphire,
				colorSecondary: palette.blue,
			});
		} else {
			MarketplaceService.PromptPremiumPurchase(Players.LocalPlayer);
		}
	};

	return (
		<PrimaryButton
			onClick={promptPremiumPurchase}
			anchorPoint={new Vector2(1, 1)}
			position={new UDim2(1, rem(-3), 1, rem(-3))}
			size={new UDim2(0, rem(4), 0, rem(4))}
			overlayGradient={new ColorSequence(palette.red, palette.blue)}
		>
			<Text
				position={new UDim2(0.5, 0, 0.5, 0)}
				textSize={rem(2)}
				textColor={palette.black}
				text={`${RobloxEmoji.Premium} `}
			/>
		</PrimaryButton>
	);
}