import { combineHumanReadable } from "@antivivi/vrldk";
import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { Server } from "shared/api/APIExpose";
import Formula from "shared/currency/Formula";
import { Identifiable, ModuleRegistry } from "shared/hamster/ModuleRegistry";

export type DifficultyRewardPrice = FlatDifficultyPowerCost | PercentageOfDifficultyPowerPrice;

export interface FlatDifficultyPowerCost {
    kind: "flatDifficultyPower";
    amount: OnoeNum;
}

export interface PercentageOfDifficultyPowerPrice {
    kind: "percentageOfDifficultyPower";
    percentage: number;
    minimum?: OnoeNum;
}

export interface WalkSpeedBuffEffect {
    kind: "walkSpeedBuff";
    amount: number;
    durationSeconds: number;
}

export interface GrantItemEffect {
    kind: "grantItem";
    itemId: string;
    amount?: number;
}

export interface RedeemRevenueEffect {
    kind: "redeemRevenue";
    seconds: number;
    currencies: Currency[];
}

export interface IncreaseDifficultyPowerEffect {
    kind: "increaseDifficultyPower";
    add?: OnoeNum;
    mul?: OnoeNum;
}

export interface IncreaseDifficultyPowerFormulaEffect {
    kind: "increaseDifficultyPowerFormula";
    formula: Formula;
    x: string;
    xCap?: OnoeNum;
}

export interface ForgeItemEffect {
    kind: "forgeItem";
    itemId: string;
    amount?: number;
}

export type DifficultyRewardEffect =
    | WalkSpeedBuffEffect
    | GrantItemEffect
    | RedeemRevenueEffect
    | IncreaseDifficultyPowerEffect
    | IncreaseDifficultyPowerFormulaEffect
    | ForgeItemEffect;

export default class DifficultyReward extends Identifiable {
    static readonly REGISTRY = new ModuleRegistry<DifficultyReward>(script.Parent!, new Set([script]));

    title?: string;
    description?: string;
    icon?: string;
    viewportItemId?: string;
    cooldownSeconds?: number;
    price?: DifficultyRewardPrice;
    readonly effects = new Set<DifficultyRewardEffect>();
    maxClaims?: number;
    layoutOrder = 0;

    constructor(
        readonly id: string,
        readonly difficulty: Difficulty,
    ) {
        super(id);
    }

    setTitle(title: string) {
        this.title = title;
        return this;
    }

    setDescription(description: string) {
        this.description = description;
        return this;
    }

    setIcon(icon: string) {
        this.icon = icon;
        return this;
    }

    setViewportItemId(viewportItemId: string) {
        this.viewportItemId = viewportItemId;
        return this;
    }

    setCooldownSeconds(cooldownSeconds: number) {
        this.cooldownSeconds = cooldownSeconds;
        return this;
    }

    setPrice(price: DifficultyRewardPrice) {
        this.price = price;
        return this;
    }

    addEffect(effect: DifficultyRewardEffect) {
        this.effects.add(effect);
        return this;
    }

    setMaxClaims(maxClaims: number) {
        this.maxClaims = maxClaims;
        return this;
    }

    setLayoutOrder(layoutOrder: number) {
        this.layoutOrder = layoutOrder;
        return this;
    }

    override init() {
        return () => {};
    }

    getPriceLabel(currentDifficultyPower: OnoeNum) {
        if (this.price === undefined) {
            return $tuple("Free!", new OnoeNum(0));
        }

        if (this.price.kind === "percentageOfDifficultyPower") {
            let cost = currentDifficultyPower.mul(new OnoeNum(this.price.percentage));
            if (this.price.minimum !== undefined) {
                const minimum = new OnoeNum(this.price.minimum);
                if (cost.lessThan(minimum)) {
                    cost = minimum;
                }
            }
            if (cost.lessEquals(0)) {
                return $tuple("Free!", cost);
            }

            return $tuple(
                `${math.floor(this.price.percentage * 100 * 100) / 100}% of your Difficulty Power (${OnoeNum.toString(cost)})`,
                cost,
            );
        } else if (this.price.kind === "flatDifficultyPower") {
            return $tuple(`${OnoeNum.toString(this.price.amount)} Difficulty Power`, this.price.amount);
        }

        return $tuple("Free!", new OnoeNum(0));
    }

    static getEffectLabel(effect: DifficultyRewardEffect) {
        switch (effect.kind) {
            case "walkSpeedBuff": {
                const durationText = this.formatDurationShort(effect.durationSeconds);
                return `Effect: +${effect.amount} walkspeed for ${durationText}.`;
            }
            case "grantItem": {
                const amount = effect.amount ?? 1;
                const item = Server.Items.itemsPerId.get(effect.itemId);
                const itemName = item?.name ?? effect.itemId;
                return `Reward: ${itemName} x${amount}.`;
            }
            case "redeemRevenue": {
                const durationText = this.formatDurationShort(effect.seconds);
                const currencies = combineHumanReadable(...effect.currencies);
                return `Reward: Redeem ${durationText} of ${currencies}.`;
            }
            case "increaseDifficultyPower": {
                const operationLabels = new Array<string>();
                if (effect.add !== undefined && effect.add.moreThan(0)) {
                    operationLabels.push(`+${OnoeNum.toString(effect.add)}`);
                }
                if (effect.mul !== undefined && effect.mul.moreThan(0)) {
                    operationLabels.push(`x${OnoeNum.toString(effect.mul)}`);
                }
                const combined = combineHumanReadable(...operationLabels);

                return `Reward: ${combined} Difficulty Power per furnace process.`;
            }
            case "increaseDifficultyPowerFormula": {
                const formulaString = effect.formula.tostring(effect.x);
                return `Reward: Multiply Difficulty Power by ${formulaString} per furnace process.`;
            }
            case "forgeItem": {
                const amount = effect.amount ?? 1;
                const item = Server.Items.itemsPerId.get(effect.itemId);
                const itemName = item?.name ?? effect.itemId;
                const quantitySuffix = amount > 1 ? ` x${amount}` : "";
                return `Reward: Forge ${itemName}${quantitySuffix} (consumes recipe cost).`;
            }
            default:
                return "Unknown effect";
        }
    }

    getEffectsLabel() {
        const labels = new Array<string>();
        this.effects.forEach((effect) => {
            labels.push(DifficultyReward.getEffectLabel(effect));
        });
        return labels.join("\n");
    }

    static formatDurationShort(seconds: number) {
        if (seconds <= 0) return "0s";
        const rounded = math.floor(seconds);
        const minutes = math.floor(rounded / 60);
        const remainder = rounded % 60;
        if (minutes > 0) {
            return `${minutes}m ${remainder}s`;
        }
        return `${remainder}s`;
    }

    /**
     * Sets up the difficulty rewards registry and provides utility functions to access them.
     */
    static setupDifficultyRewards() {
        const perDifficulty = new Map<string, DifficultyReward[]>();
        const byId = new Map<string, DifficultyReward>();

        const definitions = this.REGISTRY.load();
        for (const [id, definition] of definitions) {
            let bucket = perDifficulty.get(definition.difficulty.id);
            if (bucket === undefined) {
                bucket = new Array<DifficultyReward>();
                perDifficulty.set(definition.difficulty.id, bucket);
            }
            bucket.push(definition);
            byId.set(id, definition);
        }

        for (const [, bucket] of perDifficulty) {
            bucket.sort((a, b) => a.layoutOrder < b.layoutOrder);
        }

        function getDifficultyRewards(difficulty: Difficulty | undefined) {
            if (difficulty === undefined) {
                return new Array<DifficultyReward>();
            }
            const bucket = perDifficulty.get(difficulty.id);
            return bucket !== undefined ? bucket : new Array<DifficultyReward>();
        }

        function getDifficultyRewardById(id: string) {
            return byId.get(id);
        }

        return {
            all: definitions,
            perDifficulty,
            byId,
            getDifficultyRewards,
            getDifficultyRewardById,
        };
    }
}


import Difficulty from "@rbxts/ejt";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";
import WhiteGem from "shared/items/excavation/WhiteGem";

export = new DifficultyReward(script.Name, Difficulty.A)
    .setTitle("Gems for All")
    .setDescription("Redeem 1 White Gem every 5 minutes at no cost.")
    .setViewportItemId(WhiteGem.id)
    .setCooldownSeconds(5 * 60)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0,
    })
    .addEffect({
        kind: "grantItem",
        itemId: WhiteGem.id,
        amount: 1,
    });


import Difficulty from "@rbxts/ejt";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.Friendliness)
    .setTitle("Friendly Difficulty Surge")
    .setDescription("Immediately receive 15 seconds worth of Difficulty Power.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(45)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0,
    })
    .addEffect({
        kind: "redeemRevenue",
        seconds: 15,
        currencies: ["Difficulty Power"],
    });


import Difficulty from "@rbxts/ejt";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";
import CrystalIngot from "shared/items/negative/unimpossible/CrystalIngot";

export = new DifficultyReward(script.Name, Difficulty.Negativity)
    .setTitle("Crystal Forge")
    .setDescription("Forge a Crystal Ingot by fusing 6 Crystals.")
    .setViewportItemId(CrystalIngot.id)
    .setCooldownSeconds(15)
    .setLayoutOrder(1)
    .addEffect({
        kind: "forgeItem",
        itemId: CrystalIngot.id,
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.Negativity)
    .setTitle("Negativity Mega Boost")
    .setDescription("Claim a permanent +100 Difficulty Power bonus per furnace process.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(3)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(3000),
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        add: new OnoeNum(100),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";
import BasicNanobot from "shared/items/negative/negativity/BasicNanobot";

export = new DifficultyReward(script.Name, Difficulty.Negativity)
    .setTitle("Negativity Nanobot")
    .setDescription("Redeem a Basic Nanobot to automatically repair your items.")
    .setViewportItemId(BasicNanobot.id)
    .setCooldownSeconds(5 * 60)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0.75,
        minimum: new OnoeNum(1_000),
    })
    .addEffect({
        kind: "grantItem",
        itemId: BasicNanobot.id,
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import Formula from "shared/currency/Formula";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.Negativity)
    .setTitle("Negativity Power Scaling")
    .setDescription(
        "Claim a multiplicative boost to Difficulty Power that scales with your current Difficulty Power (capped at 10000).",
    )
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(2)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(2500),
    })
    .addEffect({
        kind: "increaseDifficultyPowerFormula",
        formula: new Formula().add(1).log(5).add(1),
        x: "difficultyPower",
        xCap: new OnoeNum(10000),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.Negativity)
    .setTitle("Negativity Supercharger")
    .setDescription("Claim a permanent x5 Difficulty Power multiplier. This is a one-time purchase.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(4)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(5000),
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        mul: new OnoeNum(5),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import Formula from "shared/currency/Formula";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.Negativity)
    .setTitle("Negativity Time Mastery")
    .setDescription(
        "Claim a multiplicative boost to Difficulty Power that scales with extended playtime (up to 10000 seconds).",
    )
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(5)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(4000),
    })
    .addEffect({
        kind: "increaseDifficultyPowerFormula",
        formula: new Formula().add(10).log(8).add(1),
        x: "playtime",
        xCap: new OnoeNum(10000),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheFirstDifficulty)
    .setTitle("Candy-Coated Consultation")
    .setDescription(
        "Trade a portion of your Difficulty Power for a kit that redeems 30 seconds of offline revenue when used.",
    )
    .setIcon(getAsset("assets/CandyResearchKit.png"))
    .setCooldownSeconds(60)
    .setLayoutOrder(9)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0.5,
        minimum: new OnoeNum(1),
    })
    .addEffect({
        kind: "grantItem",
        itemId: "CandyResearchKit",
        amount: 1,
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheFirstDifficulty)
    .setTitle("First Multiplier")
    .setDescription("Claim a permanent x1.5 Difficulty Power multiplier.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(1)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(50),
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        mul: new OnoeNum(1.5),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheFirstDifficulty)
    .setTitle("First Power Boost")
    .setDescription("Claim a permanent +2 Difficulty Power bonus per furnace process.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(2)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(25),
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        add: new OnoeNum(2),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheFirstDifficulty)
    .setTitle("The First Research")
    .setDescription("Increase Difficulty Power gain by 1 in exchange for 5% of your current Difficulty Power.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(5)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0.05,
        minimum: new OnoeNum(10),
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        add: new OnoeNum(1),
    });


import Difficulty from "@rbxts/ejt";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheLowerGap)
    .setTitle("Gap Momentum")
    .setDescription("Gain +2 walkspeed for one minute to sprint through The Lower Gap at no cost.")
    .setIcon(getAsset("assets/Speed.png"))
    .setCooldownSeconds(80)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0,
    })
    .addEffect({
        kind: "walkSpeedBuff",
        amount: 2,
        durationSeconds: 60,
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheLowerGap)
    .setTitle("Gap Research Rush")
    .setDescription("Claim a permanent x2 Difficulty Power research boost. This reward can only be taken twice.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(2)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(100),
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        mul: new OnoeNum(2),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import Formula from "shared/currency/Formula";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TheLowerGap)
    .setTitle("Gap Time Investment")
    .setDescription("Claim a multiplicative boost to Difficulty Power that scales with playtime (up to 1000 seconds).")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(0)
    .setMaxClaims(1)
    .setLayoutOrder(2)
    .setPrice({
        kind: "flatDifficultyPower",
        amount: new OnoeNum(200),
    })
    .addEffect({
        kind: "increaseDifficultyPowerFormula",
        formula: new Formula().add(1).log(10).add(1),
        x: "playtime",
        xCap: new OnoeNum(1000),
    });


import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.TrueEase)
    .setTitle("Easeful Ignition")
    .setDescription("Permanently increase furnace Difficulty Power gain by 1000 whenever a droplet is processed.")
    .setIcon(getAsset("assets/DifficultyPower.png"))
    .setCooldownSeconds(60)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0,
    })
    .addEffect({
        kind: "increaseDifficultyPower",
        add: new OnoeNum(1000),
    });


import Difficulty from "@rbxts/ejt";
import { getAsset } from "shared/asset/AssetMap";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";

export = new DifficultyReward(script.Name, Difficulty.Unimpossible)
    .setTitle("Excavation Stockpile")
    .setDescription("Synthesize a Stone every minute to fuel your digs.")
    .setIcon(getAsset("assets/MiscellaneousDifficulty.png"))
    .setViewportItemId("ExcavationStone")
    .setCooldownSeconds(60)
    .setPrice({
        kind: "percentageOfDifficultyPower",
        percentage: 0,
    })
    .addEffect({
        kind: "grantItem",
        itemId: "ExcavationStone",
    });


import { Janitor } from "@rbxts/janitor";
import { RunService } from "@rbxts/services";
import { Environment } from "@rbxts/ui-labs";

export const eater = {
    janitor: Environment.GetJanitor() as Janitor<void> | undefined,
    isEdit: !RunService.IsRunning() || (RunService.IsServer() && RunService.IsClient()),
};

const IS_EDIT = eater.isEdit;

/**
 * Utility function to eat an object by adding it to the janitor for automatic cleanup.
 * @param object The object to be cleaned up
 * @param methodName The method to call on cleanup, or true to call the object as a function
 * @param index The index to store the object under in the janitor
 * @returns The original object for convenience
 */
export default function eat<
    O extends keyof U extends never
        ? object
        : I extends keyof U
          ? U[I]
          : M extends true
            ? Callback | thread
            : M extends undefined
              ? RBXScriptConnection | { Destroy(): void }
              : object,
    M extends undefined | ((this: O) => void) | ((_: O) => void) | ExtractKeys<O, () => void> | true,
    I extends keyof U | undefined = undefined,
    U extends object | void = void,
>(object: O, methodName?: M, index?: I): O {
    const janitor = eater.janitor as Janitor<U> | undefined;

    if (janitor === undefined || janitor.Add === undefined) {
        if (!IS_EDIT) return object;

        // Janitor doesn't exist anymore, so create a temporary one to clean up this object
        const tempJanitor = new Janitor<U>();
        tempJanitor.Add(object, methodName, index);
        eater.janitor = tempJanitor;
        task.delay(5, () => {
            tempJanitor.Destroy();
        });
        return object;
    }

    return janitor.Add(object, methodName, index) ?? object;
}

/**
 * Takes a snapshot of an instance and registers it with the janitor for automatic restoration.
 * When the janitor is cleaned up, the instance will be restored to its original state.
 *
 * @param instance The instance to snapshot
 * @returns The original instance for convenience
 */
export function eatSnapshot<T extends Instance>(instance: T): T {
    const snapshot = instance.Clone() as T;
    const parent = instance.Parent;
    if (parent === undefined) return instance; // Cannot snapshot an instance without a parent

    eat(() => {
        instance.Destroy();
        snapshot.Parent = parent;
    });

    return instance;
}


import { Players, Workspace } from "@rbxts/services";
import { IS_EDIT } from "shared/Context";
import eat from "shared/hamster/eat";

let mockBackpack: Backpack | undefined;
if (IS_EDIT && Players.LocalPlayer === undefined) {
    mockBackpack = new Instance("Backpack");
    mockBackpack.Name = "Backpack";
    mockBackpack.Parent = Workspace;
    eat(mockBackpack, "Destroy");
}

/**
 * Gets the backpack of the specified player or the local player.
 * @param player The player whose backpack to get. If undefined, gets the local player's backpack.
 * @returns The player's backpack, or undefined if not found.
 */
export default function getPlayerBackpack(player?: Player): Backpack | undefined {
    return mockBackpack ?? (player ?? Players.LocalPlayer)?.FindFirstChildOfClass("Backpack");
}


import { Players, Workspace } from "@rbxts/services";
import { IS_EDIT } from "shared/Context";

/**
 * Equivalent to `player.Character`, but handles edge cases in Edit mode and when player is undefined.
 * @param player The player whose character to get. If undefined, gets the local player's character.
 * @returns The character model, or undefined if not loaded.
 */
export function getPlayerCharacter(player?: Player) {
    if (player === undefined) {
        const localPlayer = Players.LocalPlayer;
        if (localPlayer === undefined) {
            if (IS_EDIT) {
                return Workspace.FindFirstChild("Player") as Model | undefined;
            } else {
                return undefined;
            }
        }
        return localPlayer.Character;
    }

    return player.Character;
}

/**
 * Gets all player characters in the game.
 * @returns An array of character models.
 */
export function getAllPlayerCharacters() {
    const characters = new Array<Model>();
    for (const player of Players.GetPlayers()) {
        const character = getPlayerCharacter(player);
        if (character) {
            characters.push(character);
        }
    }
    if (IS_EDIT && Players.LocalPlayer === undefined) {
        const character = getPlayerCharacter();
        if (character) {
            characters.push(character);
        }
    }
    return characters;
}


import { Flamework, Modding, OnInit, OnPhysics, OnRender, OnStart, OnTick, Reflect } from "@flamework/core";
import { isConstructor } from "@flamework/core/out/utility";
import { Players, RunService } from "@rbxts/services";

export default function manuallyIgniteFlamework() {
    // preload
    let isPreloading = false;
    const paths = [["ServerScriptService", "services"]];
    const preloadPaths = new Array<Instance>();
    for (const arg of paths) {
        const service = arg.shift();
        let currentPath = game.GetService(service as keyof Services) as Instance;
        if (service === "StarterPlayer") {
            if (arg[0] !== "StarterPlayerScripts") throw "StarterPlayer only supports StarterPlayerScripts";
            if (!RunService.IsClient()) throw "The server cannot load StarterPlayer content";
            currentPath = Players.LocalPlayer.WaitForChild("PlayerScripts");
            arg.shift();
        }
        for (let i = 0; i < arg.size(); i++) {
            currentPath = currentPath.WaitForChild(arg[i]);
        }
        preloadPaths.push(currentPath);
    }

    const preload = (moduleScript: ModuleScript) => {
        isPreloading = true;
        const start = os.clock();
        const [success, value] = pcall(() => require(moduleScript));
        const endTime = math.floor((os.clock() - start) * 1000);
        isPreloading = false;
        if (!success) {
            throw `${moduleScript.GetFullName()} failed to preload (${endTime}ms): ${value}`;
        }
    };

    for (const path of preloadPaths) {
        if (path.IsA("ModuleScript")) {
            preload(path);
        }
        for (const instance of path.GetDescendants()) {
            if (instance.IsA("ModuleScript")) {
                preload(instance);
            }
        }
    }

    // ignite
    const idToObj = (Reflect as unknown as { idToObj: Map<string, object> }).idToObj;

    let inactiveThread: thread | undefined;
    function reusableThread(func: () => void) {
        const thread = coroutine.running();

        while (true) {
            if (inactiveThread === thread) {
                inactiveThread = undefined;
            }

            func();

            // If there's a different idle thread, we should end the current thread.
            if (inactiveThread !== undefined) {
                break;
            }

            inactiveThread = thread;
            [func] = coroutine.yield() as LuaTuple<[never]>;
        }
    }
    function reuseThread(func: () => void) {
        if (inactiveThread) {
            task.spawn(inactiveThread, func);
        } else {
            task.spawn(reusableThread, func);
        }
    }
    function profileYielding(func: () => void, _identifier: string) {
        return func;
    }
    function getIdentifier(obj: object, suffix = ""): string {
        return Reflect.getMetadata<string>(obj, "identifier") ?? `UnidentifiedFlameworkListener${suffix}`;
    }
    function topologicalSort(objects: string[]) {
        // This implementation ignores circular dependency trees.
        let currentSize = 0;
        const sorted = new Map<string, number>();
        const visited = new Set<string>();
        const visitor = (node: string) => {
            if (visited.has(node)) return;
            visited.add(node);

            const object = idToObj.get(node);
            if (!object) return;

            const dependencies = Reflect.getMetadata<string[]>(object, "flamework:parameters");
            for (const dependency of dependencies ?? []) {
                visitor(dependency);
            }

            sorted.set(node, currentSize++);
        };

        for (const node of objects) {
            visitor(node);
        }

        return sorted;
    }

    for (const [, ctor] of idToObj) {
        if (!isConstructor(ctor)) continue;
        //if (!Reflect.getMetadata<boolean>(ctor, "flamework:singleton")) continue;
        if (Reflect.getMetadata<boolean>(ctor, "flamework:optional")) continue;

        Modding.resolveSingleton(ctor);
    }

    const dependencies = new Array<[instance: object, loadOrder: number]>();
    type Constructor<T = object> = new (...args: never[]) => T;
    const singletons = (Modding as unknown as { getSingletons: () => Map<Constructor, object> }).getSingletons();
    for (const [ctor, dependency] of singletons) {
        const loadOrder = Reflect.getMetadata<number>(ctor, "flamework:loadOrder") ?? 1;
        dependencies.push([dependency, loadOrder]);
    }

    const sortedDependencies = topologicalSort(dependencies.map(([obj]) => getIdentifier(obj)));
    const start = new Array<[OnStart, string]>();
    const init = new Array<[OnInit, string]>();

    const tick = new Map<OnTick, string>();
    const render = new Map<OnRender, string>();
    const physics = new Map<OnPhysics, string>();

    dependencies.sort(([depA, aOrder], [depB, bOrder]) => {
        if (aOrder !== bOrder) {
            return aOrder < bOrder;
        }

        const aIndex = sortedDependencies.get(getIdentifier(depA))!;
        const bIndex = sortedDependencies.get(getIdentifier(depB))!;
        return aIndex < bIndex;
    });

    Modding.onListenerAdded<OnTick>((object) => tick.set(object, getIdentifier(object, "/OnTick")));
    Modding.onListenerAdded<OnPhysics>((object) => physics.set(object, getIdentifier(object, "/OnPhysics")));
    Modding.onListenerAdded<OnRender>((object) => render.set(object, getIdentifier(object, "/OnRender")));

    Modding.onListenerRemoved<OnTick>((object) => tick.delete(object));
    Modding.onListenerRemoved<OnPhysics>((object) => physics.delete(object));
    Modding.onListenerRemoved<OnRender>((object) => render.delete(object));

    for (const [dependency] of dependencies) {
        if (Flamework.implements<OnInit>(dependency)) init.push([dependency, getIdentifier(dependency)]);
        if (Flamework.implements<OnStart>(dependency)) start.push([dependency, getIdentifier(dependency)]);
    }

    for (const [dependency, identifier] of init) {
        const initResult = dependency.onInit();
        if (Promise.is(initResult)) {
            const [status, value] = initResult.awaitStatus();
            if (status === Promise.Status.Rejected) {
                throw `OnInit failed for dependency '${identifier}'. ${tostring(value)}`;
            }
        }
    }

    debug.resetmemorycategory();

    const heartbeatConn = RunService.Heartbeat.Connect((dt) => {
        for (const [dependency, identifier] of tick) {
            reuseThread(profileYielding(() => dependency.onTick(dt), identifier));
        }
    });

    const steppedConn = RunService.Stepped.Connect((time, dt) => {
        for (const [dependency, identifier] of physics) {
            reuseThread(profileYielding(() => dependency.onPhysics(dt, time), identifier));
        }
    });

    for (const [dependency, identifier] of start) {
        reuseThread(profileYielding(() => dependency.onStart(), identifier));
    }

    return {
        cleanup: () => {
            heartbeatConn.Disconnect();
            steppedConn.Disconnect();

            task.delay(5, () => {
                for (const [, ctor] of idToObj) {
                    if (!isConstructor(ctor)) continue;
                    table.clear(ctor);
                }
                idToObj.clear();

                for (const [dependency] of dependencies) {
                    table.clear(dependency);
                }
                dependencies.clear();
                sortedDependencies.clear();
            });
        },
        dependencies,
        init,
        start,
        tick,
        render,
        physics,
    };
}


//!native
//!optimize 2

import { getAllInstanceInfo } from "@antivivi/vrldk";
import Difficulty from "@rbxts/ejt";
import { CollectionService, Debris, RunService, TweenService } from "@rbxts/services";
import { Server } from "shared/api/APIExpose";
import { ASSETS } from "shared/asset/GameAssets";
import { IS_EDIT, IS_SERVER } from "shared/Context";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import eat from "shared/hamster/eat";
import Packets from "shared/Packets";

declare global {
    interface InstanceInfo {
        /**
         * The health of this droplet instance.
         */
        health?: number;

        /**
         * The unique ID of this droplet instance.
         */
        dropletId?: string;

        /**
         * The item ID this instance represents.
         *
         * If the instance is a droplet model, this will be the item that spawned it.
         */
        itemId?: string;

        /**
         * The area this instance belongs to.
         */
        areaId?: AreaId;

        /**
         * Fired when this instance has been touched by a droplet.
         * @param droplet The droplet that was touched.
         * @param dropletInfo The information about the droplet.
         */
        dropletTouched?: (droplet: BasePart, dropletInfo: InstanceInfo) => void;
    }

    interface DropletAssets extends Folder {
        Ball: MeshPart;
    }

    interface Assets {
        Droplet: DropletAssets;
    }
}

export default class Droplet {
    static readonly DROPLETS = new Array<Droplet>();

    static TheFirstDroplet = Droplet.registerDroplet(
        new Droplet("TheFirstDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.TheFirstDifficulty.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 1)),
    );

    static FatDroplet = Droplet.registerDroplet(
        new Droplet("FatDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.5, 1.5, 1.5);
                droplet.Color = Difficulty.TheLowerGap.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 4)),
    );

    static ManualDroplet = Droplet.registerDroplet(
        new Droplet("ManualDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Negativity.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 15)),
    );

    static HeavyweightDroplet = Droplet.registerDroplet(
        new Droplet("HeavyweightDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.75, 1.75, 1.75);
                droplet.Color = Difficulty.Negativity.color!;
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 160)),
    );

    static GrassDroplet = Droplet.registerDroplet(
        new Droplet("GrassDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Friendliness.color!;
                droplet.Material = Enum.Material.Grass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 120)),
    );

    static NativeGrassDroplet = Droplet.registerDroplet(
        new Droplet("NativeGrassDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Friendliness.color!;
                droplet.Material = Enum.Material.Grass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 120)),
    );

    static RustyDroplet = Droplet.registerDroplet(
        new Droplet("RustyDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.9, 0.9, 0.9);
                droplet.Color = Color3.fromRGB(160, 95, 53);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 180)),
    );

    static MassiveGrassDroplet = Droplet.registerDroplet(
        new Droplet("MassiveGrassDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(2, 2, 2);
                droplet.Color = Difficulty.Friendliness.color!;
                droplet.Material = Enum.Material.Grass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 650)),
    );

    static VibrantDroplet = Droplet.registerDroplet(
        new Droplet("VibrantDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(2, 2, 2);
                droplet.Color = Difficulty.TrueEase.color!;
                droplet.Material = Enum.Material.Neon;
                droplet.AddTag("Rainbow");
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 24000)),
    );

    static CommunismFundsDroplet = Droplet.registerDroplet(
        new Droplet("CommunismFundsDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = new Color3(1, 0, 0);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 3600)),
    );

    static CommunismPowerDroplet = Droplet.registerDroplet(
        new Droplet("CommunismPowerDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = new Color3(1, 1, 0);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 4)),
    );

    static CommunismDroplet = Droplet.registerDroplet(
        new Droplet("CommunismDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Color3.fromRGB(255, 89, 89);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 4000).set("Power", 3)),
    );

    static EnergyPoweredDroplet = Droplet.registerDroplet(
        new Droplet("EnergyPoweredDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 89, 89);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 12000)),
    );

    static LegDayDroplet = Droplet.registerDroplet(
        new Droplet("LegDayDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.1, 1.1, 1.1);
                droplet.Color = Color3.fromRGB(145, 145, 145);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 10000).set("Power", 15)),
    );

    static CrystalDroplet = Droplet.registerDroplet(
        new Droplet("CrystalDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.8, 0.8, 0.8);
                droplet.Color = Color3.fromRGB(224, 105, 240);
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 20000)),
    );

    static LiquidFundsDroplet = Droplet.registerDroplet(
        new Droplet("LiquidFundsDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Difficulty.FelixTheDA.color!;
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 100000)),
    );

    static LiquidPowerDroplet = Droplet.registerDroplet(
        new Droplet("LiquidPowerDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 163, 89);
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 100)),
    );

    static RapidDroplet = Droplet.registerDroplet(
        new Droplet("RapidDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.9, 0.9, 0.9);
                droplet.Color = Difficulty.Exist.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 16000).set("Power", 16)),
    );

    static RustyAmethystDroplet = Droplet.registerDroplet(
        new Droplet("RustyAmethystDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.ReversedPeripherality.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 34000)),
    );

    static AmethystDroplet = Droplet.registerDroplet(
        new Droplet("AmethystDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.ReversedPeripherality.color!;
                droplet.Material = Enum.Material.Metal;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 78000)),
    );

    static Char = Droplet.registerDroplet(
        new Droplet("Char")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = new Color3();
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 1)),
    );

    static LiquiderFundsDroplet = Droplet.registerDroplet(
        new Droplet("LiquiderFundsDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 166, 0);
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 3000000)),
    );

    static LiquiderPowerDroplet = Droplet.registerDroplet(
        new Droplet("LiquiderPowerDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 130, 28);
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 100000)),
    );

    static VitalizedDroplet = Droplet.registerDroplet(
        new Droplet("VitalizedDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.2, 1.2, 1.2);
                droplet.Color = Difficulty.Restful.color!;
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 40000).set("Power", 2000))
            .setHealth(140),
    );

    static DepressingDroplet = Droplet.registerDroplet(
        new Droplet("DepressingDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Ifinity.color!;
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 0.01)),
    );

    static FundsCompactDroplet = Droplet.registerDroplet(
        new Droplet("FundsCompactDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(0, 255, 0);
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 100000)),
    );

    static PowerCompactDroplet = Droplet.registerDroplet(
        new Droplet("PowerCompactDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 163, 89);
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 6000)),
    );

    static PurifiersDroplet = Droplet.registerDroplet(
        new Droplet("PurifiersDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 138, 255);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Purifier Clicks", 400)),
    );

    static ManualV2Droplet = Droplet.registerDroplet(
        new Droplet("ManualV2Droplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Millisecondless.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 100000)),
    );

    static LiquidestFundsDroplet = Droplet.registerDroplet(
        new Droplet("LiquidestFundsDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 135, 235);
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 30000000)),
    );

    static LiquidestPowerDroplet = Droplet.registerDroplet(
        new Droplet("LiquidestPowerDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 51, 222);
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 5000000)),
    );

    static SpatialDroplet = Droplet.registerDroplet(
        new Droplet("SpatialDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Astronomical.color!;
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 2000)),
    );

    static HydratingDroplet = Droplet.registerDroplet(
        new Droplet("HydratingDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Difficulty.Win.color!;
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setHealth(130)
            .setValue(new CurrencyBundle().set("Power", 8000)),
    );

    static BasicCoin = Droplet.registerDroplet(
        new Droplet("BasicCoin")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Winsome.color!;
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 1)),
    );

    static DoubleCoin = Droplet.registerDroplet(
        new Droplet("DoubleCoin")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.95, 0.95, 0.95);
                droplet.Color = Difficulty.Blessing.color!;
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 2)),
    );

    static QuadrupleCoin = Droplet.registerDroplet(
        new Droplet("QuadrupleCoin")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.9, 0.9, 0.9);
                droplet.Color = Difficulty.JustAir.color!;
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 4)),
    );

    static SkillDroplet = Droplet.registerDroplet(
        new Droplet("SkillDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.9, 0.9, 0.9);
                droplet.Color = Difficulty.Blessing.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Skill", 0.01)),
    );

    static ClassicDroplet = Droplet.registerDroplet(
        new Droplet("ClassicDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.96, 0.96, 0.96);
                droplet.Color = Difficulty.Vintage.color!;
                droplet.Material = Enum.Material.Basalt;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 200000)),
    );

    static LunaryDroplet = Droplet.registerDroplet(
        new Droplet("LunaryDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Vintage.color!;
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 480000).set("Power", 20000)),
    );

    static SkillerDroplet = Droplet.registerDroplet(
        new Droplet("SkillerDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.875, 0.875, 0.875);
                droplet.Color = Difficulty.Walkthrough.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Skill", 0.06)),
    );

    static HappyDroplet = Droplet.registerDroplet(
        new Droplet("HappyDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Happylike.color!;
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 1600000).set("Power", 280000).set("Skill", 0.08)),
    );

    static SolarDroplet = Droplet.registerDroplet(
        new Droplet("SolarDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Walkthrough.color!;
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 500000).set("Power", 70000).set("Skill", 0.02)),
    );

    static OctupleCoin = Droplet.registerDroplet(
        new Droplet("OctupleCoin")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.9, 0.9, 0.9);
                droplet.Color = Difficulty.AutomaticJoyful.color!;
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 8)),
    );

    static SkillestDroplet = Droplet.registerDroplet(
        new Droplet("SkillestDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.875, 0.875, 0.875);
                droplet.Color = Difficulty.Unlosable.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Skill", 0.12)),
    );

    static LiquidesterFundsDroplet = Droplet.registerDroplet(
        new Droplet("LiquidesterFundsDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(252, 105, 230);
                droplet.Material = Enum.Material.Cardboard;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 700000000)),
    );

    static LiquidesterPowerDroplet = Droplet.registerDroplet(
        new Droplet("LiquidesterPowerDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(255, 5, 214);
                droplet.Material = Enum.Material.Cardboard;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 100000000)),
    );

    static LiquidesterBitcoinDroplet = Droplet.registerDroplet(
        new Droplet("LiquidesterBitcoinDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.25, 1.25, 1.25);
                droplet.Color = Color3.fromRGB(71, 5, 255);
                droplet.Material = Enum.Material.Cardboard;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 10000)),
    );

    static SexdecupleCoin = Droplet.registerDroplet(
        new Droplet("SexdecupleCoin")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.91, 0.91, 0.91);
                droplet.Color = Difficulty.Unlosable.color!;
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 16).set("Skill", 0.15)),
    );

    static HandCrankedCoin = Droplet.registerDroplet(
        new Droplet("HandCrankedCoin")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.91, 0.91, 0.91);
                droplet.Color = Difficulty.Frivolous.color!;
                droplet.Material = Enum.Material.Ice;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Bitcoin", 24)),
    );

    static SphericalDroplet = Droplet.registerDroplet(
        new Droplet("SphericalDroplet")
            .setModel(() => {
                const droplet = ASSETS.Droplet.Ball.Clone();
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Color3.fromRGB(58, 125, 21);
                droplet.Anchored = false;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Skill", 0.1))
            .setHealth(10),
    );

    static ShatteredDroplet = Droplet.registerDroplet(
        new Droplet("ShatteredDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Color3.fromRGB(113, 16, 171);
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 200000)),
    );

    static TotalityDroplet = Droplet.registerDroplet(
        new Droplet("TotalityDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(2, 2, 2);
                droplet.Color = Difficulty.Spontaneous.color!;
                droplet.Material = Enum.Material.Neon;
                return droplet;
            })
            .setValue(
                new CurrencyBundle().set("Funds", 10000000).set("Power", 10000000).set("Bitcoin", 1000).set("Skill", 1),
            ),
    );

    static LuckyDroplet = Droplet.registerDroplet(
        new Droplet("LuckyDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Color3.fromRGB(140, 255, 245);
                droplet.Material = Enum.Material.Neon;

                const light = new Instance("PointLight");
                light.Color = new Color3(0.5, 1, 0.87);
                light.Shadows = false;
                light.Range = 10;
                light.Brightness = 0.5;
                light.Parent = droplet;

                return droplet;
            })
            .setValue(new CurrencyBundle().set("Diamonds", 1)),
    );

    static MovementDroplet = Droplet.registerDroplet(
        new Droplet("MovementDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.4, 1.4, 1.4);
                droplet.Color = Difficulty.DoSomething.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Funds", 150000)),
    );

    static BalloonDroplet = Droplet.registerDroplet(
        new Droplet("BalloonDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(0.6, 0.6, 0.6);
                droplet.Color = Difficulty.DoSomething.color!;
                droplet.Material = Enum.Material.Glass;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Power", 200000)),
    );

    static StuddedDroplet = Droplet.registerDroplet(
        new Droplet("StuddedDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1.2, 1.2, 1.2);
                droplet.Color = Difficulty.AutomaticJoyful.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Skill", 0.05).set("Purifier Clicks", 5000).set("Power", 125000))
            .setHealth(120),
    );

    static PlacidDroplet = Droplet.registerDroplet(
        new Droplet("PlacidDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Placid.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Skill", 1)),
    );

    static UnrealDroplet = Droplet.registerDroplet(
        new Droplet("UnrealDroplet")
            .setModel(() => {
                const droplet = new Instance("Part");
                droplet.Size = new Vector3(1, 1, 1);
                droplet.Color = Difficulty.Unreal.color!;
                droplet.Material = Enum.Material.Slate;
                return droplet;
            })
            .setValue(new CurrencyBundle().set("Time", 1)),
    );

    /**
     * Registers a droplet to the droplet list.
     *
     * @param droplet The droplet to register.
     * @returns The registered droplet.
     */
    static registerDroplet(droplet: Droplet) {
        Droplet.DROPLETS.push(droplet);
        return droplet;
    }

    /**
     * The default physical properties of droplets.
     */
    static readonly PHYSICAL_PROPERTIES = new PhysicalProperties(2, 2, 0.15);

    /**
     * Spawned droplets in the world with their {@link InstanceInfo}.
     */
    static readonly SPAWNED_DROPLETS = new Map<BasePart, InstanceInfo>();
    static readonly MODEL_PER_SPAWN_ID = new Map<string, BasePart>();

    /**
     * Basic counter for droplet instantiation.
     * Used for naming droplets.
     */
    static instatiationCount = 0;

    /**
     * The model of the droplet.
     * Should be set with {@link Droplet.setModel} before the droplet is used.
     */
    model!: BasePart;

    /**
     * The value of the droplet.
     * Should be set with {@link Droplet.setValue} before the droplet is used.
     */
    value!: CurrencyBundle;

    /**
     * The starting health of the droplet.
     */
    health = 100;

    /**
     * Constructs a new droplet at the given ID.
     *
     * @param id The ID of the droplet.
     */
    constructor(public readonly id: string) {}

    /**
     * Factory method to create a droplet from an item model and a CFrame.
     *
     * @param dropperModel The model of the dropper.
     * @param drop The drop part.
     * @returns The instantiator function for the droplet.
     */
    getInstantiator(dropperModel: Model, drop?: BasePart) {
        const cframe = drop === undefined ? undefined : drop.CFrame;

        const model = this.model.Clone();
        if (cframe !== undefined) {
            model.CFrame = cframe;
        }
        const health = this.health;
        const dropperModelInfo = getAllInstanceInfo(dropperModel);
        const itemId = dropperModelInfo.itemId;
        const areaId = Server.Item.getPlacedItem(dropperModel.Name)?.area as AreaId | undefined;

        return () => {
            const spawned = model.Clone();
            spawned.CustomPhysicalProperties = Droplet.PHYSICAL_PROPERTIES;

            const instanceInfo = getAllInstanceInfo(spawned);
            instanceInfo.upgrades = new Map();
            instanceInfo.health = health;
            instanceInfo.dropletId = this.id;
            instanceInfo.itemId = itemId;
            instanceInfo.areaId = areaId;

            spawned.Touched.Connect((part) => {
                getAllInstanceInfo(part).dropletTouched?.(spawned, instanceInfo);
            });

            const spawnId = tostring(++Droplet.instatiationCount);
            spawned.Name = spawnId;

            Droplet.SPAWNED_DROPLETS.set(spawned, instanceInfo);
            Droplet.MODEL_PER_SPAWN_ID.set(spawnId, spawned);
            let destroyed = false;
            spawned.Destroying.Once(() => {
                destroyed = true;
                Droplet.SPAWNED_DROPLETS.delete(spawned);
                Droplet.MODEL_PER_SPAWN_ID.delete(spawnId);
            });

            let prev: Vector3 | undefined;
            const checkStillness = () => {
                if (destroyed === true) return;
                if (prev !== undefined && (spawned as BasePart).Position.sub(prev).Magnitude < 0.5) {
                    Debris.AddItem(spawned, 1);
                    TweenService.Create(spawned as BasePart, new TweenInfo(0.5), { Transparency: 1 }).Play();
                    return;
                }
                prev = (spawned as BasePart).Position;
                task.delay(10, checkStillness);
            };
            task.delay(10, checkStillness);

            eat(spawned, "Destroy");

            return spawned;
        };
    }

    /**
     * Sets the model of the droplet.
     *
     * @param modelFunc The function to create the model of the droplet.
     * @returns This droplet.
     */
    setModel(modelFunc: () => BasePart) {
        const model = modelFunc();
        model.Name = "Droplet";
        model.CollisionGroup = "Droplet";
        model.CanQuery = false;
        model.CanTouch = true;
        model.CastShadow = false;
        model.AddTag("Droplet");
        this.model = model;
        return this;
    }

    /**
     * Sets the value of the droplet.
     *
     * @param value The value of the droplet.
     * @returns This droplet.
     */
    setValue(value: CurrencyBundle) {
        this.value = value;
        return this;
    }

    /**
     * Sets the starting health of the droplet.
     *
     * @param health The starting health value to set for the droplet.
     * @returns This droplet.
     */
    setHealth(health: number) {
        this.health = health;
        return this;
    }

    /**
     * Gets the droplet at the given ID.
     *
     * @param dropletId The ID of the droplet to get.
     * @returns The droplet at the given ID, or undefined if not found.
     */
    static getDroplet(dropletId: string) {
        for (const droplet of Droplet.DROPLETS) {
            if (droplet.id === dropletId) return droplet;
        }
        return undefined;
    }

    static {
        const heartbeatConnection = RunService.Heartbeat.Connect(() => {
            for (const [dropletModel, instanceInfo] of this.SPAWNED_DROPLETS) {
                if (instanceInfo.health === undefined) continue;

                if (instanceInfo.health <= 0 || dropletModel.Position.Y > 1000) {
                    this.SPAWNED_DROPLETS.delete(dropletModel);
                    this.MODEL_PER_SPAWN_ID.delete(dropletModel.Name);
                    dropletModel.Anchored = true;
                    dropletModel.Transparency = 1;
                    Debris.AddItem(dropletModel, 6);
                    const explosion = new Instance("Explosion");
                    explosion.ExplosionType = Enum.ExplosionType.NoCraters;
                    explosion.DestroyJointRadiusPercent = 0;
                    explosion.BlastRadius = 0;
                    explosion.Position = dropletModel.Position;
                    explosion.Parent = dropletModel;
                    continue;
                }
            }
        });
        eat(heartbeatConnection);

        if (!IS_SERVER || IS_EDIT) {
            const addedConnection = CollectionService.GetInstanceAddedSignal("Droplet").Connect((droplet) => {
                if (!droplet.IsA("BasePart")) return;
                const dropletModelId = droplet.Name;

                Droplet.MODEL_PER_SPAWN_ID.set(dropletModelId, droplet);
                droplet.Destroying.Once(() => {
                    Droplet.MODEL_PER_SPAWN_ID.delete(dropletModelId);
                });
            });
            eat(addedConnection, "Disconnect");

            const impulseConnection = Packets.setVelocity.fromServer((dropletModelId, velocity) => {
                const model = this.MODEL_PER_SPAWN_ID.get(dropletModelId);
                if (model === undefined) return;
                model.AssemblyLinearVelocity = velocity;
            });
            eat(impulseConnection, "Disconnect");
        }
    }
}


import { BaseOnoeNum, OnoeNum } from "@rbxts/serikanum";
import { buildRichText, combineHumanReadable, formatRichText } from "@antivivi/vrldk";
import StringBuilder from "@rbxts/stringbuilder";
import { IS_SERVER } from "shared/Context";
import Packets from "shared/Packets";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import { CURRENCY_DETAILS } from "shared/currency/CurrencyDetails";
import { RESET_LAYERS } from "shared/currency/mechanics/ResetLayer";
import Item from "shared/item/Item";
import Unique from "shared/item/traits/Unique";
import Items from "shared/items/Items";

let RESET_LAYERS_UNLOCKED = false;

export default class ItemMetadata {
    static readonly DESCRIPTION_PER_ITEM = (() => {
        const map = new Map<Item, string>();
        for (const [, item] of Items.itemsPerId) {
            map.set(item, this.formatCurrencyColors(item.description));
        }
        return map;
    })();

    static readonly INDICES = {
        TOOL: 0,
        SPACING: 1,
        FORMULA: 2,
        PLACEABLE_AREAS: 3,
        RESET_LAYER: 4,
        LEVEL_REQ: 5,
    };

    builder = new StringBuilder();
    formulaOperation = "x";

    constructor(
        public item: Item,
        public size = 15,
        public weight: keyof typeof Enum.FontWeight = "Medium",
    ) {
        for (const [_, value] of pairs(ItemMetadata.INDICES)) {
            this.builder[value] = "";
        }
        this.tool();
        this.spacing();
        this.formula();
        this.placeableAreas();
        this.resetLayer();
    }

    tool() {
        const tool = this.item.findTrait("Gear");
        if (tool === undefined || tool.type === "None") {
            this.builder[ItemMetadata.INDICES.TOOL] = "";
            return;
        }

        const speed = formatRichText(`\nSpeed: ${tool.speed}`, Color3.fromRGB(112, 176, 255), this.size, this.weight);
        const damage = formatRichText(`\nDamage: ${tool.damage}`, Color3.fromRGB(255, 71, 0), this.size, this.weight);

        this.builder[ItemMetadata.INDICES.TOOL] = speed + damage;
    }

    spacing() {
        if (this.item.formula !== undefined || RESET_LAYERS_UNLOCKED) {
            this.builder[ItemMetadata.INDICES.SPACING] = `\n<font size="7"> </font>`;
        } else {
            this.builder[ItemMetadata.INDICES.SPACING] = "";
        }
    }

    formula(color = Color3.fromRGB(126, 255, 167), result?: BaseOnoeNum) {
        const item = this.item;
        const formula = item.formula;
        if (formula === undefined) {
            this.builder[ItemMetadata.INDICES.FORMULA] = "";
            return;
        }

        const builder = new StringBuilder();
        builder.append("Formula: ");
        builder.append(formula.tostring(item.formulaX ?? "x"));

        if (result !== undefined) {
            builder.append(" = ");
            builder.append(this.formulaOperation);
            builder.append(OnoeNum.toString(result));
        }
        const formulaBundled = item.findTrait("FormulaBundled");
        if (formulaBundled !== undefined) {
            builder.append("\nBoost Ratio: ");
            for (const [currency, details] of CurrencyBundle.SORTED_DETAILS) {
                const ratio = formulaBundled.ratio.get(currency);
                if (ratio === undefined || ratio === 0) {
                    continue;
                }
                const formatted = CurrencyBundle.getFormatted(currency, new OnoeNum(ratio));
                const colored = new StringBuilder(formatted);

                if (result !== undefined) {
                    const value = new OnoeNum(result).mul(ratio);
                    colored.append(" (");
                    colored.append(this.formulaOperation);
                    colored.append(value.toString());
                    colored.append(")");
                }
                buildRichText(builder, colored.toString(), details.color, this.size, this.weight);

                builder.append(" : ");
            }
            builder.pop(); // Remove the last " : "
        }

        const display = formatRichText(builder.toString(), color, this.size, this.weight);
        this.builder[ItemMetadata.INDICES.FORMULA] = display;
    }

    placeableAreas(color = Color3.fromRGB(248, 255, 221)) {
        const item = this.item;
        const isEmpty = item.placeableAreas.isEmpty();
        if (item.bounds !== undefined || (!RESET_LAYERS_UNLOCKED && !isEmpty)) {
            this.builder[ItemMetadata.INDICES.PLACEABLE_AREAS] = "";
            return;
        }

        if (isEmpty) {
            this.builder[ItemMetadata.INDICES.PLACEABLE_AREAS] =
                `\n${formatRichText("This item is not placeable.", color, this.size, this.weight)}`;
            return;
        }

        const builder = new StringBuilder("Placeable in ");
        const vals = new Array<string>();
        for (const area of item.placeableAreas) {
            if (area.hidden) continue;
            vals.push(area.name);
        }
        builder.append(combineHumanReadable(...vals));

        this.builder[ItemMetadata.INDICES.PLACEABLE_AREAS] =
            `\n${formatRichText(builder.toString(), color, this.size, this.weight)}`;
    }

    resetLayer(color = Color3.fromRGB(255, 156, 99)) {
        if (RESET_LAYERS_UNLOCKED === false) {
            this.builder[ItemMetadata.INDICES.RESET_LAYER] = "";
            return;
        }

        let text: string;
        const order = this.item.getResetLayer();
        if (order > 900) text = "[Persistent]";
        else {
            let layer: ResetLayerId | undefined;
            for (const [id, l] of pairs(RESET_LAYERS))
                if (l.order === order) {
                    layer = id;
                    break;
                }
            text = layer === undefined ? "[Persistent]" : `[Resets on ${layer}]`;
        }

        this.builder[ItemMetadata.INDICES.RESET_LAYER] = `\n${formatRichText(text, color, this.size, this.weight)}`;
    }

    /**
     * Append this metadata to the description.
     * @param description The existing description to append to.
     * @param color Optional color for the appended text.
     * @param size Optional font size.
     * @param weight Optional font weight.
     * @returns The combined rich text string.
     */
    private appendMetadata(
        description: string,
        color = Color3.fromRGB(195, 195, 195),
        size = 18,
        weight?: keyof typeof Enum.FontWeight | number,
    ) {
        const builder = buildRichText(undefined, description, color, size, weight);
        return builder.appendAll(this.builder).toString();
    }

    /**
     * Format an item's description with metadata appended.
     * @param uniqueInstance Optional unique item instance for trait formatting.
     * @param useTooltipDescription Whether to use the tooltip description if available.
     * @param color Optional color for the description text.
     * @param size Optional font size.
     * @param weight Optional font weight.
     * @returns The combined rich text string.
     */
    formatItemDescription(
        uniqueInstance?: UniqueItemInstance,
        useTooltipDescription?: boolean,
        color?: Color3,
        size?: number,
        weight?: keyof typeof Enum.FontWeight | number,
    ) {
        const description = ItemMetadata.formatDescription(this.item, uniqueInstance, useTooltipDescription);
        return this.appendMetadata(description, color, size, weight);
    }

    /**
     * Formats currency names in a string with their associated colors.
     * @param text The input text containing currency names.
     * @returns The text with currency names color-formatted.
     */
    static formatCurrencyColors(text: string) {
        for (const [currency, details] of pairs(CURRENCY_DETAILS)) {
            [text] = text!.gsub(currency, `<font color="#${details.color.ToHex()}">${currency}</font>`);
        }
        return text;
    }

    /**
     * Formats an item's description, applying currency colors and unique instance traits if provided.
     * @param item The item whose description is to be formatted.
     * @param uniqueInstance Optional unique item instance for trait formatting.
     * @returns The formatted description string.
     */
    static formatDescription(item: Item, uniqueInstance?: UniqueItemInstance, useTooltipDescription?: boolean) {
        if (useTooltipDescription === true) {
            const tooltipDescription = item.tooltipDescription;
            if (tooltipDescription !== undefined) return tooltipDescription;
        }

        let description = ItemMetadata.DESCRIPTION_PER_ITEM.get(item);
        if (description === undefined) {
            description = this.formatCurrencyColors(item.description);
            ItemMetadata.DESCRIPTION_PER_ITEM.set(item, description);
        }
        if (uniqueInstance !== undefined) {
            description = item.trait(Unique).formatWithPots(description, uniqueInstance);
        }
        return item.format(description);
    }

    static {
        if (!IS_SERVER) {
            Packets.unlockedAreas.observe((areas) => {
                RESET_LAYERS_UNLOCKED = areas.has("SlamoVillage");
            });
        }
    }
}


import { findModels } from "@antivivi/vrldk";
import { CollectionService, ReplicatedStorage, Workspace } from "@rbxts/services";
import { SOUND_EFFECTS_GROUP } from "shared/asset/GameAssets";
import { IS_EDIT, IS_SERVER } from "shared/Context";

const itemModels = new Map<string, Model>();
const folder = Workspace.FindFirstChild("ItemModels") ?? ReplicatedStorage.WaitForChild("ItemModels");

/**
 * Can be used to make a part interactive with droplets.
 *
 * @param basePart The part to make interactive.
 */
function dropletInteractive(basePart: BasePart) {
    basePart.CollisionGroup = "Item"; // item collision group can collide with droplets
    basePart.CanQuery = true;
    basePart.CanTouch = true;
}

/**
 * Macro to validate if an instance is a BasePart.
 *
 * @param instance The instance to validate.
 * @returns True if the instance is a BasePart, false otherwise.
 */
function validateBasePart(instance: Instance): instance is BasePart {
    if (!instance.IsA("BasePart")) {
        warn(`BasePart expected for`, instance, "at", instance.GetFullName());
        return false;
    }
    return true;
}

/**
 * Unpacks the children of an instance into its parent and destroys the original instance.
 * @param parent The parent instance to which the children will be moved.
 * @param instance The Folder or Model instance to unpack.
 */
function unpackInstance(parent: Instance, instance: Instance) {
    for (const unpacking of instance.GetChildren()) unpacking.Parent = parent;
    instance.Destroy();
}

/**
 * Preprocesses a model to set up collision groups, tags, and other properties for its descendants.
 * @param model The model to preprocess.
 */
export function preprocessModel(model: Model) {
    const isTool = model.IsA("Tool");
    const characters = new Set<Model>();
    let i = 0;
    for (const instance of model.GetDescendants()) {
        const name = instance.Name;
        instance.SetAttribute("uid", i++);

        if (name === "Hitbox") {
            // A part with the main purpose of checking for collision with other items.
            if (!validateBasePart(instance)) continue;
            instance.CollisionGroup = "ItemHitbox";
            instance.CanQuery = true;
            continue;
        }

        if (name === "Laser" || name === "Transformer") {
            // A part that can interact with droplets to apply an effect.
            if (!validateBasePart(instance)) continue;
            dropletInteractive(instance);
            CollectionService.AddTag(instance, "Laser");
            continue;
        }

        if (name === "Lava") {
            // A part that can interact with droplets to destroy them.
            if (!validateBasePart(instance)) continue;
            dropletInteractive(instance);
            CollectionService.AddTag(instance, "Lava");
            continue;
        }

        if (name === "Decoration" || name === "Connector" || name === "Spinner") {
            // A part that can only collide and interact with players, not droplets.
            // Use this for decorative parts to reduce the number of collision checks with droplets.
            if (!validateBasePart(instance)) continue;
            instance.CollisionGroup = "Decoration";
            instance.CanCollide = true;
            instance.CanTouch = false;
            instance.CanQuery = false;
            continue;
        }

        if (name === "Ghost") {
            // A part that cannot interact with anything at all.
            if (!validateBasePart(instance)) continue;
            instance.CollisionGroup = "Decoration";
            instance.CanCollide = false;
            instance.CanTouch = false;
            instance.CanQuery = false;
            continue;
        }

        if (name === "Antighost") {
            // A part that can only collide and interact with droplets, not players.
            // Use Antighosts to replace Decoration parts to simplify geometry and collision checks.
            if (!validateBasePart(instance)) continue;
            instance.CollisionGroup = "Antighost";
            instance.CanCollide = true;
            instance.CanTouch = false;
            instance.CanQuery = false;
            instance.Transparency = 1;
            continue;
        }

        if (name === "ShopGuiPart") {
            // A part that can be used to adorn a SurfaceGui for a shop.
            if (!validateBasePart(instance)) continue;
            instance.CollisionGroup = "Decoration";
            instance.CanQuery = true;
            instance.AddTag("Unhoverable");
            continue;
        }

        if (instance.IsA("BasePart") && instance.CollisionGroup === "Default") {
            // All other parts that can collide and interact with droplets and players.
            // This is the default case for parts that are not explicitly named.
            instance.CollisionGroup = "Item";
            instance.CanTouch = false;
            continue;
        }

        if (instance.IsA("MeshPart") || instance.IsA("UnionOperation")) {
            // Warn if a MeshPart or UnionOperation is using PreciseConvexDecomposition collision fidelity.
            // This collision fidelity is expensive and should be avoided.
            if (instance.CollisionFidelity === Enum.CollisionFidelity.PreciseConvexDecomposition) {
                warn("PreciseConvexDecomposition used in " + instance.GetFullName());
            }
            continue;
        }

        if (instance.IsA("Sound")) {
            // All sounds are set to the sound effects group.
            // Ambience sounds are looped and tagged as ambience.
            if (name === "AmbienceSound") {
                instance.Looped = true;
                CollectionService.AddTag(instance, "Ambience");
            }
            instance.SoundGroup = SOUND_EFFECTS_GROUP;
            continue;
        }

        if (instance.IsA("ProximityPrompt")) {
            // All proximity prompts are made interactive.
            CollectionService.AddTag(instance, "Interactive");
            continue;
        }

        if (
            (instance.IsA("ParticleEmitter") || instance.IsA("Beam") || instance.IsA("Script")) &&
            instance.Enabled === true
        ) {
            // All particle emitters and beams are tagged as effects.
            // All scripts are disabled to prevent them from running in-game.
            instance.AddTag("Effect");
            if (instance.IsA("Script")) {
                instance.Enabled = false;
            }
            continue;
        }

        if (instance.IsA("SurfaceGui") || instance.IsA("BillboardGui") || instance.IsA("ClickDetector")) {
            // All GUIs and click detectors are made interactive.
            const parent = instance.Parent!;
            if (parent.IsA("BasePart")) {
                parent.CanQuery = true;
                CollectionService.AddTag(parent, "Unhoverable");
            }
            continue;
        }

        if (!isTool) {
            if (instance.IsA("Folder")) {
                unpackInstance(model, instance);
                continue;
            } else if (instance.IsA("Model")) {
                const primaryPart = instance.PrimaryPart;
                if (primaryPart !== undefined) {
                    if (primaryPart.Name === "HumanoidRootPart") {
                        characters.add(instance);
                    }
                } else {
                    unpackInstance(model, instance);
                }
            }
        }
    }

    for (const character of characters) {
        const primaryPart = character.PrimaryPart;
        for (const part of character.GetChildren()) {
            if (part === primaryPart || !part.IsA("BasePart")) continue;
            part.Anchored = false;
        }
    }
}

const served = findModels(folder);
for (const model of served) {
    itemModels.set(model.Name, model);

    if (!IS_SERVER || IS_EDIT) continue;
    preprocessModel(model);
}

export const ITEM_MODELS = itemModels;


import { weldModel } from "@antivivi/vrldk";
import { packet } from "@rbxts/fletchette";
import { TweenService } from "@rbxts/services";
import { Server } from "shared/api/APIExpose";
import { playSound } from "shared/asset/GameAssets";
import Item from "shared/item/Item";
import Operative from "shared/item/traits/Operative";
import perItemPacket, { perItemProperty } from "shared/item/utils/perItemPacket";
import Packets from "shared/Packets";

declare global {
    interface PlacedItem {
        lastCollection?: number;
    }
}

const lastCollectionPacket = perItemProperty(
    packet<(placementId: string, lastCollection: number) => void>(),
    packet<(placementId: string) => number>(),
);
const collectPacket = perItemPacket(packet<(placementId: string) => void>());

/**
 * A bin is an item that enables offline progress, generating revenue while the player is not actively playing the game.
 */
export default class Bin extends Operative {
    limit = 3600 * 3;

    static load(model: Model, bin: Bin) {
        const item = bin.item;
        const CurrencyService = Server.Currency;
        const ItemService = Server.Item;
        const placedItem = ItemService.getPlacedItem(model.Name);
        if (placedItem === undefined) return;
        placedItem.lastCollection ??= tick();

        const onCollected = () => {
            const lastCollection = placedItem.lastCollection;
            if (lastCollection === undefined) return;
            const diff = math.min(tick() - lastCollection, bin.limit);
            placedItem.lastCollection = tick();
            const revenue = bin.mul?.mul(CurrencyService.getOfflineRevenue()).mulConstant(diff);
            if (revenue === undefined) return;
            lastCollectionPacket.set(model, placedItem.lastCollection);
            print(`Collected ${revenue} from ${item.name}`);
            CurrencyService.incrementAll(revenue.amountPerCurrency);
            Packets.showDifference.toAllClients(revenue.amountPerCurrency);
        };
        collectPacket.fromClient(model, () => {
            onCollected();
            collectPacket.toAllClients(model);
        });
        lastCollectionPacket.set(model, placedItem.lastCollection);
    }

    static clientLoad(model: Model, bin: Bin) {
        const fill = model.WaitForChild("Fill") as Part;
        const fillPosition = fill.Position; // for 1 height
        const fillSize = fill.Size;
        let lastCollection = tick();
        lastCollectionPacket.observe(model, (lc) => {
            lastCollection = lc;
            updateHeight();
        });
        const updateHeight = () => {
            // adjust fill height and position based on last collection time
            const percentage = math.clamp((tick() - lastCollection) / bin.limit, 0, 1);
            const fillHeight = percentage * 20;
            fill.Size = new Vector3(fillSize.X, fillHeight, fillSize.Z);
            fill.Position = fillPosition.add(new Vector3(0, fillHeight / 2 - 0.5, 0));
        };
        bin.item.repeat(model, updateHeight, 1);

        const button = model.WaitForChild("Button") as Model;
        weldModel(button);
        const clickPart = button.PrimaryPart!;
        const clickDetector = clickPart.WaitForChild("ClickDetector") as ClickDetector;
        const unclickedCFrame = clickPart.CFrame;

        clickDetector.MouseClick.Connect(() => {
            collectPacket.toServer(model);
        });

        collectPacket.fromServer(model, () => {
            playSound("GiantPress.mp3", clickPart);
            TweenService.Create(clickPart, new TweenInfo(0.2), {
                CFrame: unclickedCFrame.mul(new CFrame(0, 0, 0.5)),
            }).Play();
            task.wait(0.2);
            TweenService.Create(clickPart, new TweenInfo(0.6), { CFrame: unclickedCFrame }).Play();
        });
    }

    constructor(item: Item) {
        super(item);
        item.onLoad((model) => Bin.load(model, this));
    }
}


import { OnoeNum } from "@rbxts/serikanum";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import Item from "shared/item/Item";
import ItemTrait from "shared/item/traits/ItemTrait";
import Operative from "shared/item/traits/Operative";

declare global {
    interface ItemTraits {
        FormulaBundled: FormulaBundled;
    }
}

/**
 * Allows the item to apply a formula that varies in multiplier based on currency.
 */
export default class FormulaBundled extends ItemTrait {
    flat = new CurrencyBundle();
    ratio = new Map<Currency, number>();
    x?: () => OnoeNum;

    constructor(item: Item) {
        super(item);
    }

    /**
     * Set the x function that returns the value to be used in the formula.
     *
     * @param x The function that returns the OnoeNum value for x.
     * @returns The FormulaBundled instance for chaining.
     */
    setX(x: () => OnoeNum) {
        this.x = x;
        return this;
    }

    /**
     * Set the ratios for multiple currencies in the formula.
     *
     * @param ratios A map of Currency to ratio values.
     * @returns The FormulaBundled instance for chaining.
     */
    setRatios(ratios: Map<Currency, number>) {
        this.ratio = ratios;
        return this;
    }

    /**
     * Set how much the value of a profit will be multiplied by for a specific currency in the formula.
     *
     * @param currency The currency to set the ratio for.
     * @param ratio The ratio value to set.
     * @returns The FormulaBundled instance for chaining.
     */
    setRatio(currency: Currency, ratio: number) {
        this.ratio.set(currency, ratio);
        return this;
    }

    /**
     * Set a flat value for a specific currency in the formula.
     *
     * @param currency The currency to set the flat value for.
     * @param flat The flat value to set, can be an OnoeNum or a number.
     * @returns The FormulaBundled instance for chaining.
     */
    setFlat(currency: Currency, flat: OnoeNum | number) {
        this.flat.set(currency, flat);
        return this;
    }

    /**
     * Applies the bundled formula to the trait.
     *
     * @param Trait The trait constructor to apply the formula to.
     * @returns The trait instance with the formula applied.
     */
    apply<T extends Operative>(Trait: Constructor<T>) {
        if (this.x === undefined) {
            throw "FormulaBundled: x function is not set. Use setX() to set it.";
        }
        const trait = this.trait(Trait);
        trait.applyFormula((v, trait) => {
            const ratio = new CurrencyBundle();
            for (const [currency, ratioValue] of this.ratio) {
                ratio.set(currency, v.mul(ratioValue));
            }
            for (const [currency, flatValue] of this.flat.amountPerCurrency) {
                ratio.set(currency, flatValue);
            }
            trait.setMul(ratio);
        }, this.x);
        return trait;
    }

    format(str: string): string {
        return str.gsub("%%flat%%", this.flat.toString())[0];
    }
}