@kradle/challenges 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -0
  2. package/biome.json +39 -0
  3. package/dist/actions.d.ts +203 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/actions.js +287 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/api_utils.d.ts +5 -0
  8. package/dist/api_utils.d.ts.map +1 -0
  9. package/dist/api_utils.js +13 -0
  10. package/dist/api_utils.js.map +1 -0
  11. package/dist/challenge.d.ts +56 -0
  12. package/dist/challenge.d.ts.map +1 -0
  13. package/dist/challenge.js +462 -0
  14. package/dist/challenge.js.map +1 -0
  15. package/dist/commands.d.ts +38 -0
  16. package/dist/commands.d.ts.map +1 -0
  17. package/dist/commands.js +135 -0
  18. package/dist/commands.js.map +1 -0
  19. package/dist/constants.d.ts +70 -0
  20. package/dist/constants.d.ts.map +1 -0
  21. package/dist/constants.js +112 -0
  22. package/dist/constants.js.map +1 -0
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/testmode.d.ts +3 -0
  28. package/dist/testmode.d.ts.map +1 -0
  29. package/dist/testmode.js +19 -0
  30. package/dist/testmode.js.map +1 -0
  31. package/dist/types.d.ts +103 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +10 -0
  34. package/dist/types.js.map +1 -0
  35. package/dist/utils.d.ts +17 -0
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +22 -0
  38. package/dist/utils.js.map +1 -0
  39. package/package.json +27 -0
  40. package/src/actions.ts +388 -0
  41. package/src/api_utils.ts +10 -0
  42. package/src/challenge.ts +553 -0
  43. package/src/commands.ts +141 -0
  44. package/src/constants.ts +121 -0
  45. package/src/index.ts +4 -0
  46. package/src/testmode.ts +18 -0
  47. package/src/types.ts +136 -0
  48. package/src/utils.ts +22 -0
  49. package/tsconfig.json +38 -0
@@ -0,0 +1,553 @@
1
+ import * as path from "node:path";
2
+ import {
3
+ _,
4
+ Advancement,
5
+ comment,
6
+ execute,
7
+ MCFunction,
8
+ type OBJECTIVE_CRITERION,
9
+ Objective,
10
+ type ObjectiveInstance,
11
+ type Score,
12
+ Selector,
13
+ savePack,
14
+ scoreboard,
15
+ tag,
16
+ } from "sandstone";
17
+ import type { ConditionType } from "sandstone/flow";
18
+ import { Actions, mapTarget } from "./actions";
19
+ import { Commands } from "./commands";
20
+ import {
21
+ ALL,
22
+ BUILTIN_SCORE_EVENTS,
23
+ BUILTIN_VARIABLES,
24
+ GAME_STATES,
25
+ HIDDEN_OBJECTIVE,
26
+ MAX_DURATION,
27
+ VISIBLE_OBJECTIVE,
28
+ WINNER_TAG,
29
+ } from "./constants";
30
+ import { set_up_test_mode, test_mode_enabled } from "./testmode";
31
+ import {
32
+ type _BaseConfig,
33
+ type _InputAdvancementCustomEventType,
34
+ type _InputCustomEventType,
35
+ type _InputScoreCustomEventType,
36
+ type _InputVariableType,
37
+ type _ScenarioEvents,
38
+ type CustomScoreEvent,
39
+ type FullVariable,
40
+ type FullVariables,
41
+ isSpecificObjectiveVariable,
42
+ type Roles,
43
+ type Variables,
44
+ } from "./types";
45
+ import { getChildFunctionName } from "./utils";
46
+
47
+ export const DEFAULT_CHALLENGE_PATH = "kradle-studio/challenges";
48
+
49
+ const PREVIOUS_VALUE_PREFIX = "_prev_";
50
+ const PREVIOUS_VALUES_GLOBAL_OBJECTIVE_NAME = "prev_glob_values";
51
+
52
+ const EVENT_FIRES_COUNTER_PREFIX = "_count_";
53
+ const EVENT_FIRES_GLOBAL_COUNTER_OBJECTIVE_NAME = "fired_ev_counter";
54
+
55
+ let uniqueObjectiveCounter = 0;
56
+
57
+ /**
58
+ * Adds a suffix to the objective name while ensuring it fits within the 16 character limit.
59
+ */
60
+ function addSuffixToObjectiveName(name: string, suffix: string): string {
61
+ const trimmedName = name.slice(0, 16 - suffix.length);
62
+ return `${trimmedName}${suffix}`;
63
+ }
64
+
65
+ /**
66
+ * Makes sure the objective name is unique by appending a counter to it.
67
+ * This is necessary because Minecraft requires objective names to be unique,
68
+ * as well as to fit within the 16 character limit.
69
+ *
70
+ * This is meant to be used for mass-generated objectives (previous values, counter of fired events...)
71
+ */
72
+ function getUniqueObjectiveName(name: string): string {
73
+ uniqueObjectiveCounter++;
74
+
75
+ return addSuffixToObjectiveName(name, uniqueObjectiveCounter.toString());
76
+ }
77
+
78
+ /**
79
+ * Returns an Objective instance for the given name and type.
80
+ * If the objective already exists, it returns the existing one.
81
+ * This is useful to avoid creating multiple objectives with the same name.
82
+ */
83
+ function getOrCreateObjective(name: string, type: OBJECTIVE_CRITERION): ObjectiveInstance {
84
+ try {
85
+ return Objective.create(name, type);
86
+ } catch (_) {
87
+ return Objective.get(name);
88
+ }
89
+ }
90
+
91
+ function isScoreCustomEvent(event: _InputCustomEventType): event is _InputScoreCustomEventType {
92
+ return "score" in event;
93
+ }
94
+
95
+ function isAdvancementCustomEvent(event: _InputCustomEventType): event is _InputAdvancementCustomEventType {
96
+ return "criteria" in event;
97
+ }
98
+
99
+ export class ChallengeBase<const ROLES_NAMES extends string, const VARIABLE_NAMES extends string> {
100
+ private game_duration: number;
101
+ private roles: Roles<ROLES_NAMES>;
102
+ private full_variables: FullVariables<VARIABLE_NAMES>;
103
+ private variablesPreviousValues: Record<string, Score> | undefined;
104
+ private _events: _ScenarioEvents = {};
105
+
106
+ /* The custom score events provided by the user, still in input type */
107
+ private _userInputCustomEvents: _InputScoreCustomEventType[] = [];
108
+ private _end_condition: ConditionType | undefined;
109
+ private _win_conditions: Record<ROLES_NAMES, ConditionType> | undefined;
110
+
111
+ private name: string;
112
+ private kradle_challenge_path: string;
113
+
114
+ constructor(config: _BaseConfig<ROLES_NAMES, VARIABLE_NAMES>) {
115
+ this.name = config.name;
116
+ this.kradle_challenge_path = config.kradle_challenge_path;
117
+ this.game_duration = config.GAME_DURATION ?? MAX_DURATION;
118
+
119
+ if (this.game_duration < 0) {
120
+ throw new Error("Game duration cannot be negative. Please set it to a positive value (lower than 5 minutes).");
121
+ }
122
+ if (this.game_duration > MAX_DURATION) {
123
+ throw new Error(`Game duration cannot exceed ${MAX_DURATION / 20} seconds.`);
124
+ }
125
+
126
+ this.roles = Object.fromEntries(config.roles.map((role) => [role, role])) as any;
127
+
128
+ const all_variables = {
129
+ ...BUILTIN_VARIABLES,
130
+ ...config.custom_variables,
131
+ } as Record<string, _InputVariableType<string>>;
132
+
133
+ this.full_variables = {
134
+ ...Object.fromEntries(
135
+ Object.entries(all_variables).map(([key, value]) => {
136
+ if (isSpecificObjectiveVariable(value)) {
137
+ return [
138
+ key,
139
+ {
140
+ type: value.type,
141
+ score: Objective.create(key, value.objective_type)("@s"),
142
+ default: (value as any).default,
143
+ updater: value.updater,
144
+ },
145
+ ];
146
+ }
147
+ if (value.type === "global") {
148
+ const objective = value.hidden ? HIDDEN_OBJECTIVE : VISIBLE_OBJECTIVE;
149
+ return [
150
+ key,
151
+ {
152
+ type: value.type,
153
+ score: objective(key),
154
+ default: value.default,
155
+ updater: value.updater,
156
+ },
157
+ ];
158
+ }
159
+ return [
160
+ key,
161
+ {
162
+ type: value.type,
163
+ score: Objective.create(key, "dummy")("@s"),
164
+ default: value.default,
165
+ updater: value.updater,
166
+ },
167
+ ];
168
+ }),
169
+ ),
170
+ } as FullVariables<VARIABLE_NAMES>;
171
+ }
172
+
173
+ private get variables(): Variables<VARIABLE_NAMES> {
174
+ return Object.fromEntries(
175
+ Object.entries(this.full_variables).map(([key, value]) => {
176
+ return [key, value.score];
177
+ }),
178
+ ) as Variables<VARIABLE_NAMES>;
179
+ }
180
+
181
+ /**
182
+ * Build a CustomScoreEvent from an _InputScoreCustomEventType.
183
+ * This will setup the tracking objective.
184
+ *
185
+ * @param event The input event to create the custom event from.
186
+ * @param suffix A suffix that will be added to the variable's debug name, ensuring its uniqueness.
187
+ */
188
+ private buildCustomScoreEvent(event: _InputScoreCustomEventType, suffix: string | number): CustomScoreEvent {
189
+ // How we store fire counters will depend whether the score is global or individual
190
+ const variableName = this.getVariableName(event.score);
191
+ const variableType = this.getVariableType(event.score);
192
+ const debugName = `${variableName}__${suffix}`;
193
+
194
+ if (variableType === "global") {
195
+ // For global scores, we create a single global objective.
196
+ const objective = getOrCreateObjective(EVENT_FIRES_GLOBAL_COUNTER_OBJECTIVE_NAME, "dummy");
197
+ return {
198
+ inputEvent: event,
199
+ counter: objective(debugName),
200
+ type: variableType,
201
+ debugName: debugName,
202
+ };
203
+ }
204
+
205
+ // For individual scores, we have to create a dedicated mirror objective
206
+ const objective = getOrCreateObjective(getUniqueObjectiveName(EVENT_FIRES_COUNTER_PREFIX + variableName), "dummy");
207
+ return {
208
+ inputEvent: event,
209
+ counter: objective(event.score.target),
210
+ type: variableType,
211
+ debugName: debugName,
212
+ };
213
+ }
214
+
215
+ private addVariable(name: string, variable: FullVariable) {
216
+ // @ts-expect-error
217
+ if (this.full_variables[name]) {
218
+ throw new Error(`Variable with name ${name} already exists.`);
219
+ }
220
+ // @ts-expect-error
221
+ this.full_variables[name] = variable;
222
+ }
223
+
224
+ private getPreviousValueScore(name: string): Score {
225
+ if (!this.variablesPreviousValues) {
226
+ throw new Error("Variables previous values not initialized.");
227
+ }
228
+
229
+ return this.variablesPreviousValues[name];
230
+ }
231
+
232
+ events(
233
+ events: (variables: Variables<VARIABLE_NAMES>, roles: Roles<ROLES_NAMES>) => _ScenarioEvents,
234
+ ): Pick<this, "custom_events" | "end_condition" | "win_conditions"> {
235
+ this._events = events(this.variables, this.roles);
236
+ return this;
237
+ }
238
+
239
+ /**
240
+ * Returns the name of the variable based on its score.
241
+ */
242
+ private getVariableName(variable: Score): string {
243
+ const variableName = Object.entries(this.full_variables).find(([_name, value]) => value.score === variable);
244
+ if (!variableName) {
245
+ throw new Error(`Variable with score ${variable} not found in variables.`);
246
+ }
247
+ return variableName[0];
248
+ }
249
+
250
+ /**
251
+ * Returns the type of the variable (individual/global) based on its score.
252
+ */
253
+ private getVariableType(score: Score): "individual" | "global" {
254
+ const variableName = this.getVariableName(score);
255
+ if (!variableName) {
256
+ throw new Error(`Score ${score} not found in variables.`);
257
+ }
258
+ return this.full_variables[variableName as VARIABLE_NAMES].type;
259
+ }
260
+
261
+ custom_events(
262
+ custom_events: (variables: Variables<VARIABLE_NAMES>, roles: Roles<ROLES_NAMES>) => _InputCustomEventType[],
263
+ ): Pick<this, "end_condition" | "win_conditions"> {
264
+ const inputCustomEvents = custom_events(this.variables, this.roles);
265
+
266
+ // First, we translate advancement-based events directly into dedicated scores, and create the related Advancement
267
+ const advancementsAsScoreCustomEvents: _InputScoreCustomEventType[] = inputCustomEvents
268
+ .filter(isAdvancementCustomEvent)
269
+ .map((event, index) => {
270
+ const trigger = (event.criteria?.[0].trigger ?? "").split("minecraft:").at(-1)!.replace(/[:/]/g, "_");
271
+ const name = `adv_${trigger}__${index}`;
272
+ const score = getOrCreateObjective(getUniqueObjectiveName(name), "dummy")("@s");
273
+
274
+ this.addVariable(name, {
275
+ score: score,
276
+ default: 0,
277
+ type: "individual",
278
+ updater: undefined,
279
+ });
280
+
281
+ const setMCFunction = MCFunction(`custom_events_advancements/${name}`, () => {
282
+ // Called when the advancement is granted. This sets the score to 1 and revokes the advancement.
283
+ score.set(1);
284
+ advancement.revoke(Selector("@s"));
285
+ });
286
+
287
+ const advancement = Advancement(name, {
288
+ criteria: Object.fromEntries(
289
+ event.criteria.map((criterion, criterionIndex) => [`criterion_${criterionIndex}`, criterion]),
290
+ ),
291
+ rewards: {
292
+ function: setMCFunction,
293
+ },
294
+ });
295
+
296
+ return {
297
+ actions: [
298
+ // First action is to reset the score to 0
299
+ Actions.set({ variable: score, value: 0 }),
300
+ ...event.actions,
301
+ ],
302
+ mode: event.mode,
303
+ score: score,
304
+ };
305
+ });
306
+
307
+ this._userInputCustomEvents = [...inputCustomEvents.filter(isScoreCustomEvent), ...advancementsAsScoreCustomEvents];
308
+ return this;
309
+ }
310
+
311
+ end_condition(
312
+ condition?: (variables: Variables<VARIABLE_NAMES>, roles: Roles<ROLES_NAMES>) => ConditionType,
313
+ ): Pick<this, "win_conditions"> {
314
+ this._end_condition = condition?.(this.variables, this.roles);
315
+
316
+ return this;
317
+ }
318
+
319
+ win_conditions(
320
+ condition: (variables: Variables<VARIABLE_NAMES>, roles: Roles<ROLES_NAMES>) => Record<ROLES_NAMES, ConditionType>,
321
+ ) {
322
+ this._win_conditions = condition(this.variables, this.roles);
323
+ this.build();
324
+ }
325
+
326
+ /**
327
+ * Processes a custom score event by checking if the score has reached the target,
328
+ * and if so, executes the actions associated with the event.
329
+ *
330
+ * @param event - The custom event to process.
331
+ * @param mcFunctionReference - Optional reference to the parent MCFunction - used to give better naming to the children functions.
332
+ */
333
+ private processCustomScoreEvent(event: CustomScoreEvent): void {
334
+ const { score, target, actions, mode } = event.inputEvent;
335
+ const { type, counter, debugName } = event;
336
+
337
+ const previousScore = this.getPreviousValueScore(this.getVariableName(score));
338
+
339
+ // If no target is specified, we assume the event fires when the score changes from its previous value
340
+ const scoreCondition = target
341
+ ? _.and(previousScore.notEqualTo(target), score.equalTo(target))
342
+ : score.notEqualTo(previousScore);
343
+
344
+ // If the mode is "fire_once", we also check if the counter is 0.
345
+ const condition = mode === "repeatable" ? scoreCondition : _.and(scoreCondition, counter.equalTo(0));
346
+
347
+ if (type === "global") {
348
+ // For global scores, we can directly use the condition
349
+ comment(`Processing global custom score event ${debugName}`);
350
+ _.if(condition, () => {
351
+ actions.forEach((action) => action());
352
+ counter.add(1);
353
+ });
354
+ } else {
355
+ // For individual scores, we need to check each player's score
356
+ comment(`Processing individual custom score event ${debugName}`);
357
+ execute.as(ALL).run(getChildFunctionName(debugName), () => {
358
+ _.if(condition, () => {
359
+ actions.forEach((action) => action());
360
+ counter.add(1);
361
+ });
362
+ });
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Builds the datapack based on the challenge configuration.
368
+ */
369
+ private build() {
370
+ // Initialize previous values objectives
371
+ // Will store the previous values of all variables
372
+ this.variablesPreviousValues = Object.fromEntries(
373
+ Object.entries(this.full_variables).map(([name, { type, score }]) => {
374
+ if (type === "global") {
375
+ // For global variables, we create a single global objective to store previous values
376
+ const objective = getOrCreateObjective(PREVIOUS_VALUES_GLOBAL_OBJECTIVE_NAME, "dummy");
377
+ return [name, objective(`${name}__${score.target}`)];
378
+ }
379
+
380
+ // For individual variables, we create a dedicated mirror objective
381
+ const objective = Objective.create(getUniqueObjectiveName(PREVIOUS_VALUE_PREFIX + name), "dummy");
382
+ return [name, objective(score.target)];
383
+ }),
384
+ );
385
+
386
+ // "Build" the custom events (initializes the tracking objective)
387
+ const customScoreEvents = [...this._userInputCustomEvents, ...BUILTIN_SCORE_EVENTS(this.variables)].map(
388
+ (event, index) => this.buildCustomScoreEvent(event, index),
389
+ );
390
+
391
+ // Initialize the challenge
392
+ MCFunction("start_challenge", () => {
393
+ comment("1. Setup all the global variables to their default values.");
394
+ comment(" This includes the game timer!");
395
+ this.variables.game_timer.set(this.game_duration);
396
+ for (const variable of Object.values(this.full_variables)) {
397
+ if (variable.type === "global" && variable.default !== undefined) {
398
+ variable.score.set(variable.default);
399
+ }
400
+ }
401
+ tag(ALL).remove(WINNER_TAG);
402
+
403
+ comment("2. Set up test mode if it's enabled - needs to run here so the tester player is tagged");
404
+ if (test_mode_enabled) {
405
+ set_up_test_mode();
406
+ }
407
+
408
+ comment("3. Process the start_challenge events");
409
+ this._events.start_challenge?.actions.forEach((action) => action());
410
+
411
+ comment("4. Schedule the init_participants function to run after 1s");
412
+ init_participants.schedule("1s");
413
+
414
+ comment("5. Display the game objective");
415
+ scoreboard.objectives.setDisplay("sidebar", VISIBLE_OBJECTIVE.name);
416
+ });
417
+
418
+ const init_participants = MCFunction("init_participants", () => {
419
+ comment("1. Setup all individual variables to their default values.");
420
+ for (const [name, variable] of Object.entries(this.full_variables)) {
421
+ const previousValue = this.getPreviousValueScore(name);
422
+ if (typeof previousValue === "undefined") {
423
+ throw new Error(`Previous value for variable ${name} not found.`);
424
+ }
425
+
426
+ if (variable.type === "individual" && variable.default !== undefined) {
427
+ execute.as(ALL).run(getChildFunctionName(name), () => {
428
+ variable.score.set(variable.default as number);
429
+ previousValue.set(variable.default as number);
430
+ });
431
+ }
432
+
433
+ if (variable.type === "global" && variable.default !== undefined) {
434
+ variable.score.set(variable.default);
435
+ previousValue.set(variable.default);
436
+ }
437
+ }
438
+
439
+ comment("2. Set the events fire counters to 0");
440
+
441
+ for (const event of customScoreEvents) {
442
+ // We could make the distinction between global and individual counters,
443
+ // but it just works to make all players set event fires to 0
444
+ execute.as(ALL).run(() => {
445
+ event.counter.set(0);
446
+ });
447
+ }
448
+
449
+ comment("3. Process the init_participants events");
450
+ this._events.init_participants?.actions.forEach((action) => action());
451
+
452
+ comment("4. Set the game state to ON");
453
+ this.full_variables.game_state.score.set(GAME_STATES.ON);
454
+
455
+ //tellraw("@a", [`${DISPLAY_TAG}The challenge has started!`]);
456
+ });
457
+
458
+ MCFunction(
459
+ "on_tick",
460
+ () => {
461
+ _.if(this.variables.game_state.equalTo(GAME_STATES.ON), () => {
462
+ comment("1. Run all updaters");
463
+ MCFunction("custom_variable_updaters", () => {
464
+ for (const [name, variable] of Object.entries(this.full_variables)) {
465
+ if (variable.updater) {
466
+ comment(`Updating variable ${name}`);
467
+ if (variable.type === "individual") {
468
+ execute
469
+ .as(ALL)
470
+ .at("@s")
471
+ .run(getChildFunctionName(`update_${name}`), () => {
472
+ variable.updater?.(variable.score, this.variables);
473
+ });
474
+ } else {
475
+ variable.updater(variable.score, this.variables);
476
+ }
477
+ }
478
+ }
479
+ })();
480
+
481
+ comment("2. Process the on_tick event");
482
+ this._events.on_tick?.actions.forEach((action) => action());
483
+
484
+ comment("3. Process custom events");
485
+ MCFunction("process_custom_events", () => {
486
+ // We process all custom events - this checks if the condition is reached & fire them if necessary
487
+ customScoreEvents.forEach((event) => {
488
+ this.processCustomScoreEvent(event);
489
+ });
490
+ })();
491
+
492
+ comment("4. Set previous values for all variables");
493
+ execute.as(ALL).run(getChildFunctionName("set_previous_values"), () => {
494
+ for (const [name, variable] of Object.entries(this.full_variables)) {
495
+ // We could also make the distinction between global and individual variables,
496
+ // but again it just works to run it on all players
497
+ const previousValue = this.getPreviousValueScore(name);
498
+ previousValue.set(variable.score);
499
+ }
500
+ });
501
+
502
+ comment("5. Check end conditions");
503
+
504
+ const timerCondition = this.variables.game_timer.equalTo(this.game_duration);
505
+ const endCondition = this._end_condition ? _.or(this._end_condition, timerCondition) : timerCondition;
506
+
507
+ _.if(endCondition, () => {
508
+ //tellraw("@a", [`${DISPLAY_TAG}End condition met! Ending challenge...`]);
509
+ end_challenge();
510
+ });
511
+ });
512
+ },
513
+ {
514
+ runEveryTick: true,
515
+ },
516
+ );
517
+
518
+ const end_challenge = MCFunction("end_challenge", () => {
519
+ comment("1. Set the game state to OFF");
520
+ this.full_variables.game_state.score.set(GAME_STATES.OFF);
521
+
522
+ comment("2. Process the end_challenge events");
523
+ this._events.end_challenge?.actions.forEach((action) => action());
524
+
525
+ comment("3. Announce the winners");
526
+ for (const [role, condition] of Object.entries(this._win_conditions || {})) {
527
+ execute.as(mapTarget(role)).run(getChildFunctionName(`check_winner_team_${role}`), () => {
528
+ _.if(condition as ConditionType, () => {
529
+ Actions.announce({
530
+ message: [Selector("@s"), " has won the challenge!"],
531
+ })();
532
+ tag("@s").add(WINNER_TAG);
533
+ });
534
+ });
535
+ }
536
+
537
+ Commands.declareWinners();
538
+ });
539
+
540
+ savePack("datapack", {
541
+ customPath: path.join(this.kradle_challenge_path, this.name),
542
+ });
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Creates a new challenge with the provided configuration.
548
+ */
549
+ export function createChallenge<const ROLE_NAMES extends string, const VARIABLE_NAMES extends string>(
550
+ config: _BaseConfig<ROLE_NAMES, VARIABLE_NAMES>,
551
+ ): Pick<ChallengeBase<ROLE_NAMES, VARIABLE_NAMES>, "events"> {
552
+ return new ChallengeBase(config);
553
+ }
@@ -0,0 +1,141 @@
1
+ import { _, type JSONTextComponent, type Score, Selector, type SelectorClass, tellraw } from "sandstone";
2
+ import { WATCHER, WINNER_TAG } from "./constants";
3
+
4
+ /**
5
+ * Converts a Sandstone Selector to a JSON Text Component that will be displayed like a JSON array in the chat.
6
+ * This is used to send selectors as part of JSON messages.
7
+ *
8
+ * @warning
9
+ * This does NOT work with empty selectors (they will be represented as `[""]`).
10
+ * Make sure to check if the selector matches any entities before using this function.
11
+ *
12
+ * @param selector The selector to convert.
13
+ * @returns An array of JSON Text Components that will be displayed as a JSON array in the chat.
14
+ *
15
+ * @example
16
+ * const selector = Selector("@a", { tag: "kradle_participant" });
17
+ * const jsonArray = selectorToJSONArray(selector);
18
+ * tellraw("@a", selectorToJSONArray(selector));
19
+ *
20
+ * // Everyone will see this in the chat:
21
+ * // ["Player1","Player2","Player3"]
22
+ */
23
+ function selectorToJSONArray(selector: SelectorClass<any, any>): JSONTextComponent[] {
24
+ return ['["', { selector: selector.toString(), separator: '","' } as any, '"]'];
25
+ }
26
+
27
+ /**
28
+ * Converts a Sandstone Selector to a JSON string that will be displayed like a JSON string in the chat.
29
+ * If the selector matches several entities, it will display a comma-separated list of their names.
30
+ *
31
+ * @param selector The selector to convert.
32
+ * @returns A JSON string that will be displayed like a JSON string in the chat.
33
+ *
34
+ * @example
35
+ * const selector = Selector("@s");
36
+ * const jsonString = selectorToJSONString(selector);
37
+ * tellraw("@a", jsonString);
38
+ *
39
+ * // Everyone will see this in the chat:
40
+ * // "Player1"
41
+ */
42
+ function selectorToJSONString(selector: SelectorClass<any, any>): JSONTextComponent[] {
43
+ return ['"', { selector: selector.toString(), separator: "," } as any, '"'];
44
+ }
45
+
46
+ /**
47
+ * Commands for interacting with Kradle's Watcher.
48
+ */
49
+ export const Commands = {
50
+ /**
51
+ * Raw command - sends a command to Kradle's Watcher.
52
+ *
53
+ * Arguments are passed as a JSON object.
54
+ * @param command The command to send.
55
+ * @param args The JSON Text Component arguments to send with the command.
56
+ *
57
+ * @warning
58
+ * The arguments must resolve to a JSON-serializable object. This cannot be verified at compile time.
59
+ *
60
+ * @example
61
+ * Commands.raw("start_challenge", { name: "My Challenge" });
62
+ * // The watcher will receive the following payload::
63
+ * // {"type":"kradle_command","command":"start_challenge","arguments":{"name":"My Challenge"}}
64
+ *
65
+ * Commands.raw("fetch", {url: "https://goldprice.org", "target_score": "gold_price", callback: "kradle:on_gold_price_fetched"})
66
+ * // The watcher will receive the following payload:
67
+ * // {"type":"kradle_command","command":"fetch","arguments":{"url":"https://goldprice.org","target_score":"gold_price","callback":"kradle:on_gold_price_fetched"}}
68
+ */
69
+ raw: (command: string, args: Record<string, JSONTextComponent>) => {
70
+ const basePayload: string = JSON.stringify({
71
+ command,
72
+ type: "kradle_command",
73
+ arguments: {},
74
+ });
75
+
76
+ // We need to insert our arguments into the "arguments" field of the base payload.
77
+ const splitIndex = basePayload.indexOf("}");
78
+ const leftText = basePayload.slice(0, splitIndex);
79
+ const rightText = basePayload.slice(splitIndex);
80
+
81
+ function parseValue(value: JSONTextComponent): JSONTextComponent {
82
+ if (typeof value === "boolean") {
83
+ return value.toString();
84
+ }
85
+ if (typeof value === "string") {
86
+ return JSON.stringify(value);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ const argumentsPayload = Object.entries(args).flatMap(([key, value], index) => {
92
+ const result = [`"${key}":`, parseValue(value)];
93
+ if (index > 0) {
94
+ return [",", ...result];
95
+ }
96
+ return result;
97
+ });
98
+ tellraw(WATCHER, [leftText, argumentsPayload, rightText]);
99
+ },
100
+
101
+ /**
102
+ * Declare winners of the challenge to the Watcher. This will end the challenge.
103
+ *
104
+ * The `winners` argument of the payload will be a JSON list of player names.
105
+ * Ex: { "winners": ["player1", "player2"] }
106
+ *
107
+ * If no winners are selected, an empty array will be sent.
108
+ * Ex: { "winners": [] }
109
+ */
110
+ declareWinners: () => {
111
+ const selector = Selector("@a", { tag: WINNER_TAG });
112
+
113
+ // If no winners are selected, we send them as a JSON array.
114
+ _.if(selector, () => {
115
+ Commands.raw("declare_winners", {
116
+ winners: selectorToJSONArray(selector),
117
+ });
118
+ }).else(() => {
119
+ // Else, we send an empty array.
120
+ Commands.raw("declare_winners", {
121
+ winners: "[]",
122
+ });
123
+ });
124
+ },
125
+
126
+ logVariable: (message: string, variable: Score, store: boolean) => {
127
+ const objectiveName = variable.objective.name;
128
+ const variableName = variable.target.toString();
129
+
130
+ return Commands.raw("log_variable", {
131
+ message,
132
+ store,
133
+ variable: JSON.stringify({
134
+ objective: objectiveName,
135
+ selector: variableName,
136
+ }),
137
+ value: variable,
138
+ targets: selectorToJSONString(Selector(variableName as any)),
139
+ });
140
+ },
141
+ };