import * as path from "node:path"; import { _, Advancement, comment, execute, MCFunction, type OBJECTIVE_CRITERION, Objective, type ObjectiveInstance, type Score, Selector, savePack, scoreboard, tag, } from "sandstone"; import type { ConditionType } from "sandstone/flow"; import { Actions, mapTarget } from "./actions"; import { Commands } from "./commands"; import { ALL, BUILTIN_SCORE_EVENTS, BUILTIN_VARIABLES, GAME_STATES, HIDDEN_OBJECTIVE, MAX_DURATION, VISIBLE_OBJECTIVE, WINNER_TAG, } from "./constants"; import { set_up_test_mode, test_mode_enabled } from "./testmode"; import { type _BaseConfig, type _InputAdvancementCustomEventType, type _InputCustomEventType, type _InputScoreCustomEventType, type _InputVariableType, type _ScenarioEvents, type CustomScoreEvent, type FullVariable, type FullVariables, isSpecificObjectiveVariable, type Roles, type Variables, } from "./types"; import { getChildFunctionName } from "./utils"; export const DEFAULT_CHALLENGE_PATH = "kradle-studio/challenges"; const PREVIOUS_VALUE_PREFIX = "_prev_"; const PREVIOUS_VALUES_GLOBAL_OBJECTIVE_NAME = "prev_glob_values"; const EVENT_FIRES_COUNTER_PREFIX = "_count_"; const EVENT_FIRES_GLOBAL_COUNTER_OBJECTIVE_NAME = "fired_ev_counter"; let uniqueObjectiveCounter = 0; /** * Adds a suffix to the objective name while ensuring it fits within the 16 character limit. */ function addSuffixToObjectiveName(name: string, suffix: string): string { const trimmedName = name.slice(0, 16 - suffix.length); return `${trimmedName}${suffix}`; } /** * Makes sure the objective name is unique by appending a counter to it. * This is necessary because Minecraft requires objective names to be unique, * as well as to fit within the 16 character limit. * * This is meant to be used for mass-generated objectives (previous values, counter of fired events...) */ function getUniqueObjectiveName(name: string): string { uniqueObjectiveCounter++; return addSuffixToObjectiveName(name, uniqueObjectiveCounter.toString()); } /** * Returns an Objective instance for the given name and type. * If the objective already exists, it returns the existing one. * This is useful to avoid creating multiple objectives with the same name. */ function getOrCreateObjective(name: string, type: OBJECTIVE_CRITERION): ObjectiveInstance { try { return Objective.create(name, type); } catch (_) { return Objective.get(name); } } function isScoreCustomEvent(event: _InputCustomEventType): event is _InputScoreCustomEventType { return "score" in event; } function isAdvancementCustomEvent(event: _InputCustomEventType): event is _InputAdvancementCustomEventType { return "criteria" in event; } export class ChallengeBase { private game_duration: number; private roles: Roles; private full_variables: FullVariables; private variablesPreviousValues: Record | undefined; private _events: _ScenarioEvents = {}; /* The custom score events provided by the user, still in input type */ private _userInputCustomEvents: _InputScoreCustomEventType[] = []; private _end_condition: ConditionType | undefined; private _win_conditions: Record | undefined; private name: string; private kradle_challenge_path: string; constructor(config: _BaseConfig) { this.name = config.name; this.kradle_challenge_path = config.kradle_challenge_path; this.game_duration = config.GAME_DURATION ?? MAX_DURATION; if (this.game_duration < 0) { throw new Error("Game duration cannot be negative. Please set it to a positive value (lower than 5 minutes)."); } if (this.game_duration > MAX_DURATION) { throw new Error(`Game duration cannot exceed ${MAX_DURATION / 20} seconds.`); } this.roles = Object.fromEntries(config.roles.map((role) => [role, role])) as any; const all_variables = { ...BUILTIN_VARIABLES, ...config.custom_variables, } as Record>; this.full_variables = { ...Object.fromEntries( Object.entries(all_variables).map(([key, value]) => { if (isSpecificObjectiveVariable(value)) { return [ key, { type: value.type, score: Objective.create(key, value.objective_type)("@s"), default: (value as any).default, updater: value.updater, }, ]; } if (value.type === "global") { const objective = value.hidden ? HIDDEN_OBJECTIVE : VISIBLE_OBJECTIVE; return [ key, { type: value.type, score: objective(key), default: value.default, updater: value.updater, }, ]; } return [ key, { type: value.type, score: Objective.create(key, "dummy")("@s"), default: value.default, updater: value.updater, }, ]; }), ), } as FullVariables; } private get variables(): Variables { return Object.fromEntries( Object.entries(this.full_variables).map(([key, value]) => { return [key, value.score]; }), ) as Variables; } /** * Build a CustomScoreEvent from an _InputScoreCustomEventType. * This will setup the tracking objective. * * @param event The input event to create the custom event from. * @param suffix A suffix that will be added to the variable's debug name, ensuring its uniqueness. */ private buildCustomScoreEvent(event: _InputScoreCustomEventType, suffix: string | number): CustomScoreEvent { // How we store fire counters will depend whether the score is global or individual const variableName = this.getVariableName(event.score); const variableType = this.getVariableType(event.score); const debugName = `${variableName}__${suffix}`; if (variableType === "global") { // For global scores, we create a single global objective. const objective = getOrCreateObjective(EVENT_FIRES_GLOBAL_COUNTER_OBJECTIVE_NAME, "dummy"); return { inputEvent: event, counter: objective(debugName), type: variableType, debugName: debugName, }; } // For individual scores, we have to create a dedicated mirror objective const objective = getOrCreateObjective(getUniqueObjectiveName(EVENT_FIRES_COUNTER_PREFIX + variableName), "dummy"); return { inputEvent: event, counter: objective(event.score.target), type: variableType, debugName: debugName, }; } private addVariable(name: string, variable: FullVariable) { // @ts-expect-error if (this.full_variables[name]) { throw new Error(`Variable with name ${name} already exists.`); } // @ts-expect-error this.full_variables[name] = variable; } private getPreviousValueScore(name: string): Score { if (!this.variablesPreviousValues) { throw new Error("Variables previous values not initialized."); } return this.variablesPreviousValues[name]; } events( events: (variables: Variables, roles: Roles) => _ScenarioEvents, ): Pick { this._events = events(this.variables, this.roles); return this; } /** * Returns the name of the variable based on its score. */ private getVariableName(variable: Score): string { const variableName = Object.entries(this.full_variables).find(([_name, value]) => value.score === variable); if (!variableName) { throw new Error(`Variable with score ${variable} not found in variables.`); } return variableName[0]; } /** * Returns the type of the variable (individual/global) based on its score. */ private getVariableType(score: Score): "individual" | "global" { const variableName = this.getVariableName(score); if (!variableName) { throw new Error(`Score ${score} not found in variables.`); } return this.full_variables[variableName as VARIABLE_NAMES].type; } custom_events( custom_events: (variables: Variables, roles: Roles) => _InputCustomEventType[], ): Pick { const inputCustomEvents = custom_events(this.variables, this.roles); // First, we translate advancement-based events directly into dedicated scores, and create the related Advancement const advancementsAsScoreCustomEvents: _InputScoreCustomEventType[] = inputCustomEvents .filter(isAdvancementCustomEvent) .map((event, index) => { const trigger = (event.criteria?.[0].trigger ?? "").split("minecraft:").at(-1)?.replace(/[:/]/g, "_"); const name = `adv_${trigger}__${index}`; const score = getOrCreateObjective(getUniqueObjectiveName(name), "dummy")("@s"); this.addVariable(name, { score: score, default: 0, type: "individual", updater: undefined, }); const setMCFunction = MCFunction(`custom_events_advancements/${name}`, () => { // Called when the advancement is granted. This sets the score to 1 and revokes the advancement. score.set(1); advancement.revoke(Selector("@s")); }); const advancement = Advancement(name, { criteria: Object.fromEntries( event.criteria.map((criterion, criterionIndex) => [`criterion_${criterionIndex}`, criterion]), ), rewards: { function: setMCFunction, }, }); return { actions: () => { // First action is to reset the score to 0 Actions.set({ variable: score, value: 0 }); event.actions(); }, mode: event.mode, score: score, }; }); this._userInputCustomEvents = [...inputCustomEvents.filter(isScoreCustomEvent), ...advancementsAsScoreCustomEvents]; return this; } end_condition( condition?: (variables: Variables, roles: Roles) => ConditionType, ): Pick { this._end_condition = condition?.(this.variables, this.roles); return this; } win_conditions( condition: (variables: Variables, roles: Roles) => Record, ) { this._win_conditions = condition(this.variables, this.roles); this.build(); } /** * Processes a custom score event by checking if the score has reached the target, * and if so, executes the actions associated with the event. * * @param event - The custom event to process. * @param mcFunctionReference - Optional reference to the parent MCFunction - used to give better naming to the children functions. */ private processCustomScoreEvent(event: CustomScoreEvent): void { const { score, target, actions, mode } = event.inputEvent; const { type, counter, debugName } = event; const previousScore = this.getPreviousValueScore(this.getVariableName(score)); // If no target is specified, we assume the event fires when the score changes from its previous value const scoreCondition = target ? _.and(previousScore.notEqualTo(target), score.equalTo(target)) : score.notEqualTo(previousScore); // If the mode is "fire_once", we also check if the counter is 0. const condition = mode === "repeatable" ? scoreCondition : _.and(scoreCondition, counter.equalTo(0)); if (type === "global") { // For global scores, we can directly use the condition comment(`Processing global custom score event ${debugName}`); _.if(condition, () => { actions(); counter.add(1); }); } else { // For individual scores, we need to check each player's score comment(`Processing individual custom score event ${debugName}`); execute.as(ALL).run(getChildFunctionName(debugName), () => { _.if(condition, () => { actions(); counter.add(1); }); }); } } /** * Builds the datapack based on the challenge configuration. */ private build() { // Initialize previous values objectives // Will store the previous values of all variables this.variablesPreviousValues = Object.fromEntries( Object.entries(this.full_variables).map(([name, { type, score }]) => { if (type === "global") { // For global variables, we create a single global objective to store previous values const objective = getOrCreateObjective(PREVIOUS_VALUES_GLOBAL_OBJECTIVE_NAME, "dummy"); return [name, objective(`${name}__${score.target}`)]; } // For individual variables, we create a dedicated mirror objective const objective = Objective.create(getUniqueObjectiveName(PREVIOUS_VALUE_PREFIX + name), "dummy"); return [name, objective(score.target)]; }), ); // "Build" the custom events (initializes the tracking objective) const customScoreEvents = [...this._userInputCustomEvents, ...BUILTIN_SCORE_EVENTS(this.variables)].map( (event, index) => this.buildCustomScoreEvent(event, index), ); // Initialize the challenge MCFunction("start_challenge", () => { comment("1. Setup all the global variables to their default values."); comment(" This includes the game timer!"); this.variables.game_timer.set(this.game_duration); for (const variable of Object.values(this.full_variables)) { if (variable.type === "global" && variable.default !== undefined) { variable.score.set(variable.default); } } tag(ALL).remove(WINNER_TAG); comment("2. Set up test mode if it's enabled - needs to run here so the tester player is tagged"); if (test_mode_enabled) { set_up_test_mode(); } comment("3. Process the start_challenge events"); this._events.start_challenge?.(); comment("4. Schedule the init_participants function to run after 1s"); init_participants.schedule("1s"); comment("5. Display the game objective"); scoreboard.objectives.setDisplay("sidebar", VISIBLE_OBJECTIVE.name); }); const init_participants = MCFunction("init_participants", () => { comment("1. Setup all individual variables to their default values."); for (const [name, variable] of Object.entries(this.full_variables)) { const previousValue = this.getPreviousValueScore(name); if (typeof previousValue === "undefined") { throw new Error(`Previous value for variable ${name} not found.`); } if (variable.type === "individual" && variable.default !== undefined) { execute.as(ALL).run(getChildFunctionName(name), () => { variable.score.set(variable.default as number); previousValue.set(variable.default as number); }); } if (variable.type === "global" && variable.default !== undefined) { variable.score.set(variable.default); previousValue.set(variable.default); } } comment("2. Set the events fire counters to 0"); for (const event of customScoreEvents) { // We could make the distinction between global and individual counters, // but it just works to make all players set event fires to 0 execute.as(ALL).run(() => { event.counter.set(0); }); } comment("3. Process the init_participants events"); this._events.init_participants?.(); comment("4. Set the game state to ON"); this.full_variables.game_state.score.set(GAME_STATES.ON); //tellraw("@a", [`${DISPLAY_TAG}The challenge has started!`]); }); MCFunction( "on_tick", () => { _.if(this.variables.game_state.equalTo(GAME_STATES.ON), () => { comment("1. Run all updaters"); MCFunction("custom_variable_updaters", () => { for (const [name, variable] of Object.entries(this.full_variables)) { if (variable.updater) { comment(`Updating variable ${name}`); if (variable.type === "individual") { execute .as(ALL) .at("@s") .run(getChildFunctionName(`update_${name}`), () => { variable.updater?.(variable.score, this.variables); }); } else { variable.updater(variable.score, this.variables); } } } })(); comment("2. Process the on_tick event"); this._events.on_tick?.(); comment("3. Process custom events"); MCFunction("process_custom_events", () => { // We process all custom events - this checks if the condition is reached & fire them if necessary customScoreEvents.forEach((event) => { this.processCustomScoreEvent(event); }); })(); comment("4. Set previous values for all variables"); execute.as(ALL).run(getChildFunctionName("set_previous_values"), () => { for (const [name, variable] of Object.entries(this.full_variables)) { // We could also make the distinction between global and individual variables, // but again it just works to run it on all players const previousValue = this.getPreviousValueScore(name); previousValue.set(variable.score); } }); comment("5. Check end conditions"); const timerCondition = this.variables.game_timer.equalTo(this.game_duration); const endCondition = this._end_condition ? _.or(this._end_condition, timerCondition) : timerCondition; _.if(endCondition, () => { //tellraw("@a", [`${DISPLAY_TAG}End condition met! Ending challenge...`]); end_challenge(); }); }); }, { runEveryTick: true, }, ); const end_challenge = MCFunction("end_challenge", () => { comment("1. Set the game state to OFF"); this.full_variables.game_state.score.set(GAME_STATES.OFF); comment("2. Process the end_challenge events"); this._events.end_challenge?.(); comment("3. Announce the winners"); for (const [role, condition] of Object.entries(this._win_conditions || {})) { execute.as(mapTarget(role)).run(getChildFunctionName(`check_winner_team_${role}`), () => { _.if(condition as ConditionType, () => { Actions.announce({ message: [Selector("@s"), " has won the challenge!"], }); tag("@s").add(WINNER_TAG); }); }); } Commands.gameOver(); }); savePack("datapack", { customPath: path.join(this.kradle_challenge_path, this.name), }); } } /** * Creates a new challenge with the provided configuration. */ export function createChallenge( config: _BaseConfig, ): Pick, "events"> { return new ChallengeBase(config); }