From 4e3814ebce3be8b816ccc61adfb9491832bea84a Mon Sep 17 00:00:00 2001 From: bensku Date: Sat, 8 May 2021 19:54:50 +0300 Subject: [PATCH] feat: profession and nation systems (WIP) --- package-lock.json | 12 +- package.json | 2 +- src/chat/channel.ts | 2 +- src/chat/style/theme.ts | 10 + src/chat/system.ts | 25 ++- src/index.ts | 1 + src/profession/commands.ts | 251 +++++++++++++++++++++++ src/profession/index.ts | 4 + src/profession/nation.ts | 117 +++++++++++ src/profession/permissions.ts | 55 +++++ src/profession/profession.ts | 364 ++++++++++++++++++++++++++++++++++ 11 files changed, 828 insertions(+), 15 deletions(-) create mode 100644 src/profession/commands.ts create mode 100644 src/profession/index.ts create mode 100644 src/profession/nation.ts create mode 100644 src/profession/permissions.ts create mode 100644 src/profession/profession.ts diff --git a/package-lock.json b/package-lock.json index ebd29715..9b211763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -774,9 +774,9 @@ } }, "craftjs-plugin": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/craftjs-plugin/-/craftjs-plugin-0.3.2.tgz", - "integrity": "sha512-YLAcOrDCqpUccyIAigEkos5Em8yIaOWHJAsgNWxgsuX/oLy90voGz+ZLUP+DATfW+hl5L9zOfZzysoMs0AtABQ==" + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/craftjs-plugin/-/craftjs-plugin-0.3.3.tgz", + "integrity": "sha512-vgsflKwscfvwLD9DvaeL30vL4+laZ3TiuFKT1kG9NoQoY5Jknh6p29Suvp2GuEPhbU8NmgzwuqZSXw59o0/pMw==" }, "cross-spawn": { "version": "6.0.5", @@ -2723,9 +2723,9 @@ } }, "yargs-parser": { - "version": "20.2.6", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.6.tgz", - "integrity": "sha512-AP1+fQIWSM/sMiET8fyayjx/J+JmTPt2Mr0FkrgqB4todtfa53sOsrSAcIrJRD5XS20bKUwaDIuMkWKCEiQLKA==", + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", "dev": true }, "yocto-queue": { diff --git a/package.json b/package.json index 88f2f6b4..2795c256 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "chess.js": "^0.11.0", - "craftjs-plugin": "^0.3.2", + "craftjs-plugin": "^0.3.3", "lodash": "^4.17.21", "npm-run-all": "^4.1.5", "yup": "^0.32.9" diff --git a/src/chat/channel.ts b/src/chat/channel.ts index baa1c5ef..68252436 100644 --- a/src/chat/channel.ts +++ b/src/chat/channel.ts @@ -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; } diff --git a/src/chat/style/theme.ts b/src/chat/style/theme.ts index 6b104cd1..cf3adab9 100644 --- a/src/chat/style/theme.ts +++ b/src/chat/style/theme.ts @@ -76,6 +76,11 @@ interface Theme { * Error message color. */ error: T; + + /** + * Succeess message color. + */ + success: T; }; } @@ -100,6 +105,7 @@ const CHAT_THEMES: Record> = { system: { status: '#AAAAAA', error: '#FF5555', + success: '#55FF55', }, }, }; @@ -138,4 +144,8 @@ export function getChatTheme(player: Player): Theme { ]; } +export function defaultChatTheme(): Theme { + return COMPILED_THEMES.default; +} + // TODO add theme change support diff --git a/src/chat/system.ts b/src/chat/system.ts index 2b9946b4..0b63e270 100644 --- a/src/chat/system.ts +++ b/src/chat/system.ts @@ -1,17 +1,28 @@ import { color, text } from 'craftjs-plugin/chat'; +import { CommandSender } from 'org.bukkit.command'; import { Player } from 'org.bukkit.entity'; -import { getChatTheme } from './style/theme'; +import { defaultChatTheme, getChatTheme } from './style/theme'; -export function statusMessage(player: Player, msg: string) { +export function statusMessage(receiver: CommandSender | Player, msg: string) { if (msg != '') { - const theme = getChatTheme(player); - player.sendMessage(color(theme.system.status, text(msg))); + const theme = + receiver instanceof Player ? getChatTheme(receiver) : defaultChatTheme(); + receiver.sendMessage(color(theme.system.status, text(msg))); } } -export function errorMessage(player: Player, msg: string) { +export function errorMessage(receiver: CommandSender | Player, msg: string) { if (msg != '') { - const theme = getChatTheme(player); - player.sendMessage(color(theme.system.error, text(msg))); + const theme = + receiver instanceof Player ? getChatTheme(receiver) : defaultChatTheme(); + receiver.sendMessage(color(theme.system.error, text(msg))); + } +} + +export function successMessage(receiver: CommandSender | Player, msg: string) { + if (msg != '') { + const theme = + receiver instanceof Player ? getChatTheme(receiver) : defaultChatTheme(); + receiver.sendMessage(color(theme.system.success, text(msg))); } } diff --git a/src/index.ts b/src/index.ts index c8e2e9a2..f8b42a91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,5 +17,6 @@ require('./chat/index'); require('./economy/index'); require('./death/index'); require('./locks/index'); +require('./profession/index'); log.info('Valtakausi systems loaded'); diff --git a/src/profession/commands.ts b/src/profession/commands.ts new file mode 100644 index 00000000..3a651c97 --- /dev/null +++ b/src/profession/commands.ts @@ -0,0 +1,251 @@ +import { Bukkit } from 'org.bukkit'; +import { CommandSender } from 'org.bukkit.command'; +import { Player } from 'org.bukkit.entity'; +import { errorMessage, successMessage } from '../chat/system'; +import { Nation, nationById } from './nation'; +import { + getProfession, + PlayerProfession, + professionInNation, + professionsByName, + professionsInNation, + removeProfession, + updateProfession, +} from './profession'; + +registerCommand( + 'ammatti', + (sender, _alias, args) => { + // Figure out the nation this command is operating on + // Admins and console can set it; for everyone else, guess it from their profession + let nation: Nation | undefined; + if (args[0] == '--valtio' && args[1]) { + if (sender.hasPermission('vk.profession.admin')) { + nation = nationById(args[1]); + if (!nation) { + errorMessage(sender, `Valtiota ${args[1]} ei ole olemassa`); + return; + } + args = args.slice(2); // Skip these arguments + } else { + errorMessage( + sender, + 'Sinulla ei ole oikeutta ammattien ylläpitokomentoihin.', + ); + return; + } + } else { + nation = guessNation(sender); + } + + if (!args[0]) { + return false; // Print usage + } + + switch (args[0]) { + case 'luo': + createProfession(sender, nation, args[1]); + break; + case 'poista': + deleteProfession(sender, nation, args[1]); + break; + default: + viewOrUpdate(sender, nation, args[1], args.slice(1)); + } + }, + { + usage: (sender) => { + // Different instructions depending on player permissions + if (sender.hasPermission('vk.profession.ruler')) { + sender.sendMessage('/ammatti - luo/poista ammatti'); + sender.sendMessage('/ammatti - katso/muokkaa ammattia'); + sender.sendMessage('/ammatti - pelaajan ammatti'); + } else { + sender.sendMessage('/ammatti - ammatin harjoittajat'); + sender.sendMessage('/ammatti - pelaajan ammatti'); + } + }, + completer: (sender, _alias, args) => { + if (args.length == 1) { + const names = []; + for (const player of Bukkit.onlinePlayers) { + names.push(player.name); + } + if (sender.hasPermission('vk.profession.ruler')) { + names.push('luo'); + names.push('poista'); + } + return names; + } else if (args.length == 2) { + const nation = guessNation(sender); + if (args[0] == 'poista' && nation) { + return professionsInNation(nation).map((prof) => prof.name); + } else if (args[0] != 'luo') { + return ['alaiset', 'kuvaile']; + } + } else if (args.length == 3) { + if (args[1] == 'alaiset') { + return ['lisää', 'poista', 'nollaa']; + } + } + return []; + }, + }, +); + +function guessNation(sender: CommandSender): Nation | undefined { + if (!(sender instanceof Player)) { + return undefined; // Console doesn't have a nation + } + const profession = getProfession(sender); + if (profession?.type != 'player') { + return undefined; // No profession or system profession (no associated nation) + } + return nationById(profession.nation); +} + +function createProfession( + sender: CommandSender, + nation: Nation | undefined, + name: string, +) { + if (!sender.hasPermission('vk.profession.ruler')) { + errorMessage(sender, 'Sinulla ei ole oikeutta luoda ammatteja.'); + return; + } else if (!nation) { + errorMessage(sender, 'Et kuulu valtioon.'); + return; + } else if (professionInNation(nation, name)) { + errorMessage(sender, `Ammatti ${name} on jo olemassa valtiossasi.`); + return; + } + const profession: PlayerProfession = { + type: 'player', + name: name, + description: '', // No description yet + nation: nation.id, + creator: sender.name, + features: [], + subordinates: [], + }; + updateProfession(profession); // Save new profession + + successMessage(sender, `Ammatti ${name} luotu.`); + viewOrUpdate(sender, nation, profession.name, []); // Show ruler overview +} + +function deleteProfession( + sender: CommandSender, + nation: Nation | undefined, + name: string, +) { + if (!sender.hasPermission('vk.profession.ruler')) { + errorMessage(sender, 'Sinulla ei ole oikeutta luoda ammatteja.'); + return; + } else if (!nation) { + errorMessage(sender, 'Et kuulu valtioon.'); + return; + } + const profession = professionInNation(nation, name); + if (!profession) { + errorMessage(sender, `Ammattia ${name} ei ole olemassa valtiossasi.`); + return; + } + removeProfession(profession); + successMessage(sender, `Ammatti ${name} poistettu.`); +} + +function viewOrUpdate( + sender: CommandSender, + nation: Nation | undefined, + name: string, + opts: string[], +) { + const professions = professionsByName(name.toLowerCase()); + if (professions.size == 0) { + // Maybe they meant to get a profession of player? + const player = Bukkit.getOfflinePlayerIfCached(name); + if (player) { + const prof = getProfession(player); + if (prof) { + sender.sendMessage(`${player.name} on ammatiltaan ${prof?.name}.`); + } else { + sender.sendMessage(`${player.name} ei tällä harjoita ammattia.`); + } + } else { + errorMessage(sender, `Pelaajaa tai ammattia ${name} ei löydy.`); + } + return; + } + + // Print list of players with the given profession + if (opts.length == 0) { + // TODO + return; + } + + // All other operations are reserved for rulers and admins + if (!sender.hasPermission('vk.profession.ruler')) { + errorMessage(sender, 'Sinulla ei ole oikeutta hallita ammatteja.'); + return; + } else if (!nation) { + errorMessage(sender, 'Et kuulu valtioon.'); + return; + } + + const profession = professions.get(nation.id); + if (!profession) { + errorMessage(sender, `Ammattia ${name} ei ole olemassa tässä valtiossa.`); + return; + } else if (profession.type != 'player') { + errorMessage( + sender, + 'Tämä ammatti ei ole muokattavissa komennoilla. Ota yhteys ylläpitoon.', + ); + return; + } + switch (opts[0]) { + case 'alaiset': + updateSubordinates(sender, profession, opts.slice(1)); + break; + case 'kuvaile': + profession.description = opts.slice(1).join(' '); + updateProfession(profession); + break; + } +} + +function updateSubordinates( + sender: CommandSender, + profession: PlayerProfession, + opts: string[], +) { + switch (opts[0]) { + case 'lisää': + if (!opts[1]) { + errorMessage(sender, 'Alaiseksi lisättävä ammatti puuttuu.'); + return; + } + // Remove profession from list to prevent diplicates + profession.subordinates = profession.subordinates.filter( + (name) => name != profession.name, + ); + profession.subordinates.push(profession.name); // Add it to end + profession.subordinates.sort(); // Sort alphabetically + break; + case 'poista': + if (!opts[1]) { + errorMessage(sender, 'Alaisista poistettava ammatti puuttuu.'); + return; + } + // Remove profession from list + profession.subordinates = profession.subordinates.filter( + (name) => name != profession.name, + ); + break; + case 'nollaa': + profession.subordinates = []; // Clear subordinates + break; + } + updateProfession(profession); // Save changes +} diff --git a/src/profession/index.ts b/src/profession/index.ts new file mode 100644 index 00000000..378f11ed --- /dev/null +++ b/src/profession/index.ts @@ -0,0 +1,4 @@ +require('./commands'); +require('./nation'); +require('./permissions'); +require('./profession'); diff --git a/src/profession/nation.ts b/src/profession/nation.ts new file mode 100644 index 00000000..656f5896 --- /dev/null +++ b/src/profession/nation.ts @@ -0,0 +1,117 @@ +import { Table } from 'craftjs-plugin'; +import { clickEvent, style, text } from 'craftjs-plugin/chat'; +import { getTable } from '../common/datas/database'; +import { Action } from 'net.md_5.bungee.api.chat.ClickEvent'; +import { errorMessage, successMessage } from '../chat/system'; + +const nationsDb: Table = getTable('nations'); +const nations: Map = new Map(); + +export interface Nation { + /** + * Id of the nation. Currently the name in lower case. + */ + id: string; + + /** + * Nation name. In future, this might be mutable. + */ + name: string; +} + +export function nationById(id: string): Nation | undefined { + return nations.get(id); +} + +function addNation(name: string) { + const nation: Nation = { + id: name.toLowerCase(), + name: name, + }; + nations.set(nation.id, nation); // In memory + nationsDb.set(nation.id, JSON.stringify(nation)); // Persistent +} + +function removeNation(nation: Nation) { + nations.delete(nation.id); + nationsDb.delete(nation.id); +} + +registerCommand( + 'valtio', + (sender, alias, args) => { + const action = args[0]; + const name = args[1]; + const confirm = args[2]; + if (!action) { + return false; + } + if (action == 'luo') { + if (!name) { + return false; + } + if (nationById(name.toLowerCase())) { + errorMessage(sender, `Valtio ${name} on jo olemassa!`); + return; + } + if (confirm == 'kylläolenvarma') { + addNation(name); + successMessage(sender, `Luotu valtio ${name}.`); + } else { + sender.sendMessage( + clickEvent( + Action.SUGGEST_COMMAND, + `/valtio luo ${name} kylläolenvarma`, + style( + 'underlined', + text(`Vahvistus: /valtio luo ${name} kylläolenvarma`), + ), + ), + ); + } + } else if (action == 'poista') { + if (!name) { + return false; + } + const nation = nationById(name.toLowerCase()); + if (!nation) { + errorMessage(sender, `Valtiota ${name} ei ole olemassa!`); + return; + } + if (confirm == 'kylläolenvarma') { + removeNation(nation); + successMessage(sender, `Poistettu valtio ${name}.`); + } else { + sender.sendMessage( + clickEvent( + Action.SUGGEST_COMMAND, + `/valtio poista ${name} kylläolenvarma`, + style( + 'underlined', + text(`Vahvistus: /valtio poista ${name} kylläolenvarma`), + ), + ), + ); + } + } else if (action == 'lista') { + sender.sendMessage('Lista valtioista:'); + for (const nation of nations.values()) { + sender.sendMessage(`${nation.name} (${nation.id})`); + } + } else { + return false; + } + }, + { + usage: + '/valtio - luo/poista valtioita\n/valtio lista - listaa valtiot', + permission: 'vk.nation.admin', + permissionMessage: + 'Sinulla ei ole oikeutta hallita valtioita. Ota tarvittaessa yhteys ylläpitoon.', + }, +); + +// Load nations from database +for (const [id, json] of nationsDb) { + nations.set(id, JSON.parse(json)); +} diff --git a/src/profession/permissions.ts b/src/profession/permissions.ts new file mode 100644 index 00000000..f50f8849 --- /dev/null +++ b/src/profession/permissions.ts @@ -0,0 +1,55 @@ +import { Player } from 'org.bukkit.entity'; +import { PlayerJoinEvent, PlayerQuitEvent } from 'org.bukkit.event.player'; +import { PermissionAttachment } from 'org.bukkit.permissions'; + +/** + * Players mapped to Bukkit permission attachments. + */ +const attachments: Map = new Map(); + +type PermissionSource = (player: Player) => string[]; + +/** + * Functions that provide permissions for getPermissions(). + */ +const permissionSources: PermissionSource[] = []; + +function getPermissions(player: Player): string[] { + const permissions = []; + for (const source of permissionSources) { + permissions.push(...source(player)); + } + return []; +} + +/** + * Updates permissions of a player. This should be called after e.g. + * profession changes. + * @param player Player. + */ +export function updatePermissions(player: Player): void { + // Remove old attachment if one exists + const old = attachments.get(player); + if (old) { + player.removeAttachment(old); + } + + // Create new attachment and add all permissions to it + const attachment = player.addAttachment(currentPlugin); + for (const permission of getPermissions(player)) { + attachment.setPermission(permission, true); + } + attachments.set(player, attachment); +} + +export function addPermissionSource(source: PermissionSource) { + permissionSources.push(source); +} + +registerEvent(PlayerJoinEvent, (event) => { + updatePermissions(event.player); // Create permission attachment +}); + +registerEvent(PlayerQuitEvent, (event) => { + attachments.delete(event.player); // Remove permission attachment +}); diff --git a/src/profession/profession.ts b/src/profession/profession.ts new file mode 100644 index 00000000..7c78a158 --- /dev/null +++ b/src/profession/profession.ts @@ -0,0 +1,364 @@ +import { Table } from 'craftjs-plugin'; +import { UUID } from 'java.util'; +import { Bukkit, OfflinePlayer } from 'org.bukkit'; +import { getTable } from '../common/datas/database'; +import { Nation } from './nation'; +import { addPermissionSource, updatePermissions } from './permissions'; + +/** + * Serialized profession data. + */ +const professionTable: Table = getTable('profession_defs'); + +/** + * Professions by their ids. + */ +const professions: Map = new Map(); + +/** + * Profession names to maps of nation ids to professions. + * Since multiple nations can have different professions with overlapping + * names, many user-facing commands actually operate on MANY professions. + */ +const professionsByNames: Map> = new Map(); + +/** + * Professions by nation ids. + */ +const professionsByNation: Map = new Map(); + +/** + * Profession ids to cached permissions of them. + */ +const permissionCache: Map = new Map(); + +/** + * Players mapped to their professions. + */ +const playerProfessions: Table = getTable('professions'); + +/** + * Players mapped to when they were appointed to a profession last time. + */ +const appointTimes: Table = getTable('profession_appoint_times'); + +/** + * Profession details. + */ +interface BaseProfession { + /** + * Type of profession. + */ + type: string; + + /** + * Display name of the profession. + */ + name: string; + + /** + * Profession description, shown on mouse hover in chat. + */ + description: string; + + /** + * Direct subordinates of this profession. + */ + subordinates: string[]; + + /** + * Profession features that grant it permissions. + */ + features: ProfessionFeature[]; +} + +/** + * System professions are defined in code, belong to no nation and cannot be + * changed (even by admins) with in-game commands. + */ +export interface SystemProfession extends BaseProfession { + type: 'system'; +} + +/** + * Player-created professions are associated with nations and managed by + * rulers or admins. + */ +export interface PlayerProfession extends BaseProfession { + type: 'player'; + + /** + * The nation this profession is associated with. + */ + nation: string; + + /** + * UUID of player who created this profession. + */ + creator: string; +} + +export type Profession = SystemProfession | PlayerProfession; + +/** + * Creates an unique id for a profession. + * @param profession Profession data. + * @returns Profession id. + */ +export function professionId(profession: Profession) { + if (profession.type == 'system') { + return 'system:' + profession.name.toLowerCase(); + } else { + return `${profession.nation}:${profession.name.toLowerCase()}`; + } +} + +/** + * Special profession feature that grants permissions. + */ +interface ProfessionFeature { + /** + * Who can add this feature to a profession. + */ + availability: 'ruler' | 'admin'; + + /** + * Permissions this feature grants to professions. + */ + permissions: string[]; +} + +/** + * Loads non-system professions. + */ +function loadProfessions() { + // Load professions from JSON + for (const [id, json] of professionTable) { + professions.set(id, JSON.parse(json)); + updateCaches(id); + } +} + +/** + * Updates caches after a profession has been modified or loaded. + * @param id Profession id. + */ +function updateCaches(id: string) { + const profession = professions.get(id); + if (!profession) { + throw new Error(`profession ${id} does not exist`); + } + + // Cache permissions from features + const perms = []; + for (const feature of profession.features) { + perms.push(...feature.permissions); + } + permissionCache.set(id, perms); + + if (profession.type == 'player') { + // Update name -> nation, profession lookup table + const name = profession.name.toLowerCase(); + if (!professionsByNames.has(name)) { + professionsByNames.set(name, new Map()); + } + professionsByNames.get(name)?.set(profession.nation, profession); + + // Update nation -> profession list lookup table + let nationProfs = professionsByNation.get(profession.nation) ?? []; + nationProfs = nationProfs.filter((prof) => prof.name == name); // Clear previous + nationProfs.push(profession); // Add to end + nationProfs.sort((a, b) => + a.name > b.name ? 1 : b.name > a.name ? -1 : 0, + ); // Sort by profession name + professionsByNation.set(profession.nation, nationProfs); + } +} + +/** + * Gets a profession by its id. + * @param id Profession id. + * @returns A profession, or undefined if no profession with given id exists. + */ +export function professionById(id: string): Profession | undefined { + return professions.get(id); +} + +/** + * Gets all professions that share the given name. + * @param name Profession name (case doesn't matter). + * @returns Professions by nation ids, or empty map if no profession with given + * name exists. + */ +export function professionsByName(name: string): Map { + return professionsByNames.get(name.toLowerCase()) ?? new Map(); +} + +/** + * Gets a profession by id in given nation. + * @param nation Nation data. + * @param name Profession name. + * @returns Profession data, or undefined if the profession does not exist in + * given nation. + */ +export function professionInNation( + nation: Nation, + name: string, +): PlayerProfession | undefined { + // All professions in nations are player-created + return professionById( + nation.id + ':' + name.toLowerCase(), + ) as PlayerProfession; +} + +export function professionsInNation(nation: Nation): PlayerProfession[] { + return professionsByNation.get(nation.id) ?? []; +} + +/** + * Gets permissions granted by a profession. + * @param profession Profession data. + * @returns List of permissions granted by the profession. + */ +export function getProfessionPermissions(profession: Profession): string[] { + return permissionCache.get(professionId(profession)) ?? []; +} + +/** + * Adds a new system profession. + * @param profession Profession data. + */ +export function addSystemProfession(profession: SystemProfession): void { + const id = professionId(profession); + professions.set(id, profession); + updateCaches(id); +} + +/** + * Updates or adds a new player-created profession. + * @param profession Profession data. + */ +export function updateProfession(profession: PlayerProfession) { + const id = professionId(profession); + professions.set(id, profession); // Update in-memory + professionTable.set(id, JSON.stringify(profession)); // And to database + updateCaches(id); // Cached things might have changed + + // Refresh permissions of online players with this profession + for (const player of Bukkit.onlinePlayers) { + if (getProfession(player) == profession) { + updatePermissions(player); + } + } +} + +/** + * Deletes a player-created profession. + * @param profession Profession data. + * @returns The players who had this profession before it was deleted. + */ +export function removeProfession( + profession: PlayerProfession, +): OfflinePlayer[] { + const id = professionId(profession); + + // Remove profession from all players + const players: OfflinePlayer[] = []; + filterProfessions((uuid, name) => { + if (name == id) { + players.push(Bukkit.getOfflinePlayer(uuid)); + return false; + } + return true; + }); + + // Delete the profession + professions.delete(id); + professionTable.delete(id); + return players; +} + +/** + * Gets profession of a player. + * @param player Player. + * @returns A profession, or undefined if they currently lack one. + */ +export function getProfession(player: OfflinePlayer): Profession | undefined { + const id = playerProfessions.get(player.uniqueId); + return id ? professionById(id) : undefined; +} + +/** + * Sets profession of a player. + * @param player Player. + * @param profession Profession to give. + */ +export function setProfession( + player: OfflinePlayer, + profession: Profession, +): void { + playerProfessions.set(player.uniqueId, professionId(profession)); + appointTimes.set(player.uniqueId, Date.now()); + + // Ensure that profession permissions work immediately + const online = player.player; + if (online) { + updatePermissions(online); + } +} + +/** + * Clears profession of a player, if they have one. + * @param player Player. + */ +export function clearProfession(player: OfflinePlayer): void { + playerProfessions.delete(player.uniqueId); + + // Ensure that profession permissions are removed immediately + const online = player.player; + if (online) { + updatePermissions(online); + } +} + +/** + * Gets when player was last appointed to a profession. + * @param player Player. + * @returns Milliseconds since UNIX epoch. + */ +export function getAppointTime(player: OfflinePlayer): number { + return appointTimes.get(player.uniqueId) ?? 0; +} + +/** + * Filters out professions from players. + * @param callback Function that is called with UUID of each player with + * profession and that profession. Players without professions are ignored. + * If the function returns false, the profession is removed. + */ +export function filterProfessions( + callback: (uuid: UUID, name: string) => boolean, +) { + const removeQueue = []; + for (const [uuid, name] of playerProfessions) { + if (!callback(uuid, name)) { + removeQueue.push(uuid); + } + } + + // Clear professions after iteration (probably should not modify table during it) + for (const uuid of removeQueue) { + clearProfession(Bukkit.getOfflinePlayer(uuid)); + } +} + +// Plug in player profession to permission system +addPermissionSource((player) => { + const profession = getProfession(player); + if (profession) { + return getProfessionPermissions(profession); + } + return []; +}); + +loadProfessions(); // Load non-system professions from database