Files
muuhd/scenes/scene.js
Kim Ravn Hansen 15f648535c refactor
2025-10-21 12:15:17 +02:00

212 lines
5.6 KiB
JavaScript
Executable File

import { sprintf } from "sprintf-js";
/** @typedef {import("../utils/messages.js").WebsocketMessage} WebsocketMessage */
/** @typedef {import("../models/session.js").Session} Session */
/** @typedef {import("./prompt.js").Prompt } Prompt */
/**
* Scene - a class for showing one or more prompts in a row.
*
* Scenes are mostly there to keep track of which prompt to show,
* and to store data for subsequent prompts to access.
*
* The prompts themselves are responsible for data validation and
* interpretation.
*
* @abstract
*/
export class Scene {
/**
* @type {string|string[]} This text is shown when the scene begins
*/
introText = "";
/** @constant @readonly @type {Prompt?} */
introPrompt;
/** @readonly @constant @protected @type {Session} */
#session;
get session() {
return this.#session;
}
/**
* The Prompt that is currently active.
* I.e. the handler for the latest question we asked.
*
* @readonly
* @type {Prompt}
*/
#currentPrompt;
get currentPrompt() {
return this.#currentPrompt;
}
constructor() {}
/** @param {Session} session */
execute(session) {
this.#session = session;
if (this.introText) {
this.session.sendText(this.introText);
}
if (this.introPrompt) {
this.showPrompt(this.introPrompt);
} else {
this.onReady();
}
}
/** @abstract */
onReady() {
throw new Error("Abstract method must be implemented by subclass");
}
/** @param {Prompt} prompt */
showPrompt(prompt) {
this.#currentPrompt = prompt;
prompt.execute();
}
/** @param {new (scene: Scene) => Prompt} promptClassReference */
show(promptClassReference) {
this.showPrompt(new promptClassReference(this));
}
/**
* The user has been prompted, and has replied.
*
* We route that message to the current prompt.
*
* You SHOULD NOT:
* - call this method directly
* - override this method
*
* @param {WebsocketMessage} message
*/
onReply(message) {
console.debug("REPLY", {
message,
type: typeof message,
});
this.currentPrompt.onReply(message.text);
}
/**
* The user has declared their intention to quit.
*
* We route that message to the current prompt.
*
* It may be necessary to override this method in
* case you want to trigger specific behavior before
* quitting.
*
* You SHOULD NOT:
* - call this method directly
*/
onQuit() {
this.currentPrompt.onQuit();
}
/**
* The user has typed :help [topic]
*
* We route that message to the current prompt.
*
* It should not be necessary to override this function
* unless you want some special prompt-agnostic event
* to be triggered - however, scenes should not have too
* many prompts, so handling this behavior inside the prompt
* should be the primary choice.
*
* You SHOULD NOT:
* - call this method directly
*
* @param {WebsocketMessage} message
*/
onHelp(message) {
this.currentPrompt.onHelp(message.text);
}
/**
* Triggered when a user types a :command that begins with a colon
* and the current Prompt cannot handle that command.
*
* @param {string} command
* @param {any[]} args
*/
onColonFallback(command, args) {
const propertyName = "onColon__" + command;
const property = this[propertyName];
//
// Default: we have no handler for the Foo command
if (property === undefined) {
this.session.sendError(`You cannot ${command.toUpperCase()} right now`); // :foo ==> you cannot FOO right now
return;
}
//
// If this scene has a method called onColon_foo() =>
if (typeof property === "function") {
property.call(this, args);
return;
}
//
// If this scene has a string property called onColon_foo =>
if (typeof property === "string") {
this.session.sendText(property);
return;
}
//
// We found a property that has the right name but the wrong type.
throw new Error(
[
`Logic error. Scene has a handler for a command called ${command}`,
`but it is neither a function or a string, but a ${typeof property}`,
].join(" "),
);
}
/**
* The user has typed :help [topic]
*
* We route that message to the current prompt.
*
* There is no need to override this method.
*
* onColonFallback will be called if the current prompt
* cannot handle the :colon command.
*
* @param {WebsocketMessage} message
*/
onColon(message) {
const handledByPrompt = this.currentPrompt.onColon(message.command, message.args);
if (!handledByPrompt) {
this.onColonFallback(message.command, message.args);
}
}
//
// Example dynamic colon handler (also easter egg)
/** @param {any[]} args */
onColon__imperial(args) {
if (args.length === 0) {
this.session.sendText("The imperial system is the freeest system ever. Also the least good");
}
const n = Number(args[0]);
this.session.sendText(
sprintf("%.2f centimeters is only %.2f inches. This is american wands are so short!", n, n / 2.54),
);
}
onColon__hi = "Ho!";
}