Skip to content
2 changes: 1 addition & 1 deletion src/chat/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ registerCommand(
const [action, name] = args;
const channel = CHANNEL_NAMES[name];
if (!channel) {
errorMessage(player, `Kanavaa ${channel} ei ole olemassa`);
errorMessage(sender, `Kanavaa ${channel} ei ole olemassa`);
return;
}

Expand Down
189 changes: 189 additions & 0 deletions src/chat/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { clickEvent, color, text } from 'craftjs-plugin/chat';
import { UUID } from 'java.util';
import { ConsoleCommandSender } from 'org.bukkit.command';
import { sendMessages } from './system';
import { Audience } from 'net.kyori.adventure.audience';
import { Action } from 'net.kyori.adventure.text.event.ClickEvent';

/**
* Pending prompts.
*/
const pendingPrompts: Map<
Audience,
Map<string, (action: string) => void>
> = new Map();

/**
* A prompt that can be shown to command sender.
*
* This class does not handle the chat UI (except the workaround for console).
* Code using this should, in general, send commands from command() in click
* events, then call waitAnswer() to get which one the player clicked. In all
* cases, user code should also handle the special 'timeout' answer and
* arbitrary strings typed manually by players.
*/
export class Prompt {
/**
* Target command sender.
*/
private target: Audience;

/**
* If we should try to automatically 'support' console that can't click
* links in chat.
*/
private consoleSupport: boolean;

/**
* Randomly generated id.
*/
private randomId: string;

/**
* Creates a new prompt but does not show it yet. If target is console, some
* messages may be sent immediately.
* @param target Target player or console.
* @param consoleSupport Enabled by default. If set, prompt system tries to
* make the prompts console-compatible by sending additional messages. This
* is not perfect, so commands that are commonly used from console might want
* to do their own thing and disable this.
*/
constructor(target: Audience, consoleSupport = true) {
this.target = target;
this.consoleSupport = consoleSupport;
this.randomId = UUID.randomUUID().toString(); // No need for secure randomness

// If console and console support is toggled on, send initial message
if (consoleSupport && this.target instanceof ConsoleCommandSender) {
target.sendMessage(
text(
`[Prompt] Chat prompt for console created; randomId=${this.randomId}`,
),
);
}
}

/**
* Gets a command that the player this prompt was created for can use to
* complete waitAnswer() promise.
* @param answer Action string for waitAnswer() promise completion.
* @returns A command, probably for click events.
*/
command(answer: string): string {
const command = `answerprompt ${this.randomId} ${answer}`;
// Console can't click links in chat, so we'll help a bit
// (commands that are commonly used from console may want to do more than this)
if (this.consoleSupport && this.target instanceof ConsoleCommandSender) {
this.target.sendMessage(`[Prompt] ${answer} => ${command}`);
}
return '/' + command;
}

/**
* Waits for player to answer the prompt.
* @param timeout Timeout in seconds. If player takes longer than this, the
* promise returned by this will complete with 'timeout'.
* @returns A promise of the answer. Note that the answer can be ANY string;
* even if the UI does not expose it, players can and will type it manually!
*/
waitAnswer(timeout: number): Promise<string> {
// Prepare a promise and extract resolve callback from it
let callback: null | ((answer: string) => void) = null;
const promise: Promise<string> = new Promise(
(resolve) => (callback = resolve),
);

// Add callback to pending prompts
let prompts = pendingPrompts.get(this.target);
if (!prompts) {
prompts = new Map();
pendingPrompts.set(this.target, prompts);
}
if (!callback) {
throw new Error('prompt promise creation failed, callback null');
}
prompts.set(this.randomId, callback);

// Schedule a function to send a 'timeout' answer
if (this.consoleSupport && this.target instanceof ConsoleCommandSender) {
// Copy-pasting is slower than clicking a link
this.target.sendMessage('[Prompt] Console extra time +30s');
timeout += 30;
}
wait(timeout, 'seconds').then(() => {
const prompts = pendingPrompts.get(this.target);
if (prompts) {
const callback = prompts.get(this.randomId);
if (callback) {
callback('timeout');
}
prompts.delete(this.randomId); // Remove from pending promises
if (prompts.size == 0) {
pendingPrompts.delete(this.target); // Remove unnecessary Map
}
}
});

return promise;
}
}

// Prompts are handled internally by one command
registerCommand(
'answerprompt',
(sender, _alias, args) => {
const prompts = pendingPrompts.get(sender);
if (!prompts) {
return; // No promps, player clicked twice or it has expired
}
const randomId = args[0];
const callback = prompts.get(randomId);
if (!callback) {
return; // That particular prompt does not exist
}
const answer = args[1];
if (!answer) {
return; // No answer, player probably (mis)typed by hand
}
callback(answer); // Complete the promise from waitAnswer()
},
{
accessChecker: () => true,
description: 'Internal command for prompt system.',
},
);

/**
* Sends a simple yes-no prompt. This uses the more powerful Prompt class
* under the hood.
* @param target Target command sender.
* @param timeout Timeout for prompt.
* @param promptText Text to show for in-chat prompt.
* @returns 'yes' if the player accepted, 'no' if they did not,
* 'timeout' if they did not answer at all OR any other string,
* if they typed the right commands in chat by hand.
*/
export async function promptYesNo(
target: Audience,
timeout: number,
promptText: string,
): Promise<string> {
const prompt = new Prompt(target);
sendMessages(
target,
text(promptText + ' '),
color('#00AA00', '✔'),
clickEvent(
Action.RUN_COMMAND,
prompt.command('yes'),
color('#55FF55', 'Kyllä '),
),
color('#AA0000', '✘'),
clickEvent(
Action.RUN_COMMAND,
prompt.command('no'),
color('#FF5555', 'Ei '),
),
);
return prompt.waitAnswer(timeout);
}
10 changes: 10 additions & 0 deletions src/chat/style/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ interface Theme<T> {
* Error message color.
*/
error: T;

/**
* Succeess message color.
*/
success: T;
};
}

Expand All @@ -100,6 +105,7 @@ const CHAT_THEMES: Record<string, Theme<string>> = {
system: {
status: '#AAAAAA',
error: '#FF5555',
success: '#55FF55',
},
},
};
Expand Down Expand Up @@ -140,4 +146,8 @@ export function getChatTheme(player: Player): Theme<TextColor> {
];
}

export function defaultChatTheme(): Theme<TextColor> {
return COMPILED_THEMES.default;
}

// TODO add theme change support
40 changes: 33 additions & 7 deletions src/chat/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { color, component, text } from 'craftjs-plugin/chat';
import { Audience } from 'net.kyori.adventure.audience';
import { Component } from 'net.kyori.adventure.text';
import { Player } from 'org.bukkit.entity';
import { getChatTheme } from './style/theme';
import { t } from '../common/localization/localization';
import { defaultChatTheme, getChatTheme } from './style/theme';

export function sendMessages(
target: Audience,
Expand All @@ -17,16 +18,41 @@ export function sendMessages(
);
}

export function statusMessage(player: Player, msg: string) {
export function statusMessage(
receiver: Audience,
msg: string,
...formatArgs: string[]
) {
if (msg != '') {
const theme = getChatTheme(player);
player.sendMessage(color(theme.system.status, text(msg)));
const translated = t(receiver, msg, ...formatArgs);
const theme =
receiver instanceof Player ? getChatTheme(receiver) : defaultChatTheme();
receiver.sendMessage(color(theme.system.status, translated));
}
}

export function errorMessage(player: Player, msg: string) {
export function errorMessage(
receiver: Audience,
msg: string,
...formatArgs: string[]
) {
if (msg != '') {
const translated = t(receiver, msg, ...formatArgs);
const theme =
receiver instanceof Player ? getChatTheme(receiver) : defaultChatTheme();
receiver.sendMessage(color(theme.system.error, translated));
}
}

export function successMessage(
receiver: Audience,
msg: string,
...formatArgs: string[]
) {
if (msg != '') {
const theme = getChatTheme(player);
player.sendMessage(color(theme.system.error, text(msg)));
const translated = t(receiver, msg, ...formatArgs);
const theme =
receiver instanceof Player ? getChatTheme(receiver) : defaultChatTheme();
receiver.sendMessage(color(theme.system.success, translated));
}
}
25 changes: 17 additions & 8 deletions src/common/localization/localization.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Audience } from 'net.kyori.adventure.audience';
import { Player } from 'org.bukkit.entity';
import { sprintf } from 'sprintf-js';

Expand All @@ -9,15 +10,23 @@ interface Translations {
const TRANSLATIONS = new Map<string, Translations>();

type FormatArg = string | number;

/**
* Default locale, used by e.g. console messages and when unknown locales are
* encountered.
*/
const DEFAULT_LOCALE = 'en_us';

/**
* Gets a localized string in player's language.
* @param player Who is receiving the message
* Gets a localized string in audience's language.
* @param audience Who is receiving the message
* @param key Translation key for the message
* @param formatArgs Arguments to be added to the translated string
* @returns The translated and formatted string.
*/
export function t(player: Player, key: string, ...formatArgs: FormatArg[]) {
const locale = player.locale.toString();
export function t(audience: Audience, key: string, ...formatArgs: FormatArg[]) {
const locale =
audience instanceof Player ? audience.locale.toString() : DEFAULT_LOCALE;
const msg = translateKey(key, locale);

return sprintf(msg, ...formatArgs);
Expand All @@ -29,12 +38,12 @@ export function t(player: Player, key: string, ...formatArgs: FormatArg[]) {
* player.sendMessage(tr("hello_world"));
* player.sendMessage(tr("hello_player", player.name));
*
* @param player Player who will be receiving the message
* @param audience Audience who will be receiving the message
* @returns Function to call for translation
*/
export function getTranslator(player: Player) {
export function getTranslator(audience: Audience) {
return (key: string, ...formatArgs: FormatArg[]) =>
t(player, key, ...formatArgs);
t(audience, key, ...formatArgs);
}

function translateKey(key: string, locale: string) {
Expand All @@ -44,7 +53,7 @@ function translateKey(key: string, locale: string) {
return key;
}

return translations[locale] || translations.en_us;
return translations[locale] ?? translations[DEFAULT_LOCALE];
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require('./chat/index');
require('./economy/index');
require('./death/index');
require('./locks/index');
require('./profession/index');
require('./stats/index');

log.info('Valtakausi systems loaded');
Loading