import { ReplicatedStorage, RunService } from "@rbxts/services";
import NameChanger from "server/interactive/npc/Name Changer";
import { Dialogue } from "server/interactive/npc/NPC";
import Tria from "server/interactive/npc/Tria";
import Quest, { Stage } from "server/quests/Quest";
import { Server } from "shared/api/APIExpose";
import { WAYPOINTS } from "shared/constants";
import TheFirstDropper from "shared/items/negative/tfd/TheFirstDropper";
import TheFirstFurnace from "shared/items/negative/tfd/TheFirstFurnace";
import Packets from "shared/Packets";

const triaToMineGuiding = Tria.createPathfindingOperation(
    Tria.startingCFrame,
    WAYPOINTS.NewBeginningsTriaMineGuiding.CFrame,
);
const triaToStart = Tria.createPathfindingOperation(
    WAYPOINTS.NewBeginningsTriaMineGuiding.CFrame,
    Tria.startingCFrame,
    false,
);

export = new Quest(script.Name)
    .setName("New Beginnings")
    .setLength(1)
    .setLevel(1)
    .setOrder(1)
    .addStage(
        new Stage()
            .setDescription(`You seem to be lost. Talk to the Friendly Noob next to you.`)
            .setNPC(Tria, true)
            .setDialogue(
                new Dialogue(
                    Tria,
                    "Oh! Uh... you're awake! That... that was fast, I guess. You were, um... out cold for a bit. Yeah.",
                )
                    .monologue(
                        "It... it happens a lot, people getting... washed up on shore. Not that it's... unusual or anything, haha.",
                    )
                    .monologue(
                        "I-I don't really understand how you... how you all stay alive after... the tossing in the sea.",
                    )
                    .monologue(
                        "So, uh... now that you're... here with us... I guess I should... show you, um... a few things?",
                    )
                    .monologue(
                        "There's a lot to learn and... lots of challenges, I think. But... you'll figure it out, probably.",
                    ).root,
            )
            .onReached((stage) => {
                Tria.rootPart!.CFrame = Tria.startingCFrame;
                Tria.playAnimation("Default");
                ReplicatedStorage.SetAttribute("NewBeginningsWakingUp", true);

                const continuation = new Dialogue(
                    Tria,
                    "Uh... I-I'm Tria... I guess. Um... if... if you want, I can... show you... how to, uh... maybe... make some money? Heh...",
                );
                const stageDialogueConn = stage.dialogue!.finished.connect(() => {
                    Server.Event.setEventCompleted("TriaReveal", true);
                    continuation.talk();
                });
                const continuationConn = continuation.finished.connect(() => {
                    stage.complete();
                });
                return () => {
                    stageDialogueConn.disconnect();
                    continuationConn.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Follow Tria...?`)
            .setNPC(Tria)
            .setFocus(WAYPOINTS.NewBeginningsTriaMineGuiding)
            .setDialogue(new Dialogue(Tria, "Follow me..."))
            .onReached((stage) => {
                Tria.rootPart!.CFrame = Tria.startingCFrame;
                Tria.stopAnimation("Default");
                ReplicatedStorage.SetAttribute("NewBeginningsWakingUp", false);

                task.wait(1);

                triaToMineGuiding().onComplete(() => {
                    stage.complete();
                });
                return () => {};
            }),
    )
    .addStage(
        new Stage()
            .setDescription("Figure out how to purchase items with Tria.")
            .setNPC(Tria)
            .setDialogue(
                new Dialogue(
                    Tria,
                    "O-okay, um... here we are, I guess. Your new... mine. It's... not much to look at, sorry.",
                )
                    .monologue("With some work though... it might... uh... become something nice, maybe?")
                    .monologue(
                        "Your first task... is to... get some basic equipment. Go to that shop over there and... um... get a Dropper and a Furnace.",
                    )
                    .monologue(
                        "Don't worry about the cost... you don't have any money right now... but... uh... the essentials are free for newcomers, thankfully.",
                    )
                    .monologue("Let's... let's try to get this... thing going, okay?").root,
            )
            .onReached((stage) => {
                Tria.rootPart!.CFrame = WAYPOINTS.NewBeginningsTriaMineGuiding.CFrame;
                Tria.stopAnimation("Default");

                let t = 0;
                const ItemService = Server.Item;
                const connection = RunService.Heartbeat.Connect((dt) => {
                    t += dt;
                    if (t < 0.5) return;
                    t = 0;
                    if (
                        ItemService.getBoughtAmount(TheFirstDropper) > 0 &&
                        ItemService.getBoughtAmount(TheFirstFurnace) > 0
                    ) {
                        stage.complete();
                        new Dialogue(Tria, "Oh! You did it... wow, nice job!")
                            .monologue("Now... um... place those items down carefully, okay?")
                            .monologue("Make sure... uh... the dropper head is... above the furnace... yes.")
                            .monologue("Heh... let's see if this... can make some money... I hope.")
                            .root.talk();
                    }
                });
                return () => connection.Disconnect();
            }),
    )
    .addStage(
        new Stage()
            .setDescription("Interact with Tria to learn how to start making money.")
            .setNPC(Tria)
            .setDialogue(
                new Dialogue(
                    Tria,
                    "Uh... see that backpack over there... left side of your screen... yeah, click it to open your Inventory... I think.",
                ),
            )
            .onReached((stage) => {
                Tria.rootPart!.CFrame = WAYPOINTS.NewBeginningsTriaMineGuiding.CFrame;
                Tria.stopAnimation("Default");

                const continuation = new Dialogue(Tria, "N-nice job, uh... I guess!")
                    .monologue("I... I'd like to teach you more... but... I should probably... go home now... sorry.")
                    .monologue(
                        "I'll... I'll see you again when... um... you've made a bit more money... maybe...",
                    ).root;
                let completed = false;
                let continuationConn: RBXScriptConnection;
                const connection = Server.Currency.balanceChanged.connect((balance) => {
                    const funds = balance.get("Funds");
                    if (completed === false && funds !== undefined && !funds.lessEquals(0)) {
                        completed = true;
                        continuationConn = continuation.finished.connect(() => {
                            triaToStart().onComplete(() => {
                                Tria.playAnimation("Default");
                            });
                            stage.complete();
                        });
                        continuation.talk();
                    }
                });
                return () => {
                    connection.disconnect();
                    continuationConn?.Disconnect();
                };
            }),
    )
    .onInit(() => {
        Server.Event.addCompletionListener("TriaReveal", (isCompleted) => {
            if (isCompleted) Tria.revealActualName();
        });

        if (NameChanger.defaultDialogues[0]) {
            NameChanger.defaultDialogues[0].finished.connect(() => {
                Packets.tabOpened.toAllClients("Rename");
            });
        }
    })
    .setReward({
        xp: 60,
    });


import { convertToMMSS } from "@antivivi/vrldk";
import { RunService } from "@rbxts/services";
import { Dialogue } from "server/interactive/npc/NPC";
import ProfAlaric from "server/interactive/npc/Prof. Alaric";
import Quest, { Stage } from "server/quests/Quest";
import { Server } from "shared/api/APIExpose";

const OBBY_TIME_LIMIT = 300; // 5 minutes in seconds

export = new Quest(script.Name)
    .setName("Obby Studies")
    .setLength(4)
    .setLevel(8)
    .setOrder(1)
    .addStage(
        new Stage()
            .setDescription(`Talk to the scientist in the laboratory at %coords%.`)
            .setNPC(ProfAlaric, true)
            .setDialogue(
                new Dialogue(ProfAlaric, "Oh hey, another test subject—I mean, visitor! Welcome to my lab, bro.")
                    .monologue("I've tried everything, but nothing works. Even my coffee is broken.")
                    .monologue(
                        "Look, I've been working on these item prototypes, right? But like... they just don't function. At all.",
                    )
                    .monologue("I need real-world testing data. That's where you come in.")
                    .monologue("I built this simulation chamber—it's basically an obby that gets progressively harder.")
                    .monologue("The simulation is just a little difficult. Like, only slightly soul-crushing.")
                    .monologue(
                        "You have 5 minutes to get as far as possible. Each checkpoint you reach gives me valuable data!",
                    )
                    .monologue("If you reach the end, tell me how. I think I coded it wrong.")
                    .monologue("So... you willing to do this? It's for science. And my sanity.").root,
            )
            .onReached((stage) => {
                ProfAlaric.rootPart!.CFrame = ProfAlaric.startingCFrame;
                ProfAlaric.playAnimation("Default");

                const connection = stage.dialogue?.finished.connect(() => {
                    stage.complete();
                });
                return () => connection?.disconnect();
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Enter the simulation chamber at %coords%.`)
            .setDialogue(
                new Dialogue(ProfAlaric, "The chamber is right over there. Step inside when you're ready.")
                    .monologue("Don't worry if you fail—most people do. It's totally normal.")
                    .monologue("Actually, everyone fails. But you might be different!").root,
            )
            .onReached((stage) => {
                ProfAlaric.stopAnimation("Default");

                // For now, auto-complete this stage after a short delay
                // In the actual game, this would check if the player entered a specific zone
                // The waypoint "ObbyStudiesSimulationEntrance" would need to be created in the game workspace
                task.wait(3);
                stage.complete();
                return () => {};
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Complete the obby simulation. Time remaining: %time%`)
            .setDialogue(
                new Dialogue(
                    ProfAlaric,
                    "Alright, simulation starting now! You've got 5 minutes. Let's see what you can do!",
                ).root,
            )
            .onReached((stage) => {
                ProfAlaric.stopAnimation("Default");

                // Initialize obby tracking in quest metadata
                const questMetadata = Server.empireData.questMetadata;
                questMetadata.set("ObbyStudiesStartTime", Server.empireData.playtime);
                questMetadata.set("ObbyStudiesCheckpoints", 0);
                questMetadata.set("ObbyStudiesMaxCheckpoint", 0);

                let timeElapsed = 0;
                let lastUpdate = 0;

                const connection = RunService.Heartbeat.Connect((dt) => {
                    timeElapsed += dt;
                    const timeRemaining = OBBY_TIME_LIMIT - timeElapsed;

                    // Update stage description every second
                    if (timeElapsed - lastUpdate >= 1) {
                        lastUpdate = timeElapsed;
                        stage.setDescription(
                            `Complete the obby simulation. Time remaining: ${convertToMMSS(timeRemaining)}`,
                        );
                    }

                    // Check if time is up
                    if (timeRemaining <= 0) {
                        const checkpointsReached = questMetadata.get("ObbyStudiesCheckpoints") as number | undefined;
                        Server.ChatHook.sendServerMessage(
                            `Time's up! You reached ${checkpointsReached ?? 0} checkpoints. Not bad!`,
                        );
                        stage.complete();
                    }

                    // Simulate checkpoint progression based on time
                    // In a real implementation, this would be handled by touching checkpoint parts in the obby
                    const simulatedCheckpoints = math.floor(timeElapsed / 30); // 1 checkpoint every 30 seconds
                    const currentCheckpoints = questMetadata.get("ObbyStudiesCheckpoints") as number | undefined;
                    if (simulatedCheckpoints > (currentCheckpoints ?? 0)) {
                        questMetadata.set("ObbyStudiesCheckpoints", simulatedCheckpoints);
                        if (
                            simulatedCheckpoints >
                            ((questMetadata.get("ObbyStudiesMaxCheckpoint") as number | undefined) ?? 0)
                        ) {
                            questMetadata.set("ObbyStudiesMaxCheckpoint", simulatedCheckpoints);
                        }
                        Server.ChatHook.sendServerMessage(`Checkpoint ${simulatedCheckpoints} reached!`);
                    }
                });

                return () => connection.Disconnect();
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Return to the scientist for your results.`)
            .setNPC(ProfAlaric)
            .setDialogue(new Dialogue(ProfAlaric, "You're back! Let me analyze the data..."))
            .onReached((stage) => {
                ProfAlaric.rootPart!.CFrame = ProfAlaric.startingCFrame;
                ProfAlaric.playAnimation("Default");

                const questMetadata = Server.empireData.questMetadata;
                const checkpointsReached = (questMetadata.get("ObbyStudiesCheckpoints") as number | undefined) ?? 0;

                let resultsDialogue: Dialogue;

                if (checkpointsReached >= 10) {
                    // Reached the "impossible" section
                    resultsDialogue = new Dialogue(
                        ProfAlaric,
                        "BRO. You broke my simulation. You weren't supposed to reach that far!",
                    )
                        .monologue("Do you have skill installed or something? This is wild.")
                        .monologue("I need to rethink physics. And my entire career.")
                        .monologue(
                            "Here, take this reward. You earned it. And unlocked access to the Challenges board too!",
                        ).root;
                } else if (checkpointsReached >= 7) {
                    resultsDialogue = new Dialogue(
                        ProfAlaric,
                        "Okay, I'm genuinely impressed. You got really far in there.",
                    )
                        .monologue("The data you provided is super valuable. My simulations might actually work now!")
                        .monologue(
                            "Here's your reward, and you've unlocked the Challenges board. Go check it out!",
                        ).root;
                } else if (checkpointsReached >= 4) {
                    resultsDialogue = new Dialogue(ProfAlaric, "Not bad! You reached a decent number of checkpoints.")
                        .monologue("The data is useful, even if you didn't get super far.")
                        .monologue("Take this reward, and you can now access the Challenges board!").root;
                } else {
                    resultsDialogue = new Dialogue(ProfAlaric, "Well... you tried. That's what matters, right?")
                        .monologue("The simulation is hard, I get it. Even I can't beat it.")
                        .monologue(
                            "Here's a participation reward. And hey, you've unlocked the Challenges board anyway!",
                        ).root;
                }

                const stageDialogueConn = stage.dialogue!.finished.connect(() => {
                    resultsDialogue.talk();
                });

                const resultsDialogueConn = resultsDialogue.finished.connect(() => {
                    // Mark that challenges are now unlocked
                    questMetadata.set("ChallengesUnlocked", true);
                    Server.Challenge.refreshChallenges();
                    stage.complete();
                });

                return () => {
                    stageDialogueConn.disconnect();
                    resultsDialogueConn.disconnect();
                };
            }),
    )
    .setCompletionDialogue(
        new Dialogue(
            ProfAlaric,
            "Thanks for helping with my research! Feel free to come back and try the simulation again anytime.",
        ).monologue("Just don't expect rewards every time—this is science, not a charity.").root,
    )
    .setReward({
        xp: 300,
    });


/**
 * @fileoverview Implements the Quest and Stage classes for managing multi-stage quests and quest progression on the server.
 * Handles quest initialization, stage management, NPC/dialogue integration, and quest rewards for the Roblox game.
 *
 * @since 1.0.0
 */

import Signal from "@antivivi/lemon-signal";
import { AnalyticsService, Players, ReplicatedStorage } from "@rbxts/services";
import NPC, { Dialogue } from "server/interactive/npc/NPC";
import { Server } from "shared/api/APIExpose";
import eat from "shared/hamster/eat";
import { Identifiable, ModuleRegistry } from "shared/hamster/ModuleRegistry";
import Items from "shared/items/Items";
import Packets from "shared/Packets";

/**
 * Represents a single stage within a quest, including its description, position, focus, dialogue, and NPCs.
 * Provides methods for configuring stage properties and handling stage events.
 */
export class Stage {
    /** The description of the stage. */
    description: string | undefined;

    /** The position players are guided to when focusing on this stage. */
    position: Vector3 | undefined;

    /** The part players are guided to when focusing on this stage. */
    focus: BasePart | undefined;

    /** The dialogue added when this stage is reached. */
    dialogue: Dialogue | undefined;

    /** The NPC model associated with this stage. */
    npcModel: Model | undefined;

    /** The humanoid associated with the NPC model. */
    npcHumanoid: Humanoid | undefined;

    private positionChangedCallback?: (position: Vector3 | undefined) => void;
    private reachedCallback?: (stage: this) => () => void;
    private completedCallback?: (stage: this) => void;

    private reached = false;
    private completed = false;
    private cframeChangedConnection: RBXScriptConnection | undefined;

    constructor() {}

    /**
     * Sets the description for this stage.
     * @param description The stage description.
     * @returns This stage instance.
     */
    setDescription(description: string) {
        this.description = description;
        return this;
    }

    /**
     * Sets the focus instance for this stage, optionally tracking its position.
     * @param instance The instance to focus on.
     * @returns This stage instance.
     */
    setFocus(instance?: Instance) {
        if (instance !== undefined && instance.IsA("BasePart")) {
            this.focus = instance;
            this.setPosition(instance.Position);
            this.cframeChangedConnection?.Disconnect();
            this.cframeChangedConnection = instance
                .GetPropertyChangedSignal("CFrame")
                .Connect(() => this.setPosition(instance.Position));
        }
        return this;
    }

    /**
     * Sets the NPC for this stage by name, optionally setting it as the focus.
     * @param npcName The name of the NPC.
     * @param setAsFocus Whether to set the NPC as the focus.
     * @returns This stage instance.
     */
    setNPC(npc: NPC, setAsFocus?: boolean) {
        this.npcModel = npc.model;
        this.npcHumanoid = npc.humanoid;
        if (setAsFocus === true) return this.setFocus(this.npcHumanoid?.RootPart);
        return this;
    }

    /**
     * Sets the position for this stage and fires the positionChanged signal if changed.
     * @param position The new position.
     * @returns This stage instance.
     */
    setPosition(position?: Vector3) {
        if (this.position !== position) {
            this.position = position;
            this.positionChangedCallback?.(position);
        }
        return this;
    }

    /**
     * Sets the dialogue for this stage.
     * @param dialogue The dialogue object.
     * @returns This stage instance.
     */
    setDialogue(dialogue: Dialogue) {
        this.dialogue = dialogue;
        return this;
    }

    /**
     * Registers a callback to run when the stage is reached.
     *
     * @param reached The reached callback, returning an optional cleanup function.
     * @returns This stage instance.
     */
    onReached(reached: (stage: this) => () => void) {
        this.reachedCallback = reached;
        return this;
    }

    /**
     * Registers a callback to run when the stage is completed.
     *
     * @param complete The complete callback.
     * @returns This stage instance.
     */
    onComplete(complete: (stage: this) => void) {
        this.completedCallback = complete;
        return this;
    }

    /**
     * Registers a callback to run when the stage position changes.
     *
     * @param callback The position changed callback.
     * @returns This stage instance.
     */
    onPositionChanged(callback: (position: Vector3 | undefined) => void) {
        this.positionChangedCallback = callback;
        return this;
    }

    /**
     * Triggers the stage reached event.
     *
     * @returns A cleanup function to call when the stage is no longer needed.
     */
    reach() {
        if (this.reached) {
            // Prevent multiple reaches
            return () => {};
        }

        this.reached = true;
        const dialogue = this.dialogue;
        if (dialogue) dialogue.add();

        const cleanup = this.reachedCallback?.(this);

        return () => {
            if (dialogue) dialogue.remove();
            cleanup?.();
        };
    }

    /**
     * Completes the stage, firing the completed signal.
     */
    complete() {
        if (this.completed) {
            return;
        }
        this.completed = true;
        this.completedCallback?.(this);
    }

    /**
     * Unloads the stage and its resources.
     */
    unload() {
        this.dialogue?.remove();
        this.cframeChangedConnection?.Disconnect();
        table.clear(this);
    }
}

/**
 * Represents a multi-stage quest, including its metadata, stages, rewards, and completion dialogue.
 * Provides methods for configuring quest properties, managing stages, and handling quest initialization.
 */
export default class Quest extends Identifiable {
    static readonly REGISTRY = new ModuleRegistry<Quest>(script.Parent!, new Set([script])).setLoadCallback(
        (questPerId) => {
            print(`Loaded ${questPerId.size()} quests`);

            const questInfos = new Map<string, QuestInfo>();

            for (const [questId, quest] of questPerId) {
                // Prepare quest info for clients
                const questInfo: QuestInfo = {
                    name: quest.name ?? questId,
                    colorR: quest.color.R * 255,
                    colorG: quest.color.G * 255,
                    colorB: quest.color.B * 255,
                    level: quest.level,
                    length: quest.length,
                    reward: quest.reward,
                    order: quest.order,
                    stages: [],
                };
                for (const stage of quest.stages) {
                    questInfo.stages.push({ description: stage.description! });
                }

                questInfos.set(questId, questInfo);
            }

            // Send quest info to clients
            Packets.questInfo.set(questInfos);
            this.reachStages();
        },
    );
    static readonly CLEANUP_PER_STAGE = new Map<Stage, () => void>();

    static readonly COLORS = [
        Color3.fromRGB(253, 41, 67),
        Color3.fromRGB(1, 162, 255),
        Color3.fromRGB(2, 184, 87),
        Color3.fromRGB(255, 43, 245),
        Color3.fromRGB(255, 125, 0),
        Color3.fromRGB(240, 255, 26),
        Color3.fromRGB(255, 74, 166),
        Color3.fromRGB(255, 237, 61),
        Color3.fromRGB(112, 255, 84),
        Color3.fromRGB(36, 255, 209),
    ];

    name: string | undefined = undefined;
    description: string | undefined = undefined;
    readonly stages = new Array<Stage>();
    color: Color3;
    length = 0;
    level = 0;
    order = 0;
    reward: Reward = {};
    readonly initialized = new Signal<() => void>();
    completionDialogue: Dialogue | undefined;

    /** Whether the quest is completed. */
    completed = false;

    /**
     * Constructs a new Quest instance.
     * @param id The unique quest identifier.
     */
    constructor(public readonly id: string) {
        super(id);
        this.color = Quest.COLORS[string.byte(id)[0] % Quest.COLORS.size()];
    }

    override init() {
        this.initialized.fire();

        // Set up stage completion handling
        this.stages.forEach((stage, index) => {
            const onPositionChanged = (position?: Vector3) => {
                return ReplicatedStorage.SetAttribute(`${this.id}${index}`, position);
            };
            onPositionChanged(stage.position);
            stage.onPositionChanged(onPositionChanged);

            stage.onComplete((stage) => {
                const newStage = this.advance(index);
                if (newStage === undefined) {
                    return;
                }
                Quest.CLEANUP_PER_STAGE.get(stage)?.();
                print(`Completed stage ${index} in ${this.id}, now in stage ${newStage}`);

                // Log analytics for quest progression
                const player = Players.GetPlayers()[0];
                if (player !== undefined)
                    AnalyticsService.LogOnboardingFunnelStepEvent(player, index + 1, `Quest ${this.name}`);
                Quest.reachStages();
            });
        });

        return () => {
            this.stages.forEach((stage) => {
                stage.unload();
            });
            table.clear(this);
        };
    }

    /**
     * Sets the name of the quest.
     * @param name The quest name.
     * @returns This quest instance.
     */
    setName(name: string) {
        this.name = name;
        return this;
    }

    /**
     * Sets the length (number of stages) of the quest.
     * @param length The quest length.
     * @returns This quest instance.
     */
    setLength(length: number) {
        this.length = length;
        return this;
    }

    /**
     * Sets the level requirement for the quest.
     * @param level The required level.
     * @returns This quest instance.
     */
    setLevel(level: number) {
        this.level = level;
        return this;
    }

    /**
     * Sets the order of the quest (for sorting).
     * @param order The quest order.
     * @returns This quest instance.
     */
    setOrder(order: number) {
        this.order = order;
        return this;
    }

    /**
     * Sets the reward for completing the quest.
     * @param reward The reward object.
     * @returns This quest instance.
     */
    setReward(reward: Reward) {
        this.reward = reward;
        return this;
    }

    /**
     * Creates a quest requirement that must be completed before this quest can be started.
     * @param questId The ID of the required quest.
     * @returns This quest instance.
     */
    createQuestRequirement(questId: string) {
        const depModule = Quest.REGISTRY.MODULES.get(questId);
        if (!depModule) {
            throw `Quest module not found for ID: ${questId}`;
        }
        const depQuest = require(depModule) as Quest;
        if (!depQuest) {
            throw `Quest not found for ID: ${questId}`;
        }
        const stage = new Stage()
            .setDescription(`Complete the quest "${depQuest.name}" before starting this.`)
            .onReached(() => {
                while (!Quest.REGISTRY.OBJECTS.get(questId)?.completed) {
                    task.wait(2);
                }
                stage.complete();
                return () => {};
            });
        return this.setStage(1, stage);
    }

    /**
     * Sets a stage at the specified position in the quest.
     * @param number The stage number (1-based).
     * @param stage The Stage instance.
     * @returns This quest instance.
     */
    setStage(number: number, stage: Stage) {
        this.stages.insert(number - 1, stage);
        return this;
    }

    /**
     * Adds a stage to the end of the quest.
     * @param stage The Stage instance.
     * @returns This quest instance.
     */
    addStage(stage: Stage) {
        this.stages.push(stage);
        return this;
    }

    /**
     * Sets the dialogue to be shown upon quest completion.
     * @param dialogue The completion Dialogue.
     * @returns This quest instance.
     */
    setCompletionDialogue(dialogue: Dialogue) {
        this.completionDialogue = dialogue;
        return this;
    }

    /**
     * Registers a callback to run when the quest is initialized.
     * @param callback The callback function.
     * @returns This quest instance.
     */
    onInit(callback: (quest: this) => void) {
        const connection = this.initialized.connect(() => callback(this));
        eat(connection, "Disconnect");
        return this;
    }

    /**
     * Advances a quest to the next stage with validation.
     * Ensures stages are completed in order and handles quest completion.
     * Fires {@link complete} if the quest is finished.
     * @param current The current stage number that should be completed.
     * @param stagePerQuest The map of quest IDs to their current stage numbers.
     * @returns The new stage number, or undefined if advancement failed.
     *          Returns -1 if the quest is now completed.
     */
    advance(current: number, stagePerQuest = Server.empireData.quests) {
        const currentStage = stagePerQuest.get(this.id);

        // Skip if quest is already completed
        if (currentStage === -1) {
            return;
        }

        const stageSize = this.stages.size();
        const newStage = (currentStage ?? 0) + 1;

        // Validate stage progression order
        if (newStage !== current + 1) {
            return;
        }

        // Determine next stage or completion
        const n = newStage > stageSize - 1 ? -1 : newStage;
        stagePerQuest.set(this.id, n);
        if (n === -1) {
            this.complete();
        }
        return n;
    }

    /**
     * Completes the quest, triggering rewards and cleanup.
     */
    complete() {
        if (this.completed) return warn(`Quest ${this.id} is already completed`);
        this.completed = true;

        Packets.questCompleted.toAllClients(this.id);
        const reward = this.reward;

        // Award experience points
        if (reward.xp !== undefined) {
            const originalXp = Server.Level.getXp();
            if (originalXp === undefined) {
                warn("No original xp, not rewarding");
            } else {
                Server.Level.setXp(originalXp + reward.xp);
            }
        }

        // Award items
        if (reward.items !== undefined) {
            for (const [itemId, amount] of reward.items) {
                const item = Items.getItem(itemId);
                if (item === undefined) {
                    warn(`Could not find item with id ${itemId} to reward for quest ${this.id}`);
                    continue;
                }
                Server.Item.giveItem(item, amount);
            }
        }
    }

    /**
     * Checks and triggers the reachability of quest stages based on player level.
     *
     * @param level The player level to check quest reachability (defaults to current level).
     */
    static async reachStages(level?: number) {
        const empireData = Server.empireData;
        if (empireData === undefined) {
            // Defer stage reach logic until empire data is available to prevent quests from stalling on load.
            task.spawn(() => {
                let halted = false;
                eat(() => (halted = true));
                while (Server.empireData === undefined && !halted) {
                    task.wait();
                }
                Quest.reachStages(level);
            });
            return;
        }

        if (level === undefined) {
            level = empireData.level;
        }
        const stagePerQuest = empireData.quests;
        if (stagePerQuest === undefined) {
            return;
        }

        for (const [id, quest] of Quest.REGISTRY.OBJECTS) {
            const current = stagePerQuest.get(id) ?? 0;

            // Clean up all other stages
            for (let i = 0; i < quest.stages.size(); i++) {
                if (i !== current) this.CLEANUP_PER_STAGE.get(quest.stages[i])?.();
            }

            // Skip quests above player level
            if (quest.level > level) {
                continue;
            }

            // Handle already completed quests
            if (current === -1) {
                quest.completed = true;
                const completionDialogue = quest.completionDialogue;
                if (completionDialogue) {
                    completionDialogue.add();
                }
            }

            const stage = quest.stages[current];

            // Reach the current stage, set up cleanup
            if (stage !== undefined) {
                const cleanup = stage.reach();
                this.CLEANUP_PER_STAGE.set(stage, () => {
                    cleanup();
                    this.CLEANUP_PER_STAGE.delete(stage);
                });
            }
        }
    }
}


import { playSoundAtPart } from "@antivivi/vrldk";
import { Debris, TweenService, Workspace } from "@rbxts/services";
import Freddy from "server/interactive/npc/Freddy";
import { Dialogue } from "server/interactive/npc/NPC";
import SlamoReceptionist from "server/interactive/npc/Slamo Receptionist";
import SlamoRefugee from "server/interactive/npc/Slamo Refugee";
import Quest, { Stage } from "server/quests/Quest";
import { Server } from "shared/api/APIExpose";
import { getEffect, getSound, playSound } from "shared/asset/GameAssets";
import { WAYPOINTS } from "shared/constants";
import { eatSnapshot } from "shared/hamster/eat";
import ChargedEmpoweredBrick from "shared/items/negative/instantwin/ChargedEmpoweredBrick";
import EmpoweredBrick from "shared/items/negative/instantwin/EmpoweredBrick";
import XLWool from "shared/items/negative/relax/XLWool";
import CustomProximityPrompt from "shared/world/CustomProximityPrompt";
import FreddysCauldron from "shared/world/nodes/FreddysCauldron";
import SlamoVillageConnection from "shared/world/nodes/SlamoVillageConnection";

const cauldron = FreddysCauldron.waitForInstance();
const linkway = SlamoVillageConnection.waitForInstance();
eatSnapshot(cauldron);
eatSnapshot(linkway);

const instantWinEffects = new Array<BasePart>();
for (const child of cauldron.GetChildren()) {
    if (child.Name === "InstantWin" && child.IsA("BasePart")) {
        instantWinEffects.push(child);
    }
}
const explosionEffect = cauldron.PrimaryPart!.FindFirstChildOfClass("ParticleEmitter")!;
const instantWinBlock = cauldron.WaitForChild("InstantWinBlock") as BasePart;
const hideInstantWinBlock = () => {
    instantWinBlock.Transparency = 1;
    for (const decal of instantWinBlock.GetChildren()) {
        if (decal.IsA("Decal")) {
            decal.Transparency = 1;
        }
    }
};
const showInstantWinBlock = () => {
    instantWinBlock.Transparency = 0;
    for (const decal of instantWinBlock.GetChildren()) {
        if (decal.IsA("Decal")) {
            decal.Transparency = 0;
        }
    }
};

const refugeeToWaiting = SlamoRefugee.createPathfindingOperation(
    SlamoRefugee.startingCFrame,
    WAYPOINTS.ToTheVillageRefugeeWaiting.CFrame,
);

const refugeeToBrewing = SlamoRefugee.createPathfindingOperation(
    WAYPOINTS.ToTheVillageRefugeeWaiting.CFrame,
    WAYPOINTS.ToTheVillageRefugeeBrewing.CFrame,
);

const refugeeToIntermittent = SlamoRefugee.createPathfindingOperation(
    WAYPOINTS.ToTheVillageRefugeeBrewing.CFrame,
    WAYPOINTS.ToTheVillageRefugeeIntermittentIsles.CFrame,
);

const refugeeToEnteringSlamoVillage = SlamoRefugee.createPathfindingOperation(
    WAYPOINTS.ToTheVillageRefugeeIntermittentIsles.CFrame,
    WAYPOINTS.ToTheVillageRefugeeEnteringSlamoVillage.CFrame,
);

const refugeeToEnteringPoliceStation = SlamoRefugee.createPathfindingOperation(
    WAYPOINTS.ToTheVillageRefugeeEnteringSlamoVillage.CFrame,
    WAYPOINTS.ToTheVillageRefugeeEnteringPoliceStation.CFrame,
    false,
);

export = new Quest(script.Name)
    .setName("To The Village")
    .setLength(3)
    .setLevel(4)
    .setOrder(8)
    .createQuestRequirement("AHelpingHand")
    .addStage(
        new Stage()
            .setNPC(SlamoRefugee, true)
            .setDescription(`Talk to the Slamo Refugee at %coords%.`)
            .setDialogue(
                new Dialogue(SlamoRefugee, "Sigh... I miss my village.")
                    .monologue(
                        "Did you know that there used to be a linkway between this barren wasteland and Slamo Village? It collapsed long ago, and I've been stuck here ever since.",
                    )
                    .monologue("Say, if you could help me get back to Slamo Village, I would be forever grateful.")
                    .monologue(
                        "To start, can you give me an Empowered Brick? You can craft one at that weird blacksmith's place.",
                    ).root,
            )
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = SlamoRefugee.startingCFrame!;

                const connection = stage.dialogue!.finished.connect(() => {
                    stage.complete();
                });
                return () => connection.disconnect();
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Craft an Empowered Brick and give it to the Slamo Refugee.`)
            .setFocus(WAYPOINTS.CraftingTable)
            .setDialogue(new Dialogue(SlamoRefugee, "Do you have that Empowered Brick?"))
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = SlamoRefugee.startingCFrame!;

                const continuation = new Dialogue(SlamoRefugee, "Wow... I feel the power of the brick already!")
                    .monologue(
                        "In order to fully manifest the power of the Empowered Brick, I need to infuse it with Instant Win energy.",
                    )
                    .monologue(
                        "To start, can you obtain 2 XL Wool? You can get it from that guy selling wool in the marketplace.",
                    ).root;
                const stageDialogueConn = stage.dialogue!.finished.connect(() => {
                    if (Server.Item.getAvailableAmount(EmpoweredBrick) >= 1) {
                        continuation.talk();
                    }
                });
                const continuationConn = continuation.finished.connect(() => {
                    if (Server.Quest.takeQuestItem(EmpoweredBrick, 1)) {
                        stage.complete();
                    }
                });
                return () => {
                    stageDialogueConn.disconnect();
                    continuationConn.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Obtain 2 XL Wool from the marketplace and give it to the Slamo Refugee.`)
            .setDialogue(new Dialogue(SlamoRefugee, "Do you have the 2 XL Wool?"))
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = SlamoRefugee.startingCFrame!;

                const continuation = new Dialogue(
                    SlamoRefugee,
                    "This is some high-quality wool. We can use this to absorb the Instant Win energy.",
                ).monologue(
                    "I know Freddy has a cauldron that has a bunch of Instant Win energy, but he won't let me use it. I hope you can convince him to let us borrow it.",
                ).root;

                const stageDialogueConn = stage.dialogue!.finished.connect(() => {
                    if (Server.Item.getAvailableAmount(XLWool) >= 2) {
                        continuation.talk();
                    }
                });
                const continuationConn = continuation.finished.connect(() => {
                    if (Server.Quest.takeQuestItem(XLWool, 2)) {
                        stage.complete();
                    }
                });
                return () => {
                    stageDialogueConn.disconnect();
                    continuationConn.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setNPC(Freddy, true)
            .setDescription(`Talk to Freddy at %coords%.`)
            .setDialogue(
                new Dialogue(
                    SlamoRefugee,
                    "Please talk to Freddy. He has a cauldron that can dissolve the XL Wool in Instant Win energy.",
                ),
            )
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = SlamoRefugee.startingCFrame!;
                Freddy.rootPart!.CFrame = WAYPOINTS.ToTheVillageFreddyWaiting.CFrame;
                SlamoRefugee.model!.FindFirstChild("FishingRod")?.Destroy();

                const continuation = new Dialogue(Freddy, "Oh, my friend! How can I help you?")
                    .monologue("You need to borrow my cauldron? Of course, you can use it!")
                    .next(new Dialogue(SlamoRefugee, "What the... how did you get him to agree so easily?"))
                    .monologue(
                        "I guess he just really likes you. Anyways, let's get in his house. We can get started right away.",
                    ).root;

                refugeeToWaiting();
                continuation.add(4);

                const connection = continuation.finished.connect(() => {
                    stage.complete();
                });
                return () => {
                    connection.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Fill the cauldron with Instant Win energy.`)
            .setFocus(cauldron.PrimaryPart!)
            .setDialogue(new Dialogue(SlamoRefugee, "Go on, fill the cauldron!"))
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = WAYPOINTS.ToTheVillageRefugeeWaiting.CFrame;
                Freddy.rootPart!.CFrame = WAYPOINTS.ToTheVillageFreddyWaiting.CFrame;
                SlamoRefugee.model!.FindFirstChild("FishingRod")?.Destroy();

                refugeeToBrewing();

                const proximityPrompt = cauldron.WaitForChild("ProximityPrompt") as ProximityPrompt;
                proximityPrompt.Enabled = true;
                const cleanup = CustomProximityPrompt.onTrigger(proximityPrompt, () => {
                    for (const effect of instantWinEffects) {
                        effect.Transparency = 0;
                    }
                    explosionEffect.Emit(2);
                    playSound("MagicSprinkle.mp3", cauldron.PrimaryPart);
                    proximityPrompt.Enabled = false;
                    stage.complete();
                });

                return () => {
                    cleanup();
                };
            }),
    )
    .addStage(
        new Stage()
            .setDescription(
                `Let the Slamo Refugee use the cauldron to infuse the Empowered Brick with Instant Win energy.`,
            )
            .setFocus(cauldron.PrimaryPart!)
            .setDialogue(new Dialogue(SlamoRefugee, "Let me handle this."))
            .onReached((stage) => {
                for (const effect of instantWinEffects) {
                    effect.Transparency = 0;
                }
                SlamoRefugee.rootPart!.CFrame = WAYPOINTS.ToTheVillageRefugeeBrewing.CFrame;
                Freddy.rootPart!.CFrame = WAYPOINTS.ToTheVillageFreddyWaiting.CFrame;
                SlamoRefugee.model!.FindFirstChild("FishingRod")?.Destroy();

                new Dialogue(SlamoRefugee, "Let me handle this.").talk();
                const continuation = new Dialogue(
                    SlamoRefugee,
                    "Wow... it's raw Instant Win energy. It's so potent!",
                ).monologue("Now, I can infuse the Empowered Brick with this energy. Just give me a moment.").root;
                const continuation2 = new Dialogue(
                    SlamoRefugee,
                    "It's beautiful! I can feel the power radiating from it.",
                ).monologue("Let's go repair the linkway to Slamo Village. I can't wait to get back home!").root;

                const wool = XLWool.MODEL!.Clone();
                const parts = new Array<BasePart>();
                for (const part of wool.GetDescendants()) {
                    if (part.IsA("BasePart")) {
                        part.CanCollide = false;
                        part.Anchored = true;
                        parts.push(part);
                    }
                }
                wool.PivotTo(cauldron.PrimaryPart!.CFrame.add(new Vector3(0, 5, 0)));
                wool.Parent = Workspace;

                task.wait(1);
                for (const part of parts) {
                    TweenService.Create(part, new TweenInfo(1), {
                        CFrame: part.CFrame.add(new Vector3(0, -4, 0)),
                    }).Play();
                }
                task.wait(1);
                playSound("MagicPowerUp.mp3", cauldron.PrimaryPart);
                wool.Destroy();
                const effect = explosionEffect.Clone();
                effect.Parent = instantWinBlock;
                effect.Emit(4);
                for (const effect of instantWinEffects) {
                    effect.Transparency = 1;
                }
                showInstantWinBlock();
                task.wait(1);
                continuation.talk();

                const empoweredBrick = EmpoweredBrick.MODEL!.Clone();
                const continuationConn = continuation.finished.connect(() => {
                    empoweredBrick.PivotTo(WAYPOINTS.ToTheVillageEmpoweredBrick.CFrame);
                    empoweredBrick.Parent = Workspace;
                    // tween the empowered brick inside the instant win block
                    for (const part of empoweredBrick.GetDescendants()) {
                        if (part.IsA("BasePart")) {
                            part.CanCollide = false;
                            part.Anchored = true;
                            task.delay(1, () => {
                                TweenService.Create(part, new TweenInfo(1), {
                                    CFrame: instantWinBlock.CFrame,
                                }).Play();
                            });
                        }
                    }
                    task.wait(2);
                    playSound("MagicSprinkle.mp3", instantWinBlock);
                    const effect = explosionEffect.Clone();
                    effect.Parent = instantWinBlock;
                    effect.Emit(2);
                    hideInstantWinBlock();

                    const light = new Instance("PointLight");
                    light.Color = new Color3(0, 0.27, 1);
                    light.Brightness = 2;
                    light.Range = 10;
                    light.Parent = empoweredBrick.PrimaryPart;

                    task.wait(1);
                    continuation2.talk();
                });
                const continuation2Conn = continuation2.finished.connect(() => {
                    stage.complete();
                    Server.Quest.giveQuestItem(ChargedEmpoweredBrick, 1);
                    empoweredBrick.Destroy();
                });
                return () => {
                    continuationConn.disconnect();
                    continuation2Conn.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Go to the Slamo Village linkway with the Charged Empowered Brick at %coords%.`)
            .setFocus(WAYPOINTS.ToTheVillageRefugeeIntermittentIsles)
            .setDialogue(
                new Dialogue(
                    SlamoRefugee,
                    "All you need to do is place the Charged Empowered Brick down. Once you do that, the linkway will be repaired and we can finally go home.",
                ),
            )
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = WAYPOINTS.ToTheVillageRefugeeBrewing.CFrame;
                Freddy.rootPart!.CFrame = WAYPOINTS.ToTheVillageFreddyWaiting.CFrame;
                SlamoRefugee.model!.FindFirstChild("FishingRod")?.Destroy();

                const particlePart = linkway.WaitForChild("Particle");
                const linkwayEffect = getEffect("MassiveMagicExplosion").Clone();
                linkwayEffect.Enabled = false;
                linkwayEffect.Parent = particlePart.WaitForChild("Attachment");

                const dialogue = new Dialogue(
                    SlamoRefugee,
                    "We're here. All you need to do is place the Charged Empowered Brick down. Once you do that, the linkway will be repaired and we can finally go home.",
                );
                refugeeToIntermittent().onComplete(() => {
                    dialogue.talk();
                });

                const connection = Server.Item.itemsPlaced.connect((_, placedItems) => {
                    let placed = false;
                    for (const item of placedItems) {
                        if (item.item === ChargedEmpoweredBrick.id) {
                            placed = true;
                        }
                    }
                    if (placed === false) {
                        return;
                    }

                    // Show the linkway
                    const light = new Instance("PointLight");
                    light.Color = new Color3(0, 0.27, 1);
                    light.Brightness = 5;
                    light.Range = 60;
                    light.Parent = particlePart;
                    TweenService.Create(light, new TweenInfo(1), {
                        Range: 0,
                        Brightness: 0,
                    }).Play();
                    Debris.AddItem(light, 1);

                    linkwayEffect.Emit(4);
                    playSoundAtPart(particlePart, getSound("LaserExplosion.mp3"), 2);
                    Server.Area.unlockArea("SlamoVillage");
                    stage.complete();
                });

                return () => {
                    connection.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setNPC(SlamoRefugee, true)
            .setDescription(`Follow the Slamo Refugee to the village.`)
            .setDialogue(new Dialogue(SlamoRefugee, "..."))
            .onReached((stage) => {
                SlamoRefugee.rootPart!.CFrame = WAYPOINTS.ToTheVillageRefugeeIntermittentIsles.CFrame;
                Freddy.rootPart!.CFrame = WAYPOINTS.ToTheVillageFreddyWaiting.CFrame;
                SlamoRefugee.model!.FindFirstChild("FishingRod")?.Destroy();

                const continuation = new Dialogue(SlamoRefugee, "Hey.")
                    .monologue("I bet you're wondering why I was in the Barren Islands in the first place.")
                    .monologue("To tell you the truth, I was exiled from Slamo Village for being too... different.")
                    .monologue("And now that I'm back... It's time to take my revenge.").root;

                refugeeToEnteringSlamoVillage().onComplete(() => {
                    continuation.talk();
                });
                task.wait(1);
                new Dialogue(SlamoRefugee, "Finally! The linkway is repaired!").talk(false);
                task.wait(7);
                new Dialogue(SlamoRefugee, "After all these years, I can finally see home again.").talk(false);
                task.wait(6);
                new Dialogue(SlamoRefugee, "The village is right up ahead!").talk(false);
                task.wait(6);
                new Dialogue(SlamoRefugee, "It's just as I remembered it... Small, cozy, and full of life.").talk(
                    false,
                );

                task.wait(10);
                new Dialogue(SlamoRefugee, "...").talk(false);

                const connection = continuation.finished.connect(() => {
                    SlamoRefugee.humanoid!.WalkSpeed = 30;
                    refugeeToEnteringPoliceStation(false).onComplete(() => {
                        SlamoRefugee.rootPart!.CFrame = WAYPOINTS.ToTheVillageRefugeeImprisoned.CFrame;
                        SlamoRefugee.rootPart!.Anchored = true;
                        stage.complete();
                    });
                });

                return () => {
                    connection.disconnect();
                };
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Chase after the Slamo Refugee.`)
            .setFocus(WAYPOINTS.ToTheVillageRefugeeEnteringPoliceStation)
            .setDialogue(
                new Dialogue(SlamoReceptionist, "Did you really let that exiled refugee back into the village?")
                    .monologue("Sigh... Well, we locked him in jail. He'll be dealt with soon enough.")
                    .monologue("I guess you kind of helped us out, since we didn't have to deal with him ourselves.")
                    .monologue(
                        "I mean, he literally ran to the police station all by himself. What was he expecting? A warm welcome?",
                    ).root,
            )
            .onReached((stage) => {
                Server.Event.setEventCompleted("ImprisonedSlamoRefugee", true);

                const connection = stage.dialogue!.finished.connect(() => {
                    stage.complete();
                });
                return () => connection.disconnect();
            }),
    )
    .addStage(
        new Stage()
            .setDescription(`Talk to the Slamo Refugee.`)
            .setFocus(WAYPOINTS.ToTheVillageRefugeeImprisoned)
            .setDialogue(
                new Dialogue(SlamoRefugee, "Damn them. I didn't know they invented a police force here.")
                    .monologue("I guess I should have expected it. They always were a bit too strict.")
                    .monologue(
                        "But I will not give up. Once I'm out of here, I will take back what's mine. Mark my words.",
                    )
                    .monologue("You know that mine in the middle of the village? I own that place. I built it myself.")
                    .monologue("And I will use that mine to rule this village. No one will stop me.")
                    .monologue("But for now, I will wait here. I have nothing left to lose.").root,
            )
            .onReached((stage) => {
                const connection = stage.dialogue!.finished.connect(() => {
                    stage.complete();
                });
                return () => connection.disconnect();
            }),
    )
    .setCompletionDialogue(
        new Dialogue(
            SlamoRefugee,
            "Even though I am imprisoned, I will not give up. I will make my way back to the village and take back what is mine.",
        ),
    )
    .onInit(() => {
        for (const effect of instantWinEffects) {
            effect.Transparency = 1;
        }
        explosionEffect.Enabled = false;

        hideInstantWinBlock();
        instantWinBlock.CanCollide = false;

        Server.Event.addCompletionListener("ImprisonedSlamoRefugee", () => {
            SlamoRefugee.model!.FindFirstChild("FishingRod")?.Destroy();
            SlamoRefugee.rootPart!.Anchored = true;
            SlamoRefugee.rootPart!.CFrame = WAYPOINTS.ToTheVillageRefugeeImprisoned.CFrame;
        });
    })
    .setReward({
        xp: 410,
        area: "SlamoVillage",
    });


//!native
//!optimize 2

/**
 * @fileoverview Manages challenge gameplay, UI, and progression.
 *
 * This service handles:
 * - Starting and ending challenges
 * - Challenge requirements, rewards, and effects
 * - Challenge UI updates and synchronization
 * - Challenge state persistence and backup
 *
 * @since 1.0.0
 */

import { simpleInterval } from "@antivivi/vrldk";
import { OnStart, Service } from "@flamework/core";
import { OnoeNum } from "@rbxts/serikanum";
import CurrencyService from "server/services/data/CurrencyService";
import DataService from "server/services/data/DataService";
import NamedUpgradeService from "server/services/data/NamedUpgradeService";
import SetupService from "server/services/data/SetupService";
import ChatHookService from "server/services/permissions/ChatHookService";
import PermissionService from "server/services/permissions/PermissionService";
import ResetService from "server/services/ResetService";
import { CHALLENGE_PER_ID, CHALLENGE_UPGRADES, REWARD_UPGRADES } from "shared/Challenge";
import { RESET_LAYERS } from "shared/currency/mechanics/ResetLayer";
import eat from "shared/hamster/eat";
import Item from "shared/item/Item";
import Packets from "shared/Packets";

/**
 * Service that manages challenge logic, UI, and progression.
 */
@Service()
export class ChallengeService implements OnStart {
    /** Debounce timer for challenge actions. */
    private debounce = 0;

    /** Last completion timestamp for challenge effect throttling. */
    private lastCompletion = 0;
    private readonly lastEffectPerChallenge = new Map<string, number>();

    constructor(
        private readonly dataService: DataService,
        private readonly resetService: ResetService,
        private readonly currencyService: CurrencyService,
        private readonly namedUpgradeService: NamedUpgradeService,
        private readonly setupService: SetupService,
        private readonly permissionsService: PermissionService,
        private readonly chatHookService: ChatHookService,
    ) {}

    /**
     * Starts a challenge for the player, performing resets and backups as needed.
     *
     * @param player The player starting the challenge.
     * @param challengeId The ID of the challenge to start.
     * @returns The challenge details if started, otherwise undefined.
     */
    startChallenge(player: Player, challengeId: string) {
        const t = tick();
        if (t - this.debounce < 1) {
            return;
        }
        this.debounce = t;

        print("starting challenge", challengeId);
        const challengeDetails = CHALLENGE_PER_ID.get(challengeId);
        if (challengeDetails === undefined) return;

        const empireData = this.dataService.empireData;
        const currentLevel = this.getChallengeLevel(challengeId);
        if (currentLevel > challengeDetails.cap) return;

        const requiredEmpireLevel = challengeDetails.requiredEmpireLevel(currentLevel + 1);
        if (empireData.level < requiredEmpireLevel) return;

        if (challengeDetails.resets !== undefined) {
            const resetLayer = RESET_LAYERS[challengeDetails.resets as ResetLayerId];
            if (resetLayer !== undefined) {
                // save the setup first
                this.setupService.saveSetup(player, resetLayer.area, "Autosaved");

                empireData.backup.upgrades = new Map();
                for (const [upgrade, amount] of empireData.upgrades)
                    if (resetLayer.resettingUpgrades.includes(upgrade)) empireData.backup.upgrades.set(upgrade, amount);

                empireData.backup.currencies = new Map();
                for (const [currency, amount] of this.currencyService.balance.amountPerCurrency)
                    if (resetLayer.resettingCurrencies.includes(currency))
                        empireData.backup.currencies.set(currency, amount);

                this.resetService.performReset(resetLayer);
            }
        }
        empireData.currentChallengeStartTime = empireData.playtime;
        empireData.currentChallenge = challengeId;
        this.refreshCurrentChallenge();

        return challengeDetails;
    }
    /**
     * Updates the current challenge UI and synchronizes state with the client.
     */
    refreshCurrentChallenge() {
        const challengeId = this.dataService.empireData.currentChallenge;
        if (challengeId === undefined) {
            Packets.currentChallenge.set("");
            for (const [_, id] of CHALLENGE_UPGRADES) this.namedUpgradeService.setUpgradeAmount(id, 0);
        } else {
            const currentLevel = this.getChallengeLevel(challengeId);
            Packets.currentChallenge.set(challengeId);
            for (const [id, upgId] of CHALLENGE_UPGRADES)
                this.namedUpgradeService.setUpgradeAmount(upgId, id === challengeId ? currentLevel : 0);
        }
    }

    /**
     * Ends the current challenge, restoring backups and updating stats.
     *
     * @param cleared Whether the challenge was cleared.
     * @returns Tuple of challenge and new clears count.
     */
    endChallenge(cleared: boolean) {
        const t = tick();
        if (t - this.debounce < 1) {
            return $tuple(undefined, undefined);
        }
        this.debounce = t;

        const data = this.dataService.empireData;
        const challengeId = data.currentChallenge;
        if (challengeId === undefined) {
            return $tuple(undefined, undefined);
        }
        const challenge = CHALLENGE_PER_ID.get(challengeId);
        if (challenge === undefined) return $tuple(undefined, undefined);

        const resetLayer = RESET_LAYERS[challenge.resets as ResetLayerId];
        const cachedCurrencies = table.clone(data.currencies);

        this.resetService.performReset(resetLayer);
        data.currentChallenge = undefined;

        if (data.backup.currencies !== undefined) {
            for (const [currency, amount] of data.backup.currencies) {
                const currentAmount = cachedCurrencies.get(currency);
                if (currentAmount === undefined || new OnoeNum(currentAmount).lessThan(amount))
                    this.currencyService.set(currency, new OnoeNum(amount));
            }
            data.backup.currencies = undefined;
        }

        if (data.backup.upgrades !== undefined) {
            data.backup.upgrades.forEach((amount, id) => {
                if (this.namedUpgradeService.getUpgradeAmount(id) < amount) {
                    this.namedUpgradeService.setUpgradeAmount(id, amount);
                }
            });
            data.backup.upgrades = undefined;
        }
        let newClears: number | undefined;
        if (cleared === true) {
            const clears = data.challenges.get(challengeId);
            newClears = clears === undefined ? 1 : clears + 1;
            data.challenges.set(challengeId, newClears);

            const bestClear = data.challengeBestTimes.get(challengeId);
            const dt = data.playtime - data.currentChallengeStartTime;
            if (bestClear === undefined || dt > bestClear) {
                data.challengeBestTimes.set(challengeId, dt);
            }

            // Grant item rewards for this challenge level
            const itemReward = challenge.itemRewards?.get(newClears);
            if (itemReward !== undefined) {
                const itemId = itemReward.item.id;
                const currentRewards = data.challengeItemRewards.get(itemId) ?? 0;
                data.challengeItemRewards.set(itemId, currentRewards + itemReward.count);
                Packets.challengeItemBonuses.set(data.challengeItemRewards);
            }
        }
        this.refreshChallenges();
        return $tuple(challenge, newClears);
    }

    /**
     * Returns the current level for a given challenge ID.
     * @param challengeId The challenge ID.
     */
    getChallengeLevel(challengeId: string) {
        const clears = this.dataService.empireData.challenges.get(challengeId);
        return clears === undefined ? 1 : clears + 1;
    }

    /**
     * Applies the effect of the current challenge, checking for completion and running challenge logic.
     */
    challengeEffect() {
        const data = this.dataService.empireData;
        const challengeId = data.currentChallenge;
        if (challengeId === undefined) return;
        const challenge = CHALLENGE_PER_ID.get(challengeId);
        if (challenge === undefined) return;

        let challengeCompleted = false;
        if (typeIs(challenge.goal, "table")) {
            const items = challenge.goal as Item[];
            for (const item of items) {
                const amount = data.items.inventory.get(item.id);
                if (amount === undefined || amount < 1) continue;
                challengeCompleted = true;
                break;
            }
        }

        const t = tick();
        if (
            challengeCompleted === true &&
            t - this.lastCompletion > 2 &&
            data.playtime - data.currentChallengeStartTime > 2
        ) {
            this.lastCompletion = t;
            const title = challenge.getTitleLabel(this.getChallengeLevel(challengeId));
            this.chatHookService.sendServerMessage(`Challenge ${title} has been cleared!`);
            const [_challenge, clears] = this.endChallenge(true);
            Packets.challengeCompleted.toAllClients(title, challenge.getRewardLabel(clears!));
        } else if (challenge.challengeEffect !== undefined) {
            if (!this.lastEffectPerChallenge.has(challengeId)) {
                this.lastEffectPerChallenge.set(challengeId, t);
            }
            const dt = t - this.lastEffectPerChallenge.get(challengeId)!;
            if (challenge.challengeEffectInterval === undefined || dt > challenge.challengeEffectInterval) {
                this.lastEffectPerChallenge.set(challengeId, t);
                challenge.challengeEffect(dt, this.getChallengeLevel(challengeId), (message) => {
                    this.endChallenge(false);
                    this.chatHookService.sendServerMessage(message);
                });
            }
        }
    }

    /**
     * Refreshes the list of available challenges and updates the UI.
     */
    refreshChallenges() {
        const currentLevelPerChallenge = new Map<string, number>();
        for (const [id, challenge] of CHALLENGE_PER_ID) {
            const currentLevel = this.getChallengeLevel(id);
            if (currentLevel > challenge.cap) continue;
            if (currentLevel > 1) {
                const upgradeId = REWARD_UPGRADES.get(id);
                if (upgradeId !== undefined) this.namedUpgradeService.setUpgradeAmount(upgradeId, currentLevel - 1);
            }
            currentLevelPerChallenge.set(id, currentLevel);
        }
        Packets.currentLevelPerChallenge.set(currentLevelPerChallenge);
        this.refreshCurrentChallenge();
        this.initializeChallengeRewards();
    }

    /**
     * Initializes challenge item rewards tracker based on completed challenges.
     * This ensures players who completed challenges before this feature receive their rewards retroactively.
     */
    private initializeChallengeRewards() {
        const data = this.dataService.empireData;
        const rewardMap = new Map<string, number>();

        // Calculate all item rewards based on completed challenge levels
        for (const [challengeId, clears] of data.challenges) {
            const challenge = CHALLENGE_PER_ID.get(challengeId);
            if (challenge === undefined || challenge.itemRewards === undefined) continue;

            // Sum up rewards for all completed levels
            for (let level = 1; level <= clears; level++) {
                const itemReward = challenge.itemRewards.get(level);
                if (itemReward !== undefined) {
                    const itemId = itemReward.item.id;
                    rewardMap.set(itemId, (rewardMap.get(itemId) ?? 0) + itemReward.count);
                }
            }
        }

        // Update the data if there are any discrepancies
        let hasChanges = false;
        for (const [itemId, expectedAmount] of rewardMap) {
            const currentAmount = data.challengeItemRewards.get(itemId) ?? 0;
            if (currentAmount !== expectedAmount) {
                data.challengeItemRewards.set(itemId, expectedAmount);
                hasChanges = true;
            }
        }

        if (hasChanges) {
            Packets.challengeItemBonuses.set(data.challengeItemRewards);
        }
    }

    /**
     * Starts the challenge service, sets up listeners, and begins challenge effect loop.
     */
    onStart() {
        Packets.startChallenge.fromClient((player, challengeId) => {
            if (!this.permissionsService.hasPermission(player, "reset")) return false;
            if (this.dataService.empireData.questMetadata.get("ChallengesUnlocked") !== true) return false;

            const challenge = this.startChallenge(player, challengeId);
            if (challenge === undefined) return false;

            this.chatHookService.sendServerMessage(
                `Challenge ${challenge.getTitleLabel(this.getChallengeLevel(challenge.id))} has been started by ${player.Name}. The original setup has been saved, called "Autosaved", in a printer.`,
            );
            return true;
        });

        const quitConnection = Packets.quitChallenge.fromClient((player) => {
            if (!this.permissionsService.hasPermission(player, "reset")) return;

            const [challenge] = this.endChallenge(false);
            if (challenge === undefined) return;

            this.chatHookService.sendServerMessage(
                `Challenge ${challenge.getTitleLabel(this.getChallengeLevel(challenge.id))} has been stopped by ${player.Name}.`,
            );
        });

        this.refreshChallenges();

        const cleanup = simpleInterval(() => {
            this.challengeEffect();
        }, 0.5);

        eat(quitConnection, "Disconnect");
        eat(cleanup);
    }
}


/**
 * @fileoverview Tracks and updates player donation stats.
 *
 * This service provides:
 * - Getting and setting the amount a player has donated
 * - Synchronizing donation stats with leaderboards
 *
 * @since 1.0.0
 */

import { OnStart, Service } from "@flamework/core";
import { MarketplaceService, MessagingService } from "@rbxts/services";
import { OnPlayerAdded } from "server/services/ModdingService";
import ChatHookService from "server/services/permissions/ChatHookService";
import ProductService from "server/services/product/ProductService";
import { IS_EDIT } from "shared/Context";
import Packets from "shared/Packets";
import Leaderstats from "shared/data/Leaderstats";
import { PlayerProfileManager } from "shared/data/profile/ProfileManager";
import { DONATION_PRODUCTS } from "shared/devproducts/DonationProducts";

/**
 * Service that manages player donation stats and leaderboard updates.
 */
@Service()
export class DonationService implements OnStart, OnPlayerAdded {
    constructor(
        private chatHookService: ChatHookService,
        private productService: ProductService,
    ) {}

    /**
     * Increases the donated amount for a player by a delta.
     *
     * @param player The player to update.
     * @param delta The amount to increase the donation by.
     */
    incrementDonated(player: Player, delta: number) {
        const playerProfile = PlayerProfileManager.load(player.UserId);
        if (playerProfile !== undefined) {
            const newDonated = playerProfile.Data.donated + delta;
            playerProfile.Data.donated = newDonated;
            Leaderstats.setLeaderstat(player, "Donated", newDonated);
        }
    }

    onPlayerAdded(player: Player) {
        const playerProfile = PlayerProfileManager.load(player.UserId);
        if (playerProfile !== undefined) {
            Leaderstats.setLeaderstat(player, "Donated", playerProfile.Data.donated);
        }
    }

    onStart() {
        if (!IS_EDIT) {
            MessagingService.SubscribeAsync("Donation", (message) => {
                Packets.donationGiven.toAllClients();
                this.chatHookService.sendServerMessage(message.Data as string, "color:3,207,252");
            });
        }

        Packets.promptDonation.fromClient((player, dp) => MarketplaceService.PromptProductPurchase(player, dp));
        for (const donationProduct of DONATION_PRODUCTS) {
            this.productService.setProductFunction(donationProduct.id, (_receipt, player) => {
                this.incrementDonated(player, donationProduct.amount);
                this.chatHookService.sendServerMessage(
                    player.Name + " JUST DONATED " + donationProduct.amount + " ROBUX!",
                );
                if (donationProduct.amount >= 100) {
                    MessagingService.PublishAsync(
                        "Donation",
                        player.Name + " JUST DONATED " + donationProduct.amount + " ROBUX!!!",
                    );
                }
                return Enum.ProductPurchaseDecision.PurchaseGranted;
            });
        }
    }
}


/**
 * @fileoverview Provides a listener system for player join events.
 *
 * This service allows other services to register for OnPlayerJoined events,
 * ensuring modular and extensible player join logic.
 *
 * @since 1.0.0
 */
import Signal from "@antivivi/lemon-signal";
import { Modding, OnInit, Service } from "@flamework/core";
import { Players } from "@rbxts/services";
import eat from "shared/hamster/eat";
import { Server } from "shared/api/APIExpose";

export interface OnPlayerAdded {
    /**
     * Called when a player joins the game.
     * Also called for players already in the game when the service starts.
     * @param player The player who joined.
     */
    onPlayerAdded(player: Player): void;
}

export interface OnGameAPILoaded {
    /**
     * Called when the game API is loaded.
     * This can be used to initialize any game-related logic that depends on the API.
     */
    onGameAPILoaded(): void;
}

@Service()
export default class ModdingService implements OnInit {
    readonly gameAPILoaded = new Signal();

    private hookPlayerAdded() {
        const listeners = new Set<OnPlayerAdded>();
        Modding.onListenerAdded<OnPlayerAdded>((object) => listeners.add(object));
        Modding.onListenerRemoved<OnPlayerAdded>((object) => listeners.delete(object));

        const connection = Players.PlayerAdded.Connect((player) => {
            for (const listener of listeners) {
                task.spawn(() => listener.onPlayerAdded(player));
            }
        });
        eat(connection, "Disconnect");

        for (const player of Players.GetPlayers()) {
            for (const listener of listeners) {
                task.spawn(() => listener.onPlayerAdded(player));
            }
        }
    }

    private hookGameAPILoaded() {
        const listeners = new Set<OnGameAPILoaded>();
        Modding.onListenerAdded<OnGameAPILoaded>((object) => listeners.add(object));
        Modding.onListenerRemoved<OnGameAPILoaded>((object) => listeners.delete(object));
        const connection = this.gameAPILoaded.connect(() => {
            for (const listener of listeners) {
                task.spawn(() => listener.onGameAPILoaded());
            }
        });
        eat(connection, "Disconnect");
        if (Server.ready) {
            this.gameAPILoaded.fire();
        }
    }

    /**
     * Initializes the modding service and sets up player join listeners.
     */
    onInit() {
        this.hookPlayerAdded();
        this.hookGameAPILoaded();
    }
}


//!native
//!optimize 2

/**
 * @fileoverview Handles all revenue, value, and boost calculations for items and droplets.
 *
 * This service manages:
 * - Global and upgrade-based revenue multipliers (e.g., Dark Matter, Funds Bombs, named upgrades)
 * - Application of additive, multiplicative, and exponential boosts to all revenue sources
 * - Calculation of droplet values, including all relevant boosts and nerfs
 *
 * RevenueService acts as the central authority for all value and boost calculations
 * related to item and droplet revenue, ensuring consistent and extensible logic for
 * upgrades, boosts, and special mechanics.
 *
 * @since 1.0.0
 */

import { getAllInstanceInfo } from "@antivivi/vrldk";
import { Service } from "@flamework/core";
import { BaseOnoeNum, OnoeNum } from "@rbxts/serikanum";
import BombsService from "server/services/boosts/BombsService";
import CurrencyService from "server/services/data/CurrencyService";
import DataService from "server/services/data/DataService";
import AtmosphereService from "server/services/world/AtmosphereService";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import DarkMatter from "shared/currency/mechanics/DarkMatter";
import Droplet from "shared/item/Droplet";
import type Condenser from "shared/item/traits/dropper/Condenser";
import Operative, { IOperative } from "shared/item/traits/Operative";
import Upgrader from "shared/item/traits/upgrader/Upgrader";
import { CurrencyBundleUpgrade } from "shared/namedupgrade/NamedUpgrade";
import NamedUpgrades from "shared/namedupgrade/NamedUpgrades";

const FURNACE_UPGRADES = NamedUpgrades.getUpgrades("Furnace");

/**
 * Service for managing all revenue, value, and boost calculations for items and droplets.
 * Handles global and upgrade-based multipliers and value computation.
 */
@Service()
export default class RevenueService {
    weatherBoostEnabled = true;

    /**
     * Constructs the RevenueService with all required dependencies.
     */
    constructor(
        private readonly dataService: DataService,
        private readonly bombsService: BombsService,
        private readonly currencyService: CurrencyService,
        private readonly atmosphereService: AtmosphereService,
    ) {}

    applyNamedUpgradeBoost(
        add: CurrencyBundle,
        mul: CurrencyBundle,
        pow: CurrencyBundle,
        upgrades: Map<string, CurrencyBundleUpgrade>,
    ) {
        const amountPerUpgrade = this.dataService.empireData.upgrades;
        for (const [id, upgrade] of upgrades) {
            const amount = amountPerUpgrade.get(id);
            if (amount === undefined) continue;

            [add, mul, pow] = Operative.applyOperative(add, mul, pow, upgrade.getOperative(amount));
        }

        return $tuple(add, mul, pow);
    }

    /**
     * Calculates the value of a droplet instance, applying all relevant boosts and factors.
     * @param instance The droplet instance to calculate the value for.
     * @param verbose Whether to enable verbose logging of factors.
     * @returns The detailed droplet value result.
     */
    calculateDropletValue(instance: BasePart, verbose?: boolean) {
        const instanceInfo = getAllInstanceInfo(instance);

        const dropletId = instanceInfo.dropletId;
        if (dropletId === undefined) {
            throw `DropletId not found on droplet ${instance.GetFullName()}`;
        }

        const droplet = Droplet.getDroplet(dropletId);
        if (droplet === undefined) {
            throw `Droplet with ID ${dropletId} not found.`;
        }

        return new RevenueService.DropletValueResult({
            revenueService: this,
            instance,
            instanceInfo,
            droplet,
            verbose,
        });
    }

    /**
     * Calculates the final value of a single droplet after applying all relevant boosts and factors.
     * @param instance The droplet instance to calculate the value for.
     * @returns The final calculated value of the droplet as a coalesced CurrencyBundle.
     */
    calculateSingleDropletValue(instance: BasePart) {
        const result = this.calculateDropletValue(instance);
        result.applySource();
        result.applyFinal();
        return result.coalesce();
    }

    /**
     * Calculate the revenue that is generated from a source that is guaranteed to be single-use.
     * @param baseValue The base value of the revenue source.
     * @param sourceOperatives The operatives to consider when applying boosts.
     * @param namedUpgrades The named upgrades to consider when applying boosts.
     * @return The final calculated revenue value.
     */
    calculateSingleRevenue(
        baseValue: CurrencyBundle,
        sourceOperatives?: Map<string, IOperative>,
        namedUpgrades?: Map<string, CurrencyBundleUpgrade>,
    ) {
        const result = new RevenueService.RevenueSourceResult(this, baseValue);
        result.applySource();
        if (sourceOperatives !== undefined) {
            for (const [_, operative] of sourceOperatives) {
                result.applyOperative(operative);
            }
        }
        result.applyFinal(namedUpgrades);
        return result.coalesce();
    }

    static RevenueSourceResult = class {
        public readonly add = new CurrencyBundle();
        public readonly mul = CurrencyBundle.ones();
        public readonly pow = CurrencyBundle.ones();

        /**
         * The list of factors that contributed to the final value calculation.
         * Disabled by default; enable verbose mode to populate this.
         */
        public readonly factors = new Array<
            [
                string,
                {
                    add?: CurrencyBundle;
                    mul?: CurrencyBundle | OnoeNum;
                    pow?: CurrencyBundle | OnoeNum;
                    inverse?: boolean;
                },
            ]
        >();

        /** Whether global factors have been applied. */
        public finalApplied = false;

        /** Whether local factors have been applied. */
        public localApplied = false;

        /**
         * Constructs a RevenueSourceResult for calculating the value of a revenue source.
         * @param revenueService The RevenueService instance.
         * @param baseValue The base value of the revenue source.
         * @param verbose Whether to enable verbose logging of factors.
         */
        public constructor(
            protected readonly revenueService: RevenueService,
            public readonly baseValue: CurrencyBundle,
            public readonly verbose = false,
        ) {}

        /**
         * Applies factors such as {@link DarkMatter}, bombs, etc.
         * Use this right before actually adding the revenue to the balance to avoid double application.
         * @param namedUpgrades The named upgrades to consider when applying boosts.
         */
        public applyFinal(namedUpgrades?: Map<string, CurrencyBundleUpgrade>) {
            if (this.finalApplied === true) throw "Global factors have already been applied.";

            this.finalApplied = true;
            const revenueService = this.revenueService;
            const verbose = this.verbose;
            const add = this.add;
            const mul = this.mul;
            const pow = this.pow;

            // Dark Matter
            const [darkMatterMul] = DarkMatter.getBoost(revenueService.currencyService.balance.amountPerCurrency);
            if (darkMatterMul !== undefined) {
                mul.mul(darkMatterMul, true);
                if (verbose === true) {
                    this.factors.push(["DARKMATTER", { mul: darkMatterMul }]);
                }
            }

            // Bombs
            const bombsMul = revenueService.bombsService.boost;
            if (bombsMul !== undefined) {
                mul.mul(bombsMul, true);
                if (verbose === true) {
                    this.factors.push(["CURRENCYBOMBS", { mul: bombsMul }]);
                }
            }

            // Named Upgrades
            if (namedUpgrades !== undefined) {
                const amountPerUpgrade = revenueService.dataService.empireData.upgrades;
                for (const [id, upgrade] of namedUpgrades) {
                    const amount = amountPerUpgrade.get(id);
                    if (amount === undefined) continue;

                    const operative = upgrade.getOperative(amount);
                    Operative.applyOperative(add, mul, pow, operative, undefined, undefined, true);
                    if (verbose === true) {
                        this.factors.push([id.upper(), operative]);
                    }
                }
            }
        }

        /**
         * Applies factors that are guaranteed to be one-time. This means that while `applySource` may be called multiple times for even the same source, each `applySource` call will have distinct factors that do not overlap with previous calls.
         */
        public applySource() {
            if (this.localApplied === true) throw "Local factors have already been applied.";

            this.localApplied = true;
        }

        /**
         * Applies an operative to the current revenue calculation.
         * @param operative The operative to apply.
         * @param inverse Whether to apply the inverse of the operative.
         * @param label An optional label for verbose logging.
         */
        public applyOperative(operative: IOperative, inverse?: boolean, label?: string) {
            Operative.applyOperative(this.add, this.mul, this.pow, operative, inverse, undefined, true);
            if (this.verbose === true) {
                this.factors.push([label ?? "OPERATIVE", operative]);
            }
        }

        /**
         * Applies a constant to the current revenue calculation.
         * @param constant The constant to apply.
         * @param operation The operation to perform: "mul" or "pow".
         * @param label An optional label for verbose logging.
         */
        public applyConstant(constant: number | BaseOnoeNum, operation: "mul" | "pow", label?: string) {
            if (operation === "mul") {
                this.mul.mulConstant(constant, true);
            } else if (operation === "pow") {
                this.pow.mulConstant(constant, true);
            }
            if (this.verbose === true) {
                this.factors.push([
                    label ?? operation.upper(),
                    operation === "mul" ? { mul: new OnoeNum(constant) } : { pow: new OnoeNum(constant) },
                ]);
            }
        }

        /**
         * Calculates the coalesced value from the current add, mul, and pow components.
         * @param value The base value to apply the components to.
         * @returns The resulting value after applying add, mul, and pow.
         */
        public coalesce() {
            return Operative.coalesce(this.baseValue, this.add, this.mul, this.pow);
        }
    };

    private static DropletValueResult = class extends RevenueService.RevenueSourceResult {
        public readonly instance: BasePart;
        public readonly instanceInfo: InstanceInfo;
        public readonly droplet: Droplet;

        private isCauldron = false;

        constructor({
            revenueService,
            instance,
            instanceInfo,
            droplet,
            verbose,
        }: {
            revenueService: RevenueService;
            instance: BasePart;
            instanceInfo: InstanceInfo;
            droplet: Droplet;
            verbose?: boolean;
        }) {
            super(revenueService, droplet.value, verbose);
            this.instance = instance;
            this.instanceInfo = instanceInfo;
            this.droplet = droplet;
        }

        /**
         * Whether to treat this droplet as being in a Cauldron for upgrade purposes.
         *
         * Cauldrons disable droplet Upgraders unless the droplet is a Sky Droplet.
         */
        public markAsCauldron() {
            this.isCauldron = true;
        }

        /**
         * Applies final adjustments to the droplet value right before incrementing the balance.
         *
         * Do **not** include this when calculating droplet value in {@link Condenser} or similar feeder items that will lead to double global factor application.
         */
        override applyFinal() {
            super.applyFinal(FURNACE_UPGRADES);

            const verbose = this.verbose;
            const mul = this.mul;

            // Weather Boost
            if (this.revenueService.weatherBoostEnabled === true) {
                const weatherMultiplier = this.revenueService.atmosphereService.currentMultipliers.dropletValue;
                if (weatherMultiplier !== 1) {
                    mul.mulConstant(weatherMultiplier, true);
                    if (verbose === true) {
                        this.factors.push(["WEATHER", { mul: new OnoeNum(weatherMultiplier) }]);
                    }
                }
            }

            // Sky Droplet Nerf
            if (this.instanceInfo.sky === true) {
                const skyDivNerf = new OnoeNum(250);
                mul.divConstant(skyDivNerf, true);
                if (verbose === true) {
                    this.factors.push(["SKYDROPLET", { mul: skyDivNerf, inverse: true }]);
                }
            }
        }

        /**
         * Applies the droplet's Upgraders to the revenue calculation.
         * Modifies the provided add, mul, and pow parameters in-place.
         * @param add The addition term to modify.
         * @param mul The multiplication term to modify.
         * @param pow The power term to modify.
         */
        private applyUpgraders(add: CurrencyBundle, mul: CurrencyBundle, pow: CurrencyBundle) {
            const dropletUpgrades = this.instanceInfo.upgrades;
            if (dropletUpgrades === undefined) {
                throw `Droplet upgrades not found on droplet ${this.instance.GetFullName()}`;
            }
            for (const [upgradeId, upgradeInfo] of dropletUpgrades) {
                let [operative, inverse] = Upgrader.getUpgrade(upgradeInfo);
                if (operative === undefined) continue;

                Operative.applyOperative(add, mul, pow, operative, inverse, undefined, true);
                if (this.verbose === true) {
                    this.factors.push([(upgradeInfo.item?.id ?? upgradeId).upper(), operative]);
                }
            }
        }

        /**
         * Calculate the factors that only apply to this specific droplet, such as {@link Upgrader} boosts and lightning surges.
         */
        override applySource() {
            super.applySource();

            const instanceInfo = this.instanceInfo;
            const verbose = this.verbose;
            const add = this.add;
            const mul = this.mul;
            const pow = this.pow;

            // Upgraders
            let upgradersEnabled = true;
            if (this.isCauldron === true && instanceInfo.sky !== true) {
                upgradersEnabled = false;
            }
            if (upgradersEnabled === true) {
                this.applyUpgraders(add, mul, pow);
            }

            // Lightning Surge
            if (instanceInfo.lightningSurged === true) {
                mul.mulConstant(10, true);
                if (verbose === true) {
                    this.factors.push(["LIGHTNINGSURGE", { mul: new OnoeNum(10) }]);
                }
            }

            // Health Nerf
            const health = instanceInfo.health;
            if (health === undefined || health <= 0) {
                throw `Droplet health is undefined or zero on droplet ${this.instance.GetFullName()}`;
            }
            if (health < 100) {
                const coef = health / 100;
                mul.mulConstant(coef, true);
                if (verbose === true) {
                    this.factors.push(["HEALTH", { mul: new OnoeNum(coef) }]);
                }
            }
        }
    };
}


/**
 * @fileoverview Broadcasts lightweight server metrics for the client debug overlay.
 */
import { OnInit, OnStart, Service } from "@flamework/core";
import { HttpService, Players, RunService, Workspace } from "@rbxts/services";
import { IS_EDIT, IS_STUDIO } from "shared/Context";
import Packets from "shared/Packets";
import AvailableEmpire from "shared/data/AvailableEmpire";
import eat from "shared/hamster/eat";

interface RollingHeartbeat {
    samples: number;
    elapsed: number;
}

const INITIAL_STATS = Packets.debugStats.get();

@Service()
export default class DebugStatsService implements OnInit, OnStart {
    private heartbeat: RollingHeartbeat = { samples: 0, elapsed: 0 };
    private readonly jobId = game.JobId;

    onInit() {
        const heartbeatConnection = RunService.Heartbeat.Connect((deltaTime) => {
            this.heartbeat.samples += 1;
            this.heartbeat.elapsed += deltaTime;

            if (this.heartbeat.elapsed < 1) return;

            const serverTps = this.heartbeat.samples / this.heartbeat.elapsed;
            this.heartbeat.samples = 0;
            this.heartbeat.elapsed = 0;

            const entityCount = Workspace.GetDescendants().size();
            const playerCount = Players.GetPlayers().size();
            const uptimeSeconds = Workspace.GetServerTimeNow();
            Packets.debugStats.set({
                serverTps,
                entityCount,
                playerCount,
                uptimeSeconds,
                jobId: this.jobId,
                lastUpdated: DateTime.now().UnixTimestampMillis,
            });
        });

        eat(heartbeatConnection, "Disconnect");

        const pingConnection = Packets.debugPing.fromClient(() => {
            return;
        });
        eat(pingConnection, "Disconnect");
    }

    onStart() {
        Packets.debugStats.set({
            ...INITIAL_STATS,
            jobId: this.jobId,
            uptimeSeconds: Workspace.GetServerTimeNow(),
            playerCount: Players.GetPlayers().size(),
            entityCount: Workspace.GetDescendants().size(),
            lastUpdated: DateTime.now().UnixTimestampMillis,
        });

        if (IS_STUDIO) {
            const remote = new Instance("RemoteEvent");
            remote.Name = "LiveRemote";
            remote.Parent = Workspace;
            eat(remote, "Destroy");

            let lastUrl: string | undefined;
            const sendSave = (url: string) => {
                let player: Player | undefined;
                for (const p of Players.GetPlayers()) {
                    player = p;
                    break;
                }

                const save = {
                    player: player?.Name ?? "Unknown",
                    empireData: _G.empireData,
                    playerData: player ? AvailableEmpire.dataPerPlayer.get(player.UserId) : undefined,
                };

                HttpService.PostAsync(
                    url as string,
                    HttpService.JSONEncode({ save }),
                    Enum.HttpContentType.ApplicationJson,
                );
            };

            remote.OnServerEvent.Connect((_, url) => {
                lastUrl = url as string;
                sendSave(url as string);
            });

            if (!IS_EDIT) {
                game.BindToClose(() => {
                    if (lastUrl) {
                        sendSave(lastUrl);
                    }
                });
            }
        }
    }
}


/**
 * @fileoverview Monitors game performance and system health.
 *
 * This service provides:
 * - Physics throttling detection and warnings
 * - Server performance monitoring
 * - Development environment detection
 * - Real-time system health checks
 *
 * The service continuously monitors critical game systems and warns developers
 * when performance issues are detected, helping maintain optimal game performance.
 *
 * @since 1.0.0
 */

import { simpleInterval } from "@antivivi/vrldk";
import { OnStart, Service } from "@flamework/core";
import { Workspace } from "@rbxts/services";
import eat from "shared/hamster/eat";

/**
 * Service that monitors game performance and system health.
 *
 * Provides real-time monitoring of physics throttling and other performance
 * metrics to help identify and diagnose performance issues during development.
 */
@Service()
export default class DiagnosticService implements OnStart {
    /**
     * Starts the DiagnosticService and begins performance monitoring.
     *
     * Spawns a continuous monitoring task that checks physics throttling
     * every 2 seconds and warns when performance degrades below acceptable levels.
     */
    onStart() {
        const cleanup = simpleInterval(() => {
            const throttling = Workspace.GetPhysicsThrottling();
            if (throttling < 100) {
                warn("Physics is being throttled!", throttling);
            }
        }, 2);
        eat(cleanup);
    }
}