diff --git a/eslint.config.js b/eslint.config.js index 53d59f86..26d6d842 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,6 +37,7 @@ export default ts.config( 'no-empty': 'off', 'no-console': 'off', 'no-undef': 'off', + 'no-var': 'off', 'prefer-const': 'warn', 'lines-between-class-members': 'off', '@typescript-eslint/no-explicit-any': 'off', diff --git a/package.json b/package.json index b0388453..f0927baf 100644 --- a/package.json +++ b/package.json @@ -17,19 +17,19 @@ "@formatjs/intl-locale": "^4.2.11", "@formatjs/intl-numberformat": "^8.15.4", "@formatjs/intl-pluralrules": "^5.4.4", - "@minecraft/server": "2.1.0-beta.1.21.90-stable", - "@minecraft/server-gametest": "1.0.0-beta.1.21.90-stable", - "@minecraft/server-net": "1.0.0-beta.1.21.90-stable", - "@minecraft/server-ui": "2.1.0-beta.1.21.90-stable", - "@minecraft/vanilla-data": "1.21.90", + "@minecraft/server": "2.4.0-beta.1.21.120-stable", + "@minecraft/server-gametest": "1.0.0-beta.1.21.120-stable", + "@minecraft/server-net": "1.0.0-beta.1.21.120-stable", + "@minecraft/server-ui": "2.1.0-beta.1.21.120-stable", + "@minecraft/vanilla-data": "1.21.120", "async-mutex": "^0.5.0" }, "resolutions": { - "@minecraft/server": "2.1.0-beta.1.21.90-stable", - "@minecraft/server-gametest": "1.0.0-beta.1.21.90-stable", - "@minecraft/server-net": "1.0.0-beta.1.21.90-stable", - "@minecraft/server-ui": "2.1.0-beta.1.21.90-stable", - "@minecraft/vanilla-data": "1.21.90" + "@minecraft/server": "2.4.0-beta.1.21.120-stable", + "@minecraft/server-gametest": "1.0.0-beta.1.21.120-stable", + "@minecraft/server-net": "1.0.0-beta.1.21.120-stable", + "@minecraft/server-ui": "2.1.0-beta.1.21.120-stable", + "@minecraft/vanilla-data": "1.21.120" }, "devDependencies": { "@eslint/js": "^9.29.0", @@ -57,7 +57,9 @@ "printWidth": 120, "endOfLine": "crlf", "quoteProps": "consistent", - "plugins": [], + "plugins": [ + "prettier-plugin-jsdoc" + ], "jsdocTagsOrder": "{\"template\": 24.5}" }, "packageManager": "yarn@4.9.1", diff --git a/src/index.ts b/src/index.ts index 38458904..b135f28c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -import { system } from '@minecraft/server' +// Takes too much to load dynamically which results in interrupted error +import 'lib/assets/intl-global-object' -system.beforeEvents.startup.subscribe(() => { - system.run(() => { - if (__TEST__) import('./test/loader') - else import('./modules/loader') - }) +import 'lib/assets/intl' + +import('./modules/loader').catch((e: unknown) => { + console.error('Loading error', e) }) diff --git a/src/lib.ts b/src/lib.ts index 38eab696..ccd281c6 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -5,54 +5,54 @@ import 'lib/load/message1' import 'lib/database/properties' // Database -export * from 'lib/database/inventory' -export * from 'lib/database/player' -export * from 'lib/database/scoreboard' -export * from 'lib/database/utils' - -// Command -export * from 'lib/command/index' - -// Lib -export * from 'lib/roles' -export * from 'lib/util' -export * from 'lib/vector' - -export * from 'lib/action' -export * from 'lib/cooldown' -export * from 'lib/enchantments' -export * from 'lib/event-signal' -export * from 'lib/i18n/lang' -export * from 'lib/location' -export * from 'lib/mail' -export * from 'lib/player-join' -export * from 'lib/portals' -export * from 'lib/rpg/airdrop' -export * from 'lib/rpg/boss' -export * from 'lib/rpg/leaderboard' -export * from 'lib/rpg/loot-table' -export * from 'lib/rpg/menu' -export * from 'lib/settings' -export * from 'lib/sidebar' -export * from 'lib/temporary' -export * from 'lib/utils/game' -export * from 'lib/utils/ms' -export * from 'lib/utils/search' - -// Region -export * from 'lib/region/index' - -// Form -export * from 'lib/form/action' -export * from 'lib/form/array' -export * from 'lib/form/chest' -export * from 'lib/form/message' -export * from 'lib/form/modal' -export * from 'lib/form/npc' -export * from 'lib/form/utils' - -// Extension exports -export * from 'lib/extensions/extend' -export * from 'lib/extensions/item-stack' -export * from 'lib/extensions/system' -export * from 'lib/load/extensions' +import 'lib/database/inventory' +import 'lib/database/player' +import 'lib/database/scoreboard' +import 'lib/database/utils' + +// // Command +import 'lib/command/index' + +// // Lib +// export * from 'lib/roles' +// export * from 'lib/util' +// export * from 'lib/vector' + +// export * from 'lib/action' +// export * from 'lib/cooldown' +// export * from 'lib/enchantments' +// export * from 'lib/event-signal' +// export * from 'lib/i18n/lang' +// export * from 'lib/location' +// export * from 'lib/mail' +// export * from 'lib/player-join' +// export * from 'lib/portals' +// export * from 'lib/rpg/airdrop' +// export * from 'lib/rpg/boss' +// export * from 'lib/rpg/leaderboard' +// export * from 'lib/rpg/loot-table' +// export * from 'lib/rpg/menu' +// export * from 'lib/settings' +// export * from 'lib/sidebar' +// export * from 'lib/temporary' +// export * from 'lib/utils/game' +// export * from 'lib/utils/ms' +// export * from 'lib/utils/search' + +// // Region +// export * from 'lib/region/index' + +// // Form +// export * from 'lib/form/action' +// export * from 'lib/form/array' +// export * from 'lib/form/chest' +// export * from 'lib/form/message' +// export * from 'lib/form/modal' +// export * from 'lib/form/npc' +// export * from 'lib/form/utils' + +// // Extension exports +// export * from 'lib/extensions/extend' +// export * from 'lib/extensions/item-stack' +// export * from 'lib/extensions/system' +// export * from 'lib/load/extensions' diff --git a/src/lib/achievements/achievement.ts b/src/lib/achievements/achievement.ts index 6eed9521..e6505fa7 100644 --- a/src/lib/achievements/achievement.ts +++ b/src/lib/achievements/achievement.ts @@ -4,6 +4,12 @@ import { i18n } from 'lib/i18n/text' import { isNotPlaying } from 'lib/utils/game' import { Rewards } from 'lib/utils/rewards' +declare module '@minecraft/server' { + interface PlayerDatabase { + achivs?: Achievement.DB + } +} + export namespace Achievement { export interface DBSingle { id: string diff --git a/src/lib/anticheat/anti-piston-abuse.ts b/src/lib/anticheat/anti-piston-abuse.ts new file mode 100644 index 00000000..ab5e4967 --- /dev/null +++ b/src/lib/anticheat/anti-piston-abuse.ts @@ -0,0 +1,29 @@ +import { system, world } from '@minecraft/server' +import { MinecraftBlockTypes } from '@minecraft/vanilla-data' +import { Vec } from 'lib/vector' +import { antiCheatLog } from './log-provider' + +world.afterEvents.pistonActivate.subscribe(event => { + const locations = event.piston.getAttachedBlocksLocations() + + system.runTimeout( + () => { + if (!event.block.isValid) return + + for (const location of locations) { + const block = event.block.dimension.getBlock(location) + if (block?.typeId !== MinecraftBlockTypes.Hopper) continue + + const nearbyPlayers = event.block.dimension.getPlayers({ location: event.block.location, maxDistance: 20 }) + const nearbyPlayersNames = nearbyPlayers.map(e => e.name).join('\n') + + antiCheatLog(`ПОРШЕНЬ ДЮП ${Vec.string(event.block.location)}\n${nearbyPlayersNames}`) + + event.block.dimension.createExplosion(event.block.location, 5, { breaksBlocks: true }) + return + } + }, + 'piston dupe prevent', + 2, + ) +}) diff --git a/src/lib/anticheat/anti-wither-bedrock-kill.ts b/src/lib/anticheat/anti-wither-bedrock-kill.ts new file mode 100644 index 00000000..401bdce0 --- /dev/null +++ b/src/lib/anticheat/anti-wither-bedrock-kill.ts @@ -0,0 +1,22 @@ +import { world } from '@minecraft/server' +import { MinecraftBlockTypes, MinecraftEntityTypes } from '@minecraft/vanilla-data' +import { Vec } from 'lib/vector' +import { antiCheatLog } from './log-provider' + +world.afterEvents.entitySpawn.subscribe(event => { + const { entity } = event + + if (entity.typeId !== MinecraftEntityTypes.Wither) return + + const { location } = entity + const block = entity.dimension.getBlock(location) + + if (block?.typeId !== MinecraftBlockTypes.Bedrock) return + + const nearbyPlayers = event.entity.dimension.getPlayers({ location, maxDistance: 20 }) + const nearbyPlayersNames = nearbyPlayers.map(e => e.name).join('\n') + + antiCheatLog(`ОБНАРУЖЕН АБУЗ ВИЗЕРА ${Vec.string(location)}\n${nearbyPlayersNames}`) + + entity.remove() +}) diff --git a/src/lib/anticheat/ban.ts b/src/lib/anticheat/ban.ts new file mode 100644 index 00000000..cdef101a --- /dev/null +++ b/src/lib/anticheat/ban.ts @@ -0,0 +1,15 @@ +import { system, world } from '@minecraft/server' + +new Command('ban') + .setDescription('Кикает и убирает игрока из вайтлиста') + .setPermissions('helper') + .string('playerName') + .executes((ctx, name) => { + system.delay(() => { + world.overworld.runCommand(`allowlist remove ${name}`) + world.overworld.runCommand( + `kick ${name} "Вы были забанены\nОбжаловать можно через бот техподдержки: @FolkLore_Support_bot"`, + ) + }) + ctx.player.success() + }) diff --git a/src/modules/anticheat/forbidden-items.ts b/src/lib/anticheat/forbidden-items.ts similarity index 82% rename from src/modules/anticheat/forbidden-items.ts rename to src/lib/anticheat/forbidden-items.ts index df991f6f..4c5064c7 100644 --- a/src/modules/anticheat/forbidden-items.ts +++ b/src/lib/anticheat/forbidden-items.ts @@ -1,7 +1,10 @@ import { system, world } from '@minecraft/server' import { MinecraftItemTypes } from '@minecraft/vanilla-data' -import { actionGuard, ActionGuardOrder, isNotPlaying } from 'lib' -import { createLogger } from 'lib/utils/logger' + +import { antiCheatLogger } from './log-provider' +import { ActionGuardOrder } from 'lib/region' +import { actionGuard } from 'lib/region' +import { isNotPlaying } from 'lib/utils/game' const forbiddenItems: string[] = [ MinecraftItemTypes.Barrier, @@ -13,7 +16,9 @@ const forbiddenItems: string[] = [ MinecraftItemTypes.RepeatingCommandBlock, ] -const logger = createLogger('AntiCheat') +const logger = antiCheatLogger + +// TODO Use inventorySlotChange + scan on startup and player join function interval() { try { diff --git a/src/lib/anticheat/freeze.ts b/src/lib/anticheat/freeze.ts new file mode 100644 index 00000000..059fb656 --- /dev/null +++ b/src/lib/anticheat/freeze.ts @@ -0,0 +1,53 @@ +import { InputPermissionCategory, Player, system } from '@minecraft/server' +import { ActionbarPriority } from 'lib/extensions/on-screen-display' +import { selectPlayer } from 'lib/form/select-player' + +new Command('freeze') + .setDescription('Останавливает движение игрока до unfreeze') + .setPermissions('helper') + .string('playerName') + .executes((ctx, name) => { + if (name) { + const player = Player.getByName(name) + if (!player) return ctx.error('Player not found') + + system.delay(() => { + player.inputPermissions.setPermissionCategory(InputPermissionCategory.Movement, false) + player.onScreenDisplay.setActionBar('§cВы были заморожены', ActionbarPriority.Highest) + }) + return ctx.reply('Успешно') + } + selectPlayer(ctx.player, 'заморозить').then(({ player }) => { + if (!player) return ctx.player.fail('Выберите онлайн игрока') + + player.inputPermissions.setPermissionCategory(InputPermissionCategory.Movement, false) + player.onScreenDisplay.setActionBar('§cВы были заморожены', ActionbarPriority.Highest) + ctx.player.success() + }) + }) + +new Command('unfreeze') + .setDescription('Возвращает движение игроку') + .setPermissions('helper') + + .string('playerName') + .executes((ctx, name) => { + if (name) { + const player = Player.getByName(name) + if (!player) return ctx.error('Player not found') + + system.delay(() => { + player.inputPermissions.setPermissionCategory(InputPermissionCategory.Movement, true) + player.onScreenDisplay.setActionBar('§aВы были разморожены', ActionbarPriority.Highest) + }) + return ctx.reply('Успешно') + } + selectPlayer(ctx.player, 'заморозить').then(({ id }) => { + const player = Player.getById(id) + if (!player) return ctx.player.fail('Выберите онлайн игрока') + + player.inputPermissions.setPermissionCategory(InputPermissionCategory.Movement, true) + player.onScreenDisplay.setActionBar('§aВы были разморожены', ActionbarPriority.Highest) + ctx.player.success() + }) + }) diff --git a/src/lib/anticheat/log-provider.ts b/src/lib/anticheat/log-provider.ts new file mode 100644 index 00000000..4ae0bbb3 --- /dev/null +++ b/src/lib/anticheat/log-provider.ts @@ -0,0 +1,16 @@ +import { createLogger } from 'lib/utils/logger' + +export const antiCheatLogger = createLogger('anticheat') + +export function antiCheatLog(text: string) { + if (!log) return antiCheatLogger.warn('No provider: ', text) + + antiCheatLogger.warn(text) + log(text) +} + +let log: null | ((text: string) => void) = null + +export function registerAntiCheatLogProvider(provider: typeof log) { + log = provider +} diff --git a/src/modules/anticheat/whitelist.ts b/src/lib/anticheat/whitelist.ts similarity index 92% rename from src/modules/anticheat/whitelist.ts rename to src/lib/anticheat/whitelist.ts index 2ad51064..6413ba8c 100644 --- a/src/modules/anticheat/whitelist.ts +++ b/src/lib/anticheat/whitelist.ts @@ -1,7 +1,10 @@ import { system, world } from '@minecraft/server' -import { DEFAULT_ROLE, is, ROLES, Settings } from 'lib' + import { defaultLang } from 'lib/assets/lang' import { noI18n } from 'lib/i18n/text' +import { DEFAULT_ROLE, is, ROLES } from 'lib/roles' +import { Settings } from 'lib/settings' +import { onLoad } from 'lib/utils/load-ref' import { createLogger } from 'lib/utils/logger' // Delay execution to move whitelist settings to the end of the settings menu @@ -37,7 +40,7 @@ system.delay(() => { } }) - system.delay(() => { + onLoad(() => { if (whitelist.enabled) { logger.info('To disable, use /scriptevent whitelist:disable') } diff --git a/src/lib/assets/intl-global-object.ts b/src/lib/assets/intl-global-object.ts new file mode 100644 index 00000000..bff4140d --- /dev/null +++ b/src/lib/assets/intl-global-object.ts @@ -0,0 +1,3 @@ +//@ts-expect-error Define global intl if not defined +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +globalThis.Intl ??= {} \ No newline at end of file diff --git a/src/lib/chat/chat.ts b/src/lib/chat/chat.ts new file mode 100644 index 00000000..d2c59dae --- /dev/null +++ b/src/lib/chat/chat.ts @@ -0,0 +1,138 @@ +import { ChatSendBeforeEvent, Player, system, world } from '@minecraft/server' +import { Cooldown } from 'lib/cooldown' +import { table } from 'lib/database/abstract' +import { i18n, noI18n } from 'lib/i18n/text' +import { Settings } from 'lib/settings' +import { onLoad } from 'lib/utils/load-ref' +import { msold } from 'lib/utils/ms-old' +import { Singleton } from 'lib/utils/singleton' +import './command' + +export declare namespace Chat { + interface MuteInfo { + mutedUntil: number + reason?: string + } + + interface Context { + sender: Player + text: string + nearPlayers: Player[] + farPlayers: Player[] + } +} + +export abstract class Chat extends Singleton { + muteDb = table('chatMute') + + settings = Settings.world(...Settings.worldCommon, { + cooldown: { + name: 'Задержка чата (миллисекунды)', + description: '0 что бы отключить', + value: 0, + onChange: () => this.updateCooldown(), + }, + range: { + name: 'Радиус чата', + description: 'Радиус для скрытия сообщений дальних игроков', + value: 30, + }, + capsLimit: { + name: 'Макс больших букв в сообщении', + description: 'Не разрешает отправлять сообщения где слишком много капса', + value: 5, + }, + role: { + name: 'Роли в чате', + value: true, + }, + }) + + playerSettings = Settings.player(i18n`Чат\n§7Звуки и внешний вид чата`, 'chat', { + sound: { + name: i18n`Звук`, + description: i18n`Звука сообщений от игроков поблизости`, + value: true, + }, + }) + + private cooldown!: Cooldown + + private updateCooldown() { + this.cooldown = new Cooldown(this.settings.cooldown, true, {}) + } + + informAboutMute(player: Player, mute: Chat.MuteInfo): void { + const timeText = msold.remaining(mute.mutedUntil - Date.now()) + + return player.fail( + noI18n.error`Вы замьючены в чате на ${timeText.value} ${timeText.type} по причине: ${mute.reason}`, + ) + } + + registerChatListener() { + world.beforeEvents.chatSend.subscribe(this.chatListener) + } + + chatListener: (arg0: ChatSendBeforeEvent) => void + + constructor() { + super() + + onLoad(() => { + this.updateCooldown() + }) + + this.chatListener = event => { + event.cancel = true + system.delay(() => { + try { + const player = event.sender + + if (!this.cooldown.isExpired(event.sender)) { + console.log('Spam chat', player.name, event.message) + return + } + + const mute = this.muteDb.getImmutable(event.sender.id) + if (mute) { + if (mute.mutedUntil > Date.now()) { + console.log('Muted chat', player.name, event.message) + return this.informAboutMute(player, mute) + } + } + + const text = event.message.replace(/\\n/g, '\n').replace(/§./g, '').replace(/%/g, '%%').trim() + + const caps = text.split('').reduce((p, c) => (c !== c.toLowerCase() ? p + 1 : p), 0) + if (caps > this.settings.capsLimit) { + return event.sender.fail(noI18n.error`В сообщении слишком много капса (${caps}/${this.settings.capsLimit})`) + } + + const allPlayers = world.getAllPlayers() + + // Players that are near message sender + const nearPlayers = event.sender.dimension + .getPlayers({ + location: event.sender.location, + maxDistance: this.settings.range, + }) + .filter(e => e.id !== event.sender.id && e.dimension.id === event.sender.dimension.id) + + // Array with ranged players (include sender id) + const nearIds = nearPlayers.map(e => e.id) + nearIds.push(event.sender.id) + + // Outranged players + const farPlayers = allPlayers.filter(e => !nearIds.includes(e.id)) + + this.onMessage({ sender: event.sender, text, farPlayers, nearPlayers }) + } catch (error) { + console.error('Chat error handler', error) + } + }) + } + } + + protected abstract onMessage(ctx: Chat.Context): void +} diff --git a/src/lib/chat/command.ts b/src/lib/chat/command.ts new file mode 100644 index 00000000..5665f757 --- /dev/null +++ b/src/lib/chat/command.ts @@ -0,0 +1,64 @@ +import { Player } from '@minecraft/server' +import { emoji } from 'lib/assets/emoji' +import { ModalForm } from 'lib/form/modal' +import { form } from 'lib/form/new' +import { selectPlayer } from 'lib/form/select-player' +import { noI18n } from 'lib/i18n/text' +import { ROLES } from 'lib/roles' + +new Command('chat') + .setDescription('Управление отображением игрока/ранга в чате/игре') + .setPermissions('techAdmin') + .executes(ctx => { + const player = ctx.player + chatForm(player) + }) + +function chatForm(player: Player) { + selectPlayer(player, 'настроить его отображение в чате/игре').then(e => { + chatPlayerEditForm({ target: e }).show(player) + }) +} + +const chatPlayerEditForm = form.params<{ target: { name: string; id: string } }>( + (f, { player, self, params: { target } }) => { + f.title(target.name) + const db = Player.database.getImmutable(target.id) + f.body( + noI18n`Видимый ранг: ${db.displayRole}\nРоль: ${ROLES[db.role].to(player.lang)}\nЭмоджи: ${db.emoji}\nЦвет сообщения в чате: ${`${db.chatTextColor ?? ''}сообщение`}`, + ) + f.button('Назад', () => { + chatForm(player) + }) + f.button( + `Изменить эмоджи`, + form(f => { + f.button(`Очистить выбор: ${db.emoji ?? 'Не выбрано'}`, () => { + const ddb = Player.database.get(target.id) + delete ddb.emoji + self() + }) + for (const [name, e] of Object.entries(emoji.nickname)) { + f.button(`${name} ${e}`, () => { + const ddb = Player.database.get(target.id) + ddb.emoji = e + self() + }) + } + }), + ) + f.button('Изменить', () => { + new ModalForm('Изменить') + .addTextField('Видимый ранг', 'очистит ее', db.displayRole) + .addTextField('Цвет сообщения в чате', 'очистит его', db.chatTextColor) + .show(player, (ctx, displayRole, textColor) => { + const ddb = Player.database.get(target.id) + if (!displayRole) delete ddb.displayRole + else ddb.displayRole = displayRole + if (!textColor) delete ddb.chatTextColor + else ddb.chatTextColor = textColor + self() + }) + }) + }, +) diff --git a/src/lib/chat/mute.ts b/src/lib/chat/mute.ts new file mode 100644 index 00000000..2ac9ac0b --- /dev/null +++ b/src/lib/chat/mute.ts @@ -0,0 +1,97 @@ +import { Player } from '@minecraft/server' +import { CommandContext } from 'lib/command/context' +import { ArrayForm } from 'lib/form/array' +import { ModalForm } from 'lib/form/modal' +import { form } from 'lib/form/new' +import { selectPlayer } from 'lib/form/select-player' +import { getFullname } from 'lib/get-fullname' +import { ms, Time } from 'lib/utils/ms' +import { msold } from 'lib/utils/ms-old' +import { Chat } from './chat' + +function mute(type: Time, time: number, reason = 'за поведение', id: string, ctx: CommandContext) { + const actualTime = ms.from(type, time) + const muteInfo: Chat.MuteInfo = { mutedUntil: Date.now() + actualTime, reason } + Chat.getInstance().muteDb.set(id, muteInfo) + const player = Player.getById(id) + if (player) Chat.getInstance().informAboutMute(player, muteInfo) + + const timeText = msold.remaining(actualTime) + ctx.player.success( + `Игрок ${Player.nameOrUnknown(id)} был замьючен на ${timeText.value} ${timeText.type} по причине: ${reason}`, + ) +} + +function findOfflinePlayer(nameArg: string, ctx: CommandContext) { + for (const [id, data] of Player.database.entriesImmutable()) { + if (data.name === nameArg) return id + } + ctx.error(`Игрок ${nameArg} не найден`) +} + +new Command('mute') + .setDescription('Заглушить игрока в чате') + .setPermissions('helper') + .string('name', true) + .int('time', true) + .array('timeType', ['min', 'hour', 'day', 'sec'], true) + .string('reason', true) + .executes((ctx, nameArg, timeArg = 5, typeArg = 'min', reasonArg) => { + if (nameArg) { + if (typeof ms.converters[typeArg] === 'undefined') return ctx.error('Неизвестный тип времени') + const id = findOfflinePlayer(nameArg, ctx) + if (id) mute(typeArg, timeArg, reasonArg, id, ctx) + return + } + + selectPlayer(ctx.player, 'замутить').then(e => { + new ModalForm('Мут ' + e.name) + .addTextField('Время', 'введи', '5') + .addDropdownFromObject('Тип времени', { + min: 'Минуты', + hour: 'Часы', + }) + .addTextField('Причина', 'за поведение') + .show(ctx.player, (formctx, timeRaw, type, reason) => { + const time = parseInt(timeRaw) + if (isNaN(time)) return formctx.error(`${timeRaw} это не число`) + + mute(type, time, reason || undefined, e.id, ctx) + }) + }) + }) + +new Command('unmute') + .setDescription('Вернуть обратно') + .setPermissions('helper') + .string('name', true) + .executes((ctx, name) => { + if (name) { + const id = findOfflinePlayer(name, ctx) + if (id) { + if (!Chat.getInstance().muteDb.has(id)) return ctx.error('Не был замьючен') + + Chat.getInstance().muteDb.delete(id) + return ctx.reply('Размьючен') + } + return + } + + new ArrayForm('Муты', Chat.getInstance().muteDb.entries()) + .button(([id, info]) => { + if (!info) return false + const until = `До: ${new Date(info.mutedUntil).toYYYYMMDD()} ${new Date(info.mutedUntil).toHHMM()}` + return [ + `${getFullname(id)} ${until}\n${info.reason}`, + form((f, { self }) => { + f.title(getFullname(id)) + f.body(`Причина: ${info.mutedUntil}\n${until}`) + f.button('Размутить', () => { + Chat.getInstance().muteDb.delete(id) + self() + }) + }).show, + ] + }) + .show(ctx.player) + }) diff --git a/src/lib/clan/clan.ts b/src/lib/clan/clan.ts index 2a928e30..d14ebbf5 100644 --- a/src/lib/clan/clan.ts +++ b/src/lib/clan/clan.ts @@ -1,10 +1,42 @@ -import { Player, system, world } from '@minecraft/server' +import { Player, system } from '@minecraft/server' import { table } from 'lib/database/abstract' +import { I18nMessage } from 'lib/i18n/message' +import { i18n } from 'lib/i18n/text' +import { onLoad } from 'lib/utils/load-ref' import './command' -interface StoredClan { - members: string[] - owners: string[] +interface ClanTemporalMember { + until: number +} + +export enum ClanRole { + Member = 'member', + Helper = 'helper', + Owner = 'owner', +} + +const roleNames: Record = { + [ClanRole.Member]: i18n`Участник`, + [ClanRole.Helper]: i18n`Помошник`, + [ClanRole.Owner]: i18n`Владелец`, +} + +export interface ClanMember { + id: string + createdAt: number + updatedAt: number + role: ClanRole +} + +interface ClanJSON { + members2: ClanMember[] + + /** @deprecated Use {@link members2} instead */ + members?: string[] + /** @deprecated Use {@link members2} instead */ + owners?: string[] + + temporalMembers?: Record name: string shortname: string @@ -16,7 +48,11 @@ interface StoredClan { } export class Clan { - private static database = table('clan') + private static database = table('clan') + + static roleToString(role: ClanRole) { + return roleNames[role] + } static getPlayerClan(playerId: string) { for (const clan of this.instances.values()) { @@ -28,12 +64,15 @@ export class Clan { return this.instances.values() } + static get(id: string) { + return this.instances.get(id) + } + static create(player: Player, name: string, shortname: string) { while (this.database.has(name)) name += '-' this.database.set(name, { - members: [player.id], - owners: [player.id], + members2: [], name, shortname, @@ -45,7 +84,9 @@ export class Clan { }) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return new Clan(name, this.database.get(name)!) + const clan = new Clan(name, this.database.get(name)!) + clan.addMember(player.id, ClanRole.Owner) + return clan } static getInvites(playerId: string) { @@ -55,17 +96,33 @@ export class Clan { private static instances = new Map() static { - world.afterEvents.worldLoad.subscribe(() => + onLoad(() => system.run(() => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - for (const [id, db] of this.database.entries()) new Clan(id, db!) + for (const [id, db] of this.database.entries()) { + if (!db) continue + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + db.members2 ??= [] + + const clan = new Clan(id, db) + + // Migrate old format + if (db.owners?.length) { + for (const m of db.owners) clan.addMember(m, ClanRole.Owner) + delete db.owners + } + if (db.members?.length) { + for (const m of db.members) clan.addMember(m) + delete db.members + } + } }), ) } constructor( - private readonly id: string, - public readonly db: StoredClan, + public readonly id: string, + public readonly db: ClanJSON, ) { const clan = Clan.instances.get(this.id) if (clan) return clan @@ -91,41 +148,54 @@ export class Clan { } get members() { - return this.db.members as Readonly + return this.db.members2 as readonly ClanMember[] + } + + get membersIds() { + return this.db.members2.map(e => e.id) as readonly string[] } get owners() { - return this.db.owners as Readonly + return this.db.members2.filter(e => e.role === ClanRole.Owner).map(e => e.id) as readonly string[] } get joinRequests() { - return this.db.joinRequests as Readonly + return this.db.joinRequests as Readonly } get invites() { - return this.db.invites as Readonly + return this.db.invites as Readonly } isMember(playerId: string) { - return this.db.members.includes(playerId) + return !!this.getMember(playerId) } isOwner(playerId: string) { - return this.db.owners.includes(playerId) + return this.getMember(playerId)?.role === ClanRole.Owner + } + + isHelper(playerId: string) { + return this.getMember(playerId)?.role === ClanRole.Helper } isInvited(playerId: string) { return this.db.invites.includes(playerId) } - setRole(playerId: string, role: 'member' | 'owner') { - if (role === 'member') this.db.owners = this.db.owners.filter(e => e !== playerId) - if (role === 'owner') this.db.owners.push(playerId) + getMember(playerId: string) { + return this.db.members2.find(e => e.id === playerId) + } + + setMemberRole(playerId: string, role: ClanRole) { + const member = this.getMember(playerId) + + if (!member) return + member.role = role } remove(playerId: string) { - this.db.members = this.db.members.filter(e => e !== playerId) - this.db.owners = this.db.owners.filter(e => e !== playerId) + this.db.members2 = this.db.members2.filter(e => e.id !== playerId) } sendInvite(playerId: string) { @@ -151,16 +221,61 @@ export class Clan { this.db.invites = this.db.invites.filter(e => e !== id) } - add(id: string) { + addMember(id: string, role: ClanRole = ClanRole.Member) { + if (this.isMember(id)) return + for (const clan of Clan.getAll()) { clan.db.joinRequests = clan.db.joinRequests.filter(e => e !== id) clan.db.invites = clan.db.invites.filter(e => e !== id) } - this.db.members.push(id) + this.db.members2.push({ id, role, createdAt: Date.now(), updatedAt: Date.now() }) } delete() { Clan.database.delete(this.id) Clan.instances.delete(this.id) } + + addTemporalMember(id: string, until: number) { + this.db.temporalMembers ??= {} + this.db.temporalMembers[id] = { until } + } + + removeTemporalMember(id: string) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + if (this.db.temporalMembers) delete this.db.temporalMembers[id] + } + + isTemporalMemberValid(id: string, member?: ClanTemporalMember) { + if (!member) return false + + // 0 means forever + if (member.until === 0) return true + + if (Date.now() > member.until) { + this.removeTemporalMember(id) + return false + } + + return true + } + + get temporalMembers() { + if (!this.db.temporalMembers) return [] + + const members: { id: string; until: number }[] = [] + for (const [id, member] of Object.entries(this.db.temporalMembers)) { + if (this.isTemporalMemberValid(id, member)) members.push({ id, until: member.until }) + } + return members + } + + isTemporalMember(id: string): boolean { + return this.isTemporalMemberValid(id, this.db.temporalMembers?.[id]) + } + + getTemporalMember(id: string) { + const member = this.db.temporalMembers?.[id] + if (this.isTemporalMemberValid(id, member)) return member + } } diff --git a/src/lib/clan/create.ts b/src/lib/clan/create.ts new file mode 100644 index 00000000..71d73637 --- /dev/null +++ b/src/lib/clan/create.ts @@ -0,0 +1,127 @@ +import { Player } from '@minecraft/server' +import { Cooldown } from 'lib/cooldown' +import { registerResettableCooldown } from 'lib/cooldownreset' +import { ArrayForm } from 'lib/form/array' +import { MessageForm } from 'lib/form/message' +import { ModalForm } from 'lib/form/modal' +import { i18n } from 'lib/i18n/text' +import { Mail } from 'lib/mail' +import { onLoad } from 'lib/utils/load-ref' +import { ms } from 'lib/utils/ms' +import { Clan } from './clan' +import { clanInvites, clanMenu, inClanMenu } from './menu' + +export function selectOrCreateClanMenu(player: Player, back?: VoidFunction) { + new ArrayForm(i18n`Выбор клана`, [...Clan.getAll()].reverse()) + .description(i18n`Выберите клан, чтобы отправить заявку или создайте свой клан!`) + .addCustomButtonBeforeArray(form => { + const invitedTo = Clan.getInvites(player.id) + if (invitedTo.length) + form.button(i18n.accent`Приглашения`.badge(invitedTo.length).to(player.lang), () => { + new ArrayForm(i18n`Приглашения`, invitedTo) + .button(clan => [ + getClanButtonName(clan), + () => { + clan.addMember(player.id) + player.success(i18n`Вы приняли приглашение в клан '${clan.name}'`) + inClanMenu({ clan }).show(player) + }, + ]) + .back(() => selectOrCreateClanMenu(player, back)) + .show(player) + }) + form.button(i18n.accent`Создать свой клан`.to(player.lang), () => + promptClanNameShortname( + player, + i18n`Создать клан`, + (name, shortname) => { + const clan = Clan.create(player, name, shortname) + clanInvites(player, clan, () => clanMenu(player)[1]()) + }, + () => selectOrCreateClanMenu(player, back), + ), + ) + }) + .button(clan => [ + getClanButtonName(clan, clan.isInvited(player.id) ? i18n.disabled : i18n), + () => { + if (!clan.requestJoin(player)) { + return player.fail( + i18n.error`Вы уже отправили заявку в клан '${Clan.getPlayerClan(player.id)?.name ?? clan.name}'!`, + ) + } + + player.success(i18n`Заявка на вступление в клан '${clan.name}' отправлена!`) + Mail.sendMultiple( + clan.owners, + i18n.nocolor`Запрос на вступление в клан от '${player.name}'`, + i18n`Игрок хочет вступить в ваш клан, вы можете принять или отклонить его через меню кланов`, + ) + }, + ]) + .back(back) + .show(player) +} +export function getClanButtonName(clan: Clan, style: Text.Fn = i18n): Text { + return style`[${clan.shortname}] ${clan.name}\nУчастники: ${clan.members.length} ${clan.owners.map(id => Player.nameOrUnknown(id)).join(', ')}` +} + +const cooldown = onLoad(() => { + const cd = new Cooldown(ms.from('day', 1), true, Cooldown.defaultDb.get('clan')) + registerResettableCooldown('Создание/изменение названия клана', cd) + return cd +}) + +export function promptClanNameShortname( + player: Player, + title: Text, + onDone: (name: string, shortname: string) => void, + back: VoidFunction, + clan?: Clan, + defaultName?: string, + defaultShortname?: string, +) { + if (!cooldown.value.isExpired(player, false)) return + new ModalForm(title.to(player.lang)) + .addTextField( + i18n`Название клана`.to(player.lang), + i18n`Ну, давай, придумай чета оригинальное`.to(player.lang), + defaultName, + ) + .addTextField( + i18n`Тэг клана`.to(player.lang), + i18n`Чтобы блатными в чате выглядеть`.to(player.lang), + defaultShortname, + ) + .show(player, (_, name, shortname) => { + name = name.trim() + shortname = shortname.trim() + + function err(reason: Text) { + return new MessageForm(i18n`Ошибка`.to(player.lang), reason.to(player.lang)) + .setButton1(i18n`Щас исправлю`.to(player.lang), () => + promptClanNameShortname(player, title, onDone, back, clan, name, shortname), + ) + .setButton2(i18n`Та ну не надоело`.to(player.lang), back) + .show(player) + } + + if (name.includes('§')) return err(i18n`Имя '${name}' не может содержать параграф`) + if (shortname.includes('§')) return err(i18n`Короткое имя '${shortname}' не может содержать параграф`) + if (shortname.length > 5) return err(i18n`Короткое имя '${shortname}' должно быть КОРОТКИМ, меньше 5 символов`) + if (shortname.length < 2) + return err( + i18n.error`Короткое имя '${shortname}' не может быть СЛИШКОМ коротким, минимум 2 символа. А то как понять че это за клан '${shortname}'`, + ) + + for (const c of Clan.getAll()) { + if (c === clan) continue + if (c.name === name) return err(i18n.error`Клан с именем '${name}' уже существует.`) + if (c.shortname === shortname) return err(i18n.error`Короткое имя '${shortname}' уже занято.`) + } + + if (!cooldown.value.isExpired(player)) return + + onDone(name, shortname) + }) +} diff --git a/src/lib/clan/menu.ts b/src/lib/clan/menu.ts index d0415f50..37cf14b7 100644 --- a/src/lib/clan/menu.ts +++ b/src/lib/clan/menu.ts @@ -1,24 +1,16 @@ -import { Player, world } from '@minecraft/server' -import { Cooldown } from 'lib/cooldown' +import { Player } from '@minecraft/server' import { ArrayForm } from 'lib/form/array' -import { MessageForm, ask } from 'lib/form/message' +import { ask, MessageForm } from 'lib/form/message' import { ModalForm } from 'lib/form/modal' -import { form } from 'lib/form/new' +import { form, FormContext, NewFormCreator } from 'lib/form/new' import { selectPlayer } from 'lib/form/select-player' import { BUTTON } from 'lib/form/utils' import { getFullname } from 'lib/get-fullname' import { i18n, textTable } from 'lib/i18n/text' import { Mail } from 'lib/mail' -import { ms } from 'lib/utils/ms' -// import { registerResettableCooldown } from 'modules/commands/cooldownreset' -import { Clan } from './clan' - -let cd: Cooldown - -world.afterEvents.worldLoad.subscribe(() => { - cd = new Cooldown(ms.from('day', 1), true, Cooldown.defaultDb.get('clan')) - // registerResettableCooldown('Изменение/создание клана', cd) -}) +import { is } from 'lib/roles' +import { Clan, ClanMember, ClanRole } from './clan' +import { getClanButtonName, promptClanNameShortname, selectOrCreateClanMenu } from './create' export function clanMenu(player: Player, back?: VoidFunction) { const clan = Clan.getPlayerClan(player.id) @@ -34,114 +26,24 @@ export function clanMenu(player: Player, back?: VoidFunction) { } } -function selectOrCreateClanMenu(player: Player, back?: VoidFunction) { - new ArrayForm(i18n`Выбор клана`, [...Clan.getAll()].reverse()) - .description(i18n`Выберите клан, чтобы отправить заявку или создайте свой клан!`) - .addCustomButtonBeforeArray(form => { - const invitedTo = Clan.getInvites(player.id) - if (invitedTo.length) - form.button(i18n.accent`Приглашения`.badge(invitedTo.length).to(player.lang), () => { - new ArrayForm(i18n`Приглашения`, invitedTo) - .button(clan => [ - getClanName(clan), - () => { - clan.add(player.id) - player.success(i18n`Вы приняли приглашение в клан '${clan.name}'`) - inClanMenu({ clan }).show(player) - }, - ]) - .back(() => selectOrCreateClanMenu(player, back)) - .show(player) - }) - form.button(i18n.accent`Создать свой клан`.to(player.lang), () => - promptClanNameShortname( - player, - i18n`Создать клан`, - (name, shortname) => { - const clan = Clan.create(player, name, shortname) - clanInvites(player, clan, () => clanMenu(player)[1]()) - }, - () => selectOrCreateClanMenu(player, back), - ), - ) - }) - .button(clan => [ - getClanName(clan, clan.isInvited(player.id) ? i18n.disabled : i18n), - () => { - if (!clan.requestJoin(player)) return - player.fail(i18n.error`Вы уже отправили заявку в клан '${Clan.getPlayerClan(player.id)?.name ?? clan.name}'!`) - - Mail.sendMultiple( - clan.owners, - i18n.nocolor`Запрос на вступление в клан от '${player.name}'`, - i18n`Игрок хочет вступить в ваш клан, вы можете принять или отклонить его через меню кланов`, - ) - player.success(i18n`Заявка на вступление в клан '${clan.name}' отправлена!`) - }, - ]) - .back(back) - .show(player) - - function getClanName(clan: Clan, style: Text.Fn = i18n): Text { - return style`[${clan.shortname}] ${clan.name}\nУчастники: ${clan.members.length}` - } +interface ClanButtonContext { + f: NewFormCreator + clan: Clan + formContext: FormContext<{ clan: Clan }> + isOwner: boolean + isHelper: boolean } - -function promptClanNameShortname( - player: Player, - title: Text, - onDone: (name: string, shortname: string) => void, - back: VoidFunction, - clan?: Clan, - defaultName?: string, - defaultShortname?: string, -) { - if (!cd.isExpired(player, false)) return - new ModalForm(title.to(player.lang)) - .addTextField( - i18n`Имя клана`.to(player.lang), - i18n`Ну, давай, придумай чета оригинальное`.to(player.lang), - defaultName, - ) - .addTextField( - i18n`Краткое имя клана`.to(player.lang), - i18n`Чтобы блатными в чате выглядеть`.to(player.lang), - defaultShortname, - ) - .show(player, (_, name, shortname) => { - name = name.trim() - shortname = shortname.trim() - - function err(reason: Text) { - return new MessageForm(i18n`Ошибка`.to(player.lang), reason.to(player.lang)) - .setButton1(i18n`Щас исправлю`.to(player.lang), () => - promptClanNameShortname(player, title, onDone, back, clan, name, shortname), - ) - .setButton2(i18n`Та ну не надоело`.to(player.lang), back) - .show(player) - } - - if (name.includes('§')) return err(i18n`Имя '${name}' не может содержать параграф`) - if (shortname.includes('§')) return err(i18n`Короткое имя '${shortname}' не может содержать параграф`) - if (shortname.length > 5) return err(i18n`Короткое имя '${shortname}' должно быть КОРОТКИМ, меньше 5 символов`) - if (shortname.length < 2) - return err( - i18n.error`Короткое имя '${shortname}' не может быть СЛИШКОМ коротким, минимум 2 символа. А то как понять че это за клан '${shortname}'`, - ) - - for (const c of Clan.getAll()) { - if (c === clan) continue - if (c.name === name) return err(i18n.error`Клан с именем '${name}' уже существует.`) - if (c.shortname === shortname) return err(i18n.error`Короткое имя '${shortname}' уже занято.`) - } - - if (!cd.isExpired(player)) return - - onDone(name, shortname) - }) +const clanAdditionalButtons: ((ctx: ClanButtonContext) => void)[] = [] +export function registerClanMenuButton(register: (ctx: ClanButtonContext) => void) { + clanAdditionalButtons.push(register) } -const inClanMenu = form.params<{ clan: Clan }>((f, { self, player, params: { clan } }) => { +export const inClanMenu = form.params<{ clan: Clan }>((f, formContext) => { + const { + self, + player, + params: { clan }, + } = formContext f.title(i18n`Меню клана`) f.body( textTable([ @@ -151,14 +53,20 @@ const inClanMenu = form.params<{ clan: Clan }>((f, { self, player, params: { cla ) const isOwner = clan.isOwner(player.id) + const isHelper = clan.isHelper(player.id) f.button(i18n`Участники`.size(clan.members.length), () => clanMembers(player, clan, self)) - if (isOwner) { + if (isOwner || isHelper) { f.button(i18n`Заявки на вступление`.badge(clan.joinRequests.length), () => clanJoinRequests(player, clan, self)) - f.button(i18n`Приглашения`.badge(clan.invites.length), () => clanInvites(player, clan, self)) - f.button(i18n`Изменить имя/короткое имя`, () => + } + + const context: ClanButtonContext = { clan, f, formContext, isHelper, isOwner } + for (const button of clanAdditionalButtons) button(context) + + if (isOwner) { + f.button(i18n`Изменить название или тэг клана`, () => promptClanNameShortname( player, i18n`Изменить`, @@ -175,7 +83,7 @@ const inClanMenu = form.params<{ clan: Clan }>((f, { self, player, params: { cla ) f.ask(i18n.error`Удалить клан`, i18n.error`Удалить`, () => { Mail.sendMultiple( - clan.members, + clan.membersIds, i18n.nocolor`Клан '${clan.name}' распущен`, i18n`К сожалению, клан был распущен. Хз че создателю не понравилось, найдите клан получше или создайте новый, печалиться смысла нет. Ну базы еще можете залутать, врятли создатель успел вас удалить из всех клановых баз.`, ) @@ -193,20 +101,30 @@ const inClanMenu = form.params<{ clan: Clan }>((f, { self, player, params: { cla new ArrayForm('Кланы', [...Clan.getAll()].reverse()) .button((clan, _, __) => { return [ - i18n.join`[${clan.shortname}] ${clan.name}`.size(clan.members.length).to(player.lang), + getClanButtonName(clan), form((f, { self }) => { f.title(clan.name) f.body(`Короткое имя: ${clan.shortname}`) for (const o of clan.members) { - f.button(getFullname(o), self) + f.button(i18n`${getFullname(o.id)}\n${Clan.roleToString(o.role)}`, self) } }).show, ] }) .show(player) }) + + if (is(player.id, 'techAdmin')) { + f.button(i18n`Админ: добавить игрока`, () => + selectPlayer(player, 'добавить в клан', self).then(e => { + clan.addMember(e.id) + player.success() + }), + ) + } }) + function clanJoinRequests(player: Player, clan: Clan, back?: VoidFunction) { const self = () => { clanJoinRequests(player, clan, back) @@ -221,8 +139,7 @@ function clanJoinRequests(player: Player, clan: Clan, back?: VoidFunction) { .setButton1(i18n`Принять!`.to(player.lang), () => { const message = i18n.nocolor`Вы приняты в клан ${clan.name}` Mail.send(id, message, i18n`Откройте меню клана с помощью /clan`) - - clan.add(id) + clan.addMember(id) self() }) .setButton2(i18n`Нет, не заслужил`.to(player.lang), () => { @@ -236,7 +153,7 @@ function clanJoinRequests(player: Player, clan: Clan, back?: VoidFunction) { .back(back) .show(player) } -function clanInvites(player: Player, clan: Clan, back?: VoidFunction) { +export function clanInvites(player: Player, clan: Clan, back?: VoidFunction) { new ArrayForm(i18n`Приглашения в клан '${clan.name}'`, clan.invites) .addCustomButtonBeforeArray(form => form.button(i18n.accent`Новое приглашение`.to(player.lang), BUTTON['+'], () => @@ -271,53 +188,94 @@ function inviteToClan(player: Player, clan: Clan, back?: VoidFunction) { back?.() }) } - function clanMembers(player: Player, clan: Clan, back?: VoidFunction) { const self = () => clanMembers(player, clan, back) new ArrayForm(i18n`Участники клана`, clan.members) .back(back) - .button(id => { - const memberName = getFullname(id) - return [memberName, () => clanMember({ clan, member: id, memberName }).show(player, self)] + .button(member => { + const memberName = i18n`${getFullname(member.id)}\n${Clan.roleToString(member.role)}` + return [memberName, () => clanMember({ clan, member, memberName }).show(player, self)] }) .show(player) } -const clanMember = form.params<{ clan: Clan; member: string; memberName: string }>( + +const clanMember = form.params<{ clan: Clan; member: ClanMember; memberName: Text }>( (f, { player, self, params: { clan, member, memberName } }) => { f.title(i18n`Участник`) + f.body(memberName) const isOwner = clan.isOwner(player.id) - const isMemberOwner = clan.isOwner(member) + const isHelper = clan.isHelper(player.id) + const isSelf = member.id === player.id - if (isOwner) { - f.button(isMemberOwner ? i18n`Понизить до участника` : i18n`Повысить до владельца`, () => { - clan.setRole(member, isMemberOwner ? 'member' : 'owner') - player.success(i18n`Роль участника клана ${memberName} сменена успешно.`) - Mail.send( - member, - isMemberOwner ? i18n.nocolor`Вы были понижены до участника` : i18n.nocolor`Вы были повышены до владельца`, - i18n`В клане '${clan.name}'`, - ) - self() - }).button(i18n.error`Выгнать`, () => { - new ModalForm(i18n`Выгнать участника '${memberName}'`.to(player.lang)) - .addTextField( - i18n`Причина`.to(player.lang), - i18n`Ничего не произойдет, если вы не укажете причину`.to(player.lang), - ) - .show(player, (_, reason) => { - if (reason) { - clan.remove(member) + if (isSelf) { + if (isOwner) { + const otherOwners = clan.owners.length > 1 + f.ask((otherOwners ? i18n.error : i18n.disabled)`Отказаться от владения`, i18n`Да`, () => { + if (!otherOwners) + return player.fail( + i18n.error`Вы не можете отказаться от владения клана являясь единственным его владельцем`, + ) + clan.setMemberRole(player.id, ClanRole.Member) + }) + + f.button((otherOwners ? i18n.error : i18n.disabled)`Покинуть клан`, () => { + if (!otherOwners) + return player.fail(i18n.error`Вы единственный владелец. Кнопка удаления клана находится в меню клана снизу`) + clan.remove(player.id) + }) + } + } else { + if (isOwner) { + const prevRole = Clan.roleToString(member.role) + f.button( + i18n`Сменить роль`, + roleSelect({ + member, + onSelect(role) { + clan.setMemberRole(member.id, role) + + const changeString = i18n`${prevRole} -> ${Clan.roleToString(role)}` + player.success(i18n`Роль участника клана ${memberName} сменена успешно: ${changeString}.`) Mail.send( - member, - i18n.nocolor`Вы выгнаны из клана '${clan.name}'`, - i18n`Вы были выгнаны из клана игроком '${player.name}'. Причина: ${reason}`, + member.id, + i18n.nocolor`Роль в клане ${changeString}`, + i18n`В клане '${clan.name}', сменена игроком ${getFullname(player)}`, ) - player.success(i18n`Участник ${memberName} успешно выгнан из клана ${clan.name}`) - } else player.info(i18n`Причина не была указана, участник остался в клане`) - self() - }) - }) + self() + }, + }), + ) + } + if (member.role === ClanRole.Owner ? isOwner : isOwner || isHelper) { + f.button(i18n.error`Выгнать`, () => { + new ModalForm(i18n`Выгнать участника '${memberName}'`.to(player.lang)) + .addTextField(i18n`Причина`.to(player.lang), i18n`Причина обязательна`.to(player.lang)) + .show(player, (_, reason) => { + if (reason) { + clan.remove(member.id) + Mail.send( + member.id, + i18n.nocolor`Вы выгнаны из клана '${clan.name}'`, + i18n`Вы были выгнаны из клана игроком '${player.name}'. Причина: ${reason}`, + ) + player.success(i18n`Участник ${memberName} успешно выгнан из клана ${clan.name}`) + } else player.fail(i18n`Причина не была указана, участник остался в клане`) + self() + }) + }) + } + } + }, +) + +const roleSelect = form.params<{ member: ClanMember; onSelect: (role: ClanRole) => void }>( + (f, { params: { member, onSelect } }) => { + f.title(i18n`Сменить роль ${Player.nameOrUnknown(member.id)}`) + + for (const role of Object.values(ClanRole)) { + const color = member.role === role ? i18n.accent : i18n + f.button(color`${Clan.roleToString(role)}`, onSelect.bind(undefined, role)) } }, ) diff --git a/src/lib/command/argument-types.ts b/src/lib/command/argument-types.ts index 05ac66e5..92dadc08 100644 --- a/src/lib/command/argument-types.ts +++ b/src/lib/command/argument-types.ts @@ -1,3 +1,5 @@ +import { CustomCommandParamType, CustomCommandRegistry } from '@minecraft/server' + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters export abstract class IArgumentType { /** The return type */ @@ -20,6 +22,12 @@ export abstract class IArgumentType { */ abstract typeName: string + abstract ctype: CustomCommandParamType + + register(ctx: CustomCommandRegistry, namespace: string) { + return true + } + /** The name this argument is */ abstract name: string @@ -46,6 +54,8 @@ export class LiteralArgumentType extends IArgumentTyp super() } + ctype: CustomCommandParamType = CustomCommandParamType.String + type = null typeName = 'literal' @@ -69,6 +79,8 @@ export class StringArgumentType extends IArgumentType super() } + ctype = CustomCommandParamType.String + type = 'string' typeName = '§3string' @@ -89,6 +101,8 @@ export class IntegerArgumentType extends IArgumentTyp super() } + ctype: CustomCommandParamType = CustomCommandParamType.Integer + type = 1 typeName = 'int' @@ -110,6 +124,8 @@ export class LocationArgumentType extends IArgumentTy super() } + ctype: CustomCommandParamType = CustomCommandParamType.Location + type = { x: 0, y: 0, z: 0 } as Vector3 typeName = 'location' @@ -132,6 +148,8 @@ export class BooleanArgumentType extends IArgumentTyp super() } + ctype: CustomCommandParamType = CustomCommandParamType.Boolean + type = false as boolean typeName = 'boolean' @@ -154,6 +172,14 @@ export class ArrayArgumentType { + const avaibleCommands = Command.commands.filter(e => e.sys.requires(ctx.player)) + const cmds = Math.max(1, commandsOnPage ?? 15) + const maxPages = Math.ceil(avaibleCommands.length / cmds) + const page = Math.min(Math.max(inputPage ?? 1, 1), maxPages) + const path = avaibleCommands.slice(page * cmds - cmds, page * cmds) + + const cv = colors[getRole(ctx.player.id)] + + ctx.reply(noI18n.nocolor`${cv}─═─═─═─═─═ §r${page}/${maxPages} ${cv}═─═─═─═─═─═─`) + + for (const command of path) { + const q = '§f.' + + const c = i18n.nocolor`${cv}§r ${q}${command.sys.name} §o§7- ${ + command.sys.description ? command.sys.description.to(ctx.player.lang) : i18n`Пусто` //§r + }`.to(ctx.player.lang) + + ctx.reply(c) + } + ctx.reply(i18n.nocolor`${cv}─═─═─═§f Доступно: ${avaibleCommands.length}/${Command.commands.length} ${cv}═─═─═─═─`) + }) + +function helpForCommand(player: Player, commandName: string) { + const cmd = Command.commands.find(e => e.sys.name == commandName || e.sys.aliases.includes(commandName)) + + if (!cmd) return commandNotFound(player, commandName) + + if (!cmd.sys.requires(player)) return commandNoPermissions(player, cmd) + + const d = cmd.sys + const aliases = d.aliases.length > 0 ? i18n` (также ${d.aliases.join(', ')})` : '' + const overview = i18n.nocolor` §fКоманда §6.${d.name}${aliases}§7§o - ${d.description}` + + player.tell(' ') + player.tell(overview) + player.tell(' ') + + let child = false + for (const subcommand of Command.getHelp(player.lang, cmd)) { + child = true + player.tell(`§7 §f.${subcommand}`) + } + if (child) player.tell(' ') + return +} + +Command.getHelpForCommand = (command, ctx) => helpForCommand(ctx.player, command.sys.name) +help.string('commandName').executes((ctx, command) => helpForCommand(ctx.player, command)) + +new CmdLet('help').setDescription(i18n`Выводит справку о команде`).executes(ctx => { + helpForCommand(ctx.player, ctx.command.sys.name) + return 'stop' +}) + +const colors: Record = Object.fromEntries( + Object.entriesStringKeys(ROLES).map(([role, display]) => [role, display.to(defaultLang).slice(0, 2)]), +) diff --git a/src/lib/command/index.ts b/src/lib/command/index.ts index fcbf235c..b3cba9e1 100644 --- a/src/lib/command/index.ts +++ b/src/lib/command/index.ts @@ -1,11 +1,24 @@ /* eslint-disable @typescript-eslint/unified-signatures */ -import { ChatSendAfterEvent, Player, system, world } from '@minecraft/server' -import { defaultLang, Language } from 'lib/assets/lang' +import { + ChatSendAfterEvent, + ChatSendBeforeEvent, + CommandPermissionLevel, + CustomCommandOrigin, + CustomCommandParameter, + CustomCommandStatus, + Player, + PlayerPermissionLevel, + system, + world, +} from '@minecraft/server' +import { consoleLang, defaultLang, Language } from 'lib/assets/lang' +import { Message } from 'lib/i18n/message' import { i18n, noI18n } from 'lib/i18n/text' -import { stringifyError } from 'lib/util' +import { stringifyError, util } from 'lib/util' import { stringifySymbol } from 'lib/utils/inspect' import { createLogger } from 'lib/utils/logger' -import { is } from '../roles' +import { Vec } from 'lib/vector' +import { getRole, is, ROLES } from '../roles' import { ArrayArgumentType, BooleanArgumentType, @@ -31,6 +44,8 @@ type ArgReturn = Command< type CommandCallback = (ctx: CommandContext, ...args: any[]) => void export class Command void> { + static loaded = false + static prefixes = ['.', '-'] static isCommand(message: string) { @@ -43,14 +58,14 @@ export class Command static logger = createLogger('Command') - static chatListener(event: ChatSendAfterEvent) { + private static chatListener(event: ChatSendAfterEvent) { if (!this.isCommand(event.message)) return this.chatSendListener(event) const parsed = parseCommand(event.message, 1) if (!parsed) { this.logger.player(event.sender).error`Unable to parse: ${event.message}` return event.sender.fail(noI18n`Failed to parse command`) - } else this.logger.player(event.sender).info`Command ${event.message}` + } else if (event.sender.name !== 'server') this.logger.player(event.sender).info`Command ${event.message}` const { cmd, args, input } = parsed @@ -72,8 +87,8 @@ export class Command v => v.sys.type.matches(args[i]!).success || (!args[i] && v.sys.type.optional), ) if (!child && !args[i] && start.sys.callback) return 'success' - if (!child) return commandSyntaxFail(event.sender, command, args, i), 'fail' - if (!child.sys.requires(event.sender)) return commandNoPermissions(event.sender, child), 'fail' + if (!child) return (commandSyntaxFail(event.sender, command, args, i), 'fail') + if (!child.sys.requires(event.sender)) return (commandNoPermissions(event.sender, child), 'fail') childs.push(child) return getChilds(child, i + 1) } @@ -85,6 +100,21 @@ export class Command sendCallback(args, childs, event, command, input) } + // TODO Better registration, global event etc + static registerChatListener(chat: (arg0: ChatSendBeforeEvent) => void) { + this.chatSendListener = chat + world.beforeEvents.chatSend.subscribe(event => { + event.cancel = true + system.delay(() => { + Command.chatListener(event) + }) + }) + } + + static register(namespace: string) { + register(namespace) + } + /** An array of all active commands */ static commands: Command[] = [] @@ -93,7 +123,7 @@ export class Command } [stringifySymbol]() { - return `§f${Command.prefixes[0]}${this.getFullName()}` + return `§f/${this.getFullName()}` } private getFullName(name = ''): string { @@ -106,7 +136,7 @@ export class Command for (const command of this.commands) { if (!command.sys.parent && command.sys.name === name) { Command.logger - .warn`Duplicate command name: ${name} at\n${stringifyError.stack.get(2)}${command.stack ? i18n.warn`And:\n${command.stack}` : ''}` + .warn`Duplicate command name: ${name} at\n${stringifyError.stack.get(0)}${command.stack ? i18n.warn`And:\n${command.stack}` : ''}` return } } @@ -115,6 +145,7 @@ export class Command private stack: string sys = { + admin: false, /** * The name of the command * @@ -140,14 +171,16 @@ export class Command * @param player This will return the player that uses this command * @returns If this player has permission to use this command */ - requires: (player: Player) => is(player.id, 'admin'), + requires: ((player: Player) => is(player.id, 'admin')) as ((player: Player) => boolean) & { + onFail?: PlayerCallback + }, /** * Minimal role required to run this command. * * This is an alias to `requires: (p) => is(player, role)` */ - role: 'admin' as Role, + role: 'admin' as Role | undefined, /** * Other names that can call this command * @@ -184,9 +217,13 @@ export class Command * @param {string} name - Name of the new command */ constructor(name: string, type?: IArgumentType, depth = 0, parent: Command | null = null) { - this.stack = stringifyError.stack.get(2) + this.stack = stringifyError.stack.get(0) if (!parent && !__VITEST__) Command.checkIsUnique(name) + if (Command.loaded) { + Command.logger.warn('Commands are already loaded, tried registering ', name, new Error().stack) + } + this.sys.name = name if (type) this.sys.type = type @@ -237,7 +274,7 @@ export class Command * @example * "admin" */ - setPermissions(arg?: Role | ((player: Player) => boolean) | 'everybody'): this + setPermissions(arg?: Role | (((player: Player) => boolean) & { onFail?: PlayerCallback }) | 'everybody'): this /** * Sets minimal role that allows player to execute the command. Default allowed role is admin. @@ -270,11 +307,16 @@ export class Command if (arg === 'everybody') { // Everybody this.sys.requires = () => true + this.sys.role = 'member' + this.sys.admin = false } else if (typeof arg === 'function') { // Custom permissions function this.sys.requires = arg + this.sys.admin = false + delete this.sys.role } else { // Role + this.sys.admin = arg === 'creator' || arg === 'techAdmin' this.sys.role = arg this.sys.requires = p => is(p.id, arg) } @@ -420,9 +462,181 @@ declare global { globalThis.Command = Command -world.beforeEvents.chatSend.subscribe(event => { - event.cancel = true - system.delay(() => { - Command.chatListener(event) +function register(namespace: string) { + system.beforeEvents.startup.subscribe(load => { + for (const command of Command.commands) { + if (command.sys.depth !== 0) continue + // Only simple commands are supported rn + + try { + const mandatoryParameters: CustomCommandParameter[] = [] + const optionalParameters: CustomCommandParameter[] = [] + + let callback = command.sys.callback + const locationIndexes: number[] = [] + let i = 0 + + let nowOptional = false + + function collectParams(command: Command) { + const child = command.sys.children[0] + if (!child || child instanceof LiteralArgumentType) return + + // Location is split + if (child.sys.type.name.endsWith('*')) return collectParams(child) + + child.sys.type.register(load.customCommandRegistry, namespace) + const param: CustomCommandParameter = { name: child.sys.type.name, type: child.sys.type.ctype } + if (child.sys.type.optional) { + nowOptional = true + optionalParameters.push(param) + } else { + if (nowOptional) + throw new Error('Mandatory param cannot come after optional in command ' + command.sys.name) + mandatoryParameters.push(param) + } + + if (child.sys.type instanceof LocationArgumentType) locationIndexes.push(i) + i++ + + if (child.sys.callback) callback = child.sys.callback + + collectParams(child) + } + collectParams(command) + + load.customCommandRegistry.registerCommand( + { + name: namespace + ':' + command.sys.name, + permissionLevel: command.sys.admin ? CommandPermissionLevel.GameDirectors : CommandPermissionLevel.Any, + description: command.sys.description.to(defaultLang), + mandatoryParameters, + optionalParameters, + }, + (ctx, ...args) => { + if (!callback) { + return { + status: CustomCommandStatus.Failure, + message: 'Команда не готова', + } + } + + const isServer = !(ctx.sourceEntity instanceof Player) + const output: CommandOutputBuffer = { output: '', isSync: isServer } + const player: Player = + ctx.sourceEntity instanceof Player ? ctx.sourceEntity : createPlayerProxy(ctx, command, output) + + const allowed = command.sys.requires(player) + if (!allowed) { + if (command.sys.requires.onFail) { + command.sys.requires.onFail(player) + } else { + if (command.sys.role) { + player.fail( + i18n.error`Команда доступна только начиная с роли ${ROLES[command.sys.role]}. Ваша роль: ${ROLES[getRole(player.id)]}`, + ) + } else { + player.fail(i18n.error`Команда недоступна`) + } + } + + return { status: CustomCommandStatus.Failure, message: output.output || undefined } + } + + for (const i of locationIndexes) { + const arg: unknown = args[i] + if (Vec.isVec(arg)) args[i] = Vec.floor(arg) + } + + if (isServer) { + execCmd(player, command, callback, args, output) + } else { + system.delay(() => { + if (!callback) throw new Error('no callback') + execCmd(player, command, callback, args, output) + }) + } + return { status: CustomCommandStatus.Success, message: output.output || undefined } + }, + ) + } catch (e) { + Command.logger.error('Failed to load command', command.sys.name, e) + } + } + + Command.loaded = true }) -}) +} + +interface CommandOutputBuffer { + output: string + isSync: boolean +} + +function createPlayerProxy( + ctx: CustomCommandOrigin, + command: Command<(ctx: CommandContext) => void>, + output: CommandOutputBuffer, +): Player { + const sendMessage = (...messages: unknown[]) => { + const translated = messages.map(e => (e instanceof Message ? e.to(consoleLang) : e)) + const message = util.format([...translated]) + if (output.isSync) { + output.output += `${message}\n` + } else { + Command.logger.info('server', 'async', `/${command.sys.name}`, message) + } + } + return new Proxy( + { + dimension: ctx.sourceEntity?.dimension ?? ctx.sourceBlock?.dimension ?? world.overworld, + + commandPermissionLevel: CommandPermissionLevel.Owner, + playerPermissionLevel: PlayerPermissionLevel.Custom, + isValid: true, + fail: sendMessage, + success: sendMessage, + info: sendMessage, + warn: sendMessage, + sendMessage: sendMessage, + tell: sendMessage, + playSound: () => void 0, + id: 'server', + name: 'server', + } as Partial, + { + get(target, p, receiver) { + if (!(p in target)) { + throw new Error( + `Command is not supported to be run by server, tried to use ${String(p)}, only ${Object.keys(target).join(' | ')} are supported`, + ) + } + + return Reflect.get(target, p, receiver) as unknown + }, + }, + ) as unknown as Player +} + +function execCmd( + player: Player, + command: Command<(ctx: CommandContext) => void>, + callback: (ctx: CommandContext, ...args: unknown[]) => void | Promise, + args: unknown[], + output: CommandOutputBuffer, +) { + try { + Command.logger.player(player).info(command.sys.name, ...args) + const result = callback(new CommandContext({ sender: player, message: '', targets: [] }, [], command, ''), ...args) + if (result && result instanceof Promise) { + output.isSync = false + result.catch((e: unknown) => { + Command.logger.player(player).error(command.sys.name, '[async]', ...args, e) + player.fail('Ошибка в асинхронной команде ' + String(e)) + }) + } + } catch (e) { + Command.logger.player(player).error(command.sys.name, ...args, e) + player.fail('Ошибка в команде ' + String(e)) + } +} diff --git a/src/lib/command/utils.ts b/src/lib/command/utils.ts index e4f93ced..6753707a 100644 --- a/src/lib/command/utils.ts +++ b/src/lib/command/utils.ts @@ -1,7 +1,6 @@ import { ChatSendAfterEvent, Player } from '@minecraft/server' import { Sounds } from 'lib/assets/custom-sounds' import { developersAreWarned } from 'lib/assets/text' -import { intlListFormat } from 'lib/i18n/intl' import { i18n, noI18n } from 'lib/i18n/text' import { ROLES } from 'lib/roles' import { inaccurateSearch } from '../utils/search' @@ -80,12 +79,10 @@ export function suggest(player: Pick, input: string, op if (!search[0] || search[0][1] < settings.minMatchTriggerValue) return player.tell( - i18n.error`Вы имели ввиду ${intlListFormat( - i18n.error.style, - player.lang, - 'or', - search.slice(0, settings.maxSuggestionsCount).map(e => noI18n.nocolor`${e[0]} (${~~(e[1] * 100)}%%)`), - )}?`, + i18n.error`Вы имели ввиду ${search + .slice(0, settings.maxSuggestionsCount) + .map(e => noI18n.nocolor`${e[0]} (${~~(e[1] * 100)}%%)`) + .join(', ')}?`, ) } diff --git a/src/lib/cooldown.ts b/src/lib/cooldown.ts index fa8f30ca..657cb477 100644 --- a/src/lib/cooldown.ts +++ b/src/lib/cooldown.ts @@ -9,6 +9,14 @@ export class Cooldown { static defaultDb = table>('cooldowns', () => ({})) + static getDb(cd: Cooldown) { + return cd.db + } + + static getTime(cd: Cooldown) { + return cd.time + } + /** * Create class for manage player cooldowns * @@ -50,8 +58,10 @@ export class Cooldown { const id = player instanceof Player ? player.id : player const elapsed = this.getElapsed(id) if (elapsed) { - if (this.tell && player instanceof Player) - player.fail(i18n.error`Не так быстро! Попробуй через ${i18n.time(this.time - elapsed)}`) + if (this.tell && player instanceof Player) { + const after = this.time - elapsed + player.fail(i18n.error`Не так быстро! Попробуй через ${after > 1000 ? i18n.hhmmss(after) : i18n`${after}мсек`}`) + } return false } else { diff --git a/src/lib/cooldownreset.ts b/src/lib/cooldownreset.ts new file mode 100644 index 00000000..ba1d18fa --- /dev/null +++ b/src/lib/cooldownreset.ts @@ -0,0 +1,63 @@ +import { Cooldown } from 'lib/cooldown' +import { form } from 'lib/form/new' +import { getFullname } from 'lib/get-fullname' +import { i18n } from 'lib/i18n/text' + +interface CooldownController { + list(): Record + reset(id: string): void +} + +// After compilation the initialization of this variable is placed lower then the hoisted call of the function below for some reason +let cds: { name: string; cd: CooldownController }[] | undefined + +/** + * Use cooldown controller when the cooldown IS NOT AN INSTANCE OF COOLDOWN, e.g. its some custom data structure + */ +export function registerResettableCooldown(name: string, cd: CooldownController | Cooldown) { + cds ??= [] + + if (cd instanceof Cooldown) { + cds.push({ + name, + cd: { + list() { + return Object.map(Cooldown.getDb(cd) as Record, (k, v) => + Cooldown.getTime(cd) + v < Date.now() ? false : [k, Cooldown.getTime(cd) + v], + ) + }, + reset(id) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete Cooldown.getDb(cd)[id] + }, + }, + }) + } else { + cds.push({ name, cd }) + } +} + +const cdsform = form(f => { + f.title('Кулдауны') + for (const cd of cds ?? []) { + f.button(cdform(cd)) + } +}) + +const cdform = form.params<{ cd: CooldownController; name: string }>((f, { params, self }) => { + const list = params.cd.list() + f.title(i18n.join`${params.name}`.size(Object.keys(list).length)) + for (const [id, time] of Object.entries(list)) { + const elapsed = time - Date.now() + if (elapsed < 0) continue + f.button(i18n`${getFullname(id)}\n${i18n.hhmmss(elapsed)}`, () => { + params.cd.reset(id) + self() + }) + } +}) + +new Command('cooldownreset') + .setPermissions('techAdmin') + .setDescription('Сбрасывает разные кулдауны') + .executes(cdsform.command) diff --git a/src/lib/cutscene/cutscene.ts b/src/lib/cutscene/cutscene.ts index c68edeb8..f74d5e13 100644 --- a/src/lib/cutscene/cutscene.ts +++ b/src/lib/cutscene/cutscene.ts @@ -6,7 +6,7 @@ import { table } from 'lib/database/abstract' import { noI18n } from 'lib/i18n/text' import { Compass } from 'lib/rpg/menu' import { Sidebar } from 'lib/sidebar' -import { restorePlayerCamera } from 'lib/utils/game' +import { onLoad, restorePlayerCamera } from 'lib/utils/game' import { WeakPlayerMap } from 'lib/weak-player-storage' /** @@ -64,7 +64,9 @@ export class Cutscene { ) { Cutscene.all.set(id, this) - this.sections = Cutscene.db.get(this.id).slice() + onLoad(() => { + this.sections = Cutscene.db.get(this.id).slice() + }) } private get defaultSection() { @@ -295,6 +297,6 @@ function bezier>(vectors: [T, T, T, T], axis: k function getVector5(player: Player): Vector5 { const { x: rx, y: ry } = player.getRotation() - const { x, y, z } = Vec.floor(player.getHeadLocation()) + const { x, y, z } = player.getHeadLocation() return { x, y, z, rx: Math.floor(rx), ry: Math.floor(ry) } } diff --git a/src/lib/cutscene/edit.ts b/src/lib/cutscene/edit.ts index 6fa7ca7a..110e580d 100644 --- a/src/lib/cutscene/edit.ts +++ b/src/lib/cutscene/edit.ts @@ -9,219 +9,227 @@ import { Cooldown } from 'lib/cooldown' import { i18n } from 'lib/i18n/text' import { Temporary } from 'lib/temporary' import { util } from 'lib/util' -import { isLocationError } from 'lib/utils/game' +import { isLocationError, onLoad } from 'lib/utils/game' import { Cutscene } from './cutscene' import { cutscene as cusceneCommand } from './menu' -/** List of items that controls the editing process */ -const controls: Record< - string, - [slot: number, item: ItemStack, onUse: (player: Player, cutscene: Cutscene, temp: Temporary) => void] -> = { - create: [ - 3, - new ItemStack(Items.WeTool).setInfo('§r§6> §fСоздать точку', 'используй предмет, чтобы создать точку катсцены.'), - (player, cutscene) => { - if (!cutscene.sections[0]) cutscene.withNewSection(cutscene.sections, {}) - - cutscene.withNewPoint(player, { sections: cutscene.sections, warn: true }) - player.info( - `Точка добавлена. Точек в секции: §f${cutscene.sections[cutscene.sections.length - 1]?.points.length}`, - ) - }, - ], - createSection: [ - 4, - new ItemStack(MinecraftItemTypes.ChainCommandBlock).setInfo( - '§r§3> §fСоздать секцию', - 'используй предмет, чтобы создать секцию катсцены (множество точек).', - ), - (player, cutscene) => { - cutscene.withNewSection(cutscene.sections, {}) - player.info(`Секция добавлена. Секций всего: §f${cutscene.sections.length}`) - }, - ], - cancel: [ - 7, - new ItemStack(MinecraftItemTypes.Barrier).setInfo( - '§r§c> §fОтмена', - 'используйте предмет, чтобы отменить редактироание катсцены и вернуть все в исходное состояние.', - ), - (player, cutscene, temp) => { - // Restore bakcup - const backup = EditingCutscene.get(player.id) - if (backup) cutscene.sections = backup.cutsceneSectionsBackup - - temp.cleanup() - player.success('Успешно отменено!') - }, - ], - saveAndExit: [ - 8, - new ItemStack(MinecraftItemTypes.HoneyBottle).setInfo( - '§r§6> §fСохранить и выйти', - 'используй предмет, чтобы выйти из меню катсцены.', - ), - (player, cutscene, temp) => { - temp.cleanup() - player.success(i18n`Сохранено. Проверить: ${cusceneCommand}§f play ${cutscene.id}`) - }, - ], +export const cutsceneEdit = { + /** + * Checks if the cutscene location is valid, then teleports the player to that location and backs up player inventory + * and cutscene data. + * + * @param player - The player who is editing the cutscene. + * @param cutscene - The cutscene cene to be edited + */ + editCatcutscene: (player: Player, cutscene: Cutscene): void => { + throw new Error('Not loaded!') + }, } -/** - * Checks if the cutscene location is valid, then teleports the player to that location and backs up player inventory - * and cutscene data. - * - * @param player - The player who is editing the cutscene. - * @param cutscene - The cutscene cene to be edited - */ -export function editCatcutscene(player: Player, cutscene: Cutscene) { - backupPlayerInventoryAndCutscene(player, cutscene) - - new Temporary(({ world, system, temporary }) => { - system.runInterval( - () => { - for (const section of cutscene.sections) { - if (!section) continue - - for (const point of section.points) particle(point, blueParticle) - } - }, - 'cutscene section edges particles', - 30, - ) - - const controller = { cancel: false } - util.catch(async function visualize() { - while (!temporary.cleaned) { - await system.sleep(10) - - const sections = cutscene.withNewPoint(player) - if (!sections) return - - await cutscene.forEachPoint( - point => { - if (!Vec.isValid(point)) return - particle(point, whiteParticle) - }, - { controller, sections, intervalTime: 1 }, +onLoad(() => { + /** List of items that controls the editing process */ + const controls: Record< + string, + [slot: number, item: ItemStack, onUse: (player: Player, cutscene: Cutscene, temp: Temporary) => void] + > = { + create: [ + 3, + new ItemStack(Items.WeTool).setInfo('§r§6> §fСоздать точку', 'используй предмет, чтобы создать точку катсцены.'), + (player, cutscene) => { + if (!cutscene.sections[0]) cutscene.withNewSection(cutscene.sections, {}) + + cutscene.withNewPoint(player, { sections: cutscene.sections, warn: true }) + player.info( + `Точка добавлена. Точек в секции: §f${cutscene.sections[cutscene.sections.length - 1]?.points.length}`, ) - } - }) + }, + ], + createSection: [ + 4, + new ItemStack(MinecraftItemTypes.ChainCommandBlock).setInfo( + '§r§3> §fСоздать секцию', + 'используй предмет, чтобы создать секцию катсцены (множество точек).', + ), + (player, cutscene) => { + cutscene.withNewSection(cutscene.sections, {}) + player.info(`Секция добавлена. Создайте точку внутри секции. Секций всего: §f${cutscene.sections.length}`) + }, + ], + cancel: [ + 7, + new ItemStack(MinecraftItemTypes.Barrier).setInfo( + '§r§c> §fОтмена', + 'используйте предмет, чтобы отменить редактироание катсцены и вернуть все в исходное состояние.', + ), + (player, cutscene, temp) => { + // Restore bakcup + const backup = EditingCutscene.get(player.id) + if (backup) cutscene.sections = backup.cutsceneSectionsBackup + + temp.cleanup() + player.success('Успешно отменено!') + }, + ], + saveAndExit: [ + 8, + new ItemStack(MinecraftItemTypes.HoneyBottle).setInfo( + '§r§6> §fСохранить и выйти', + 'используй предмет, чтобы выйти из меню катсцены.', + ), + (player, cutscene, temp) => { + temp.cleanup() + player.success(i18n`Сохранено. Проверить: ${cusceneCommand}§f play ${cutscene.id}`) + }, + ], + } + + cutsceneEdit.editCatcutscene = (player, cutscene) => { + backupPlayerInventoryAndCutscene(player, cutscene) - const cooldown = new Cooldown(1000) + new Temporary(({ world, system, temporary }) => { + system.runInterval( + () => { + for (const section of cutscene.sections) { + if (!section) continue - world.beforeEvents.itemUse.subscribe(event => { - if (event.source.id !== player.id) return - if (!cooldown.isExpired(player)) return + for (const point of section.points) particle(point, blueParticle) + } + }, + 'cutscene section edges particles', + 30, + ) - for (const [, control, onUse] of Object.values(controls)) { - if (control.is(event.itemStack)) { - event.cancel = true - system.delay(() => onUse(player, cutscene, temporary)) + const controller = { cancel: false } + util.catch(async function visualize() { + while (!temporary.cleaned) { + await system.sleep(10) + + const sections = cutscene.withNewPoint(player) + if (!sections) return + + await cutscene.forEachPoint( + point => { + if (!Vec.isValid(point)) return + particle(point, whiteParticle) + }, + { controller, sections, intervalTime: 1 }, + ) } - } - }) + }) - world.beforeEvents.playerLeave.subscribe(event => { - if (event.player.id === player.id) system.delay(() => temporary.cleanup()) - }) + const cooldown = new Cooldown(1000) - function particle(point: Vector3 | undefined, vars: MolangVariableMap) { - if (!point) return - try { - player.dimension.spawnParticle('minecraft:wax_particle', point, vars) - } catch (e) { - if (isLocationError(e)) return - if (e instanceof TypeError && e.message.includes('Native optional type conversion')) return + world.beforeEvents.itemUse.subscribe(event => { + if (event.source.id !== player.id) return + if (!cooldown.isExpired(player)) return - console.error(e) - } - } + for (const [, control, onUse] of Object.values(controls)) { + if (control.is(event.itemStack)) { + event.cancel = true + system.delay(() => onUse(player, cutscene, temporary)) + } + } + }) - return { - cleanup() { - const editingPlayer = EditingCutscene.get(player.id) - if (!editingPlayer) return + world.beforeEvents.playerLeave.subscribe(event => { + if (event.player.id === player.id) system.delay(() => temporary.cleanup()) + }) - const { hotbarSlots, position } = editingPlayer + function particle(point: Vector3 | undefined, vars: MolangVariableMap) { + if (!point) return + try { + player.dimension.spawnParticle('minecraft:wax_particle', point, vars) + } catch (e) { + if (isLocationError(e)) return + if (e instanceof TypeError && e.message.includes('Native optional type conversion')) return - if (player.isValid) { - forEachHotbarSlot(player, (i, container) => container.setItem(i, hotbarSlots[i])) - player.teleport(position) + console.error(e) } + } - EditingCutscene.delete(player.id) - cutscene.save() - }, - } - }) -} + return { + cleanup() { + const editingPlayer = EditingCutscene.get(player.id) + if (!editingPlayer) return -const blueParticle = new MolangVariableMap() -blueParticle.setColorRGBA('color', { - red: 0.5, - green: 0.5, - blue: 1, - alpha: 0, -}) + const { hotbarSlots, position } = editingPlayer -const whiteParticle = new MolangVariableMap() -whiteParticle.setColorRGBA('color', { - red: 1, - green: 1, - blue: 1, - alpha: 0, -}) + if (player.isValid) { + forEachHotbarSlot(player, (i, container) => container.setItem(i, hotbarSlots[i])) + player.teleport(position) + } -interface EditingCutscenePlayer { - hotbarSlots: (ItemStack | undefined)[] - position: Vector3 - cutsceneSectionsBackup: Cutscene['sections'] -} + EditingCutscene.delete(player.id) + cutscene.save() + }, + } + }) + } -/** Map of player id to player editing cutscene */ -const EditingCutscene = new Map() + const blueParticle = new MolangVariableMap() + blueParticle.setColorRGBA('color', { + red: 0.5, + green: 0.5, + blue: 1, + alpha: 0, + }) -function backupPlayerInventoryAndCutscene(player: Player, cutscene: Cutscene) { - EditingCutscene.set(player.id, { - hotbarSlots: backupPlayerInventory(player), - position: Vec.floor(player.location), - cutsceneSectionsBackup: cutscene.sections.slice(), + const whiteParticle = new MolangVariableMap() + whiteParticle.setColorRGBA('color', { + red: 1, + green: 1, + blue: 1, + alpha: 0, }) - cutscene.sections = [] -} + interface EditingCutscenePlayer { + hotbarSlots: (ItemStack | undefined)[] + position: Vector3 + cutsceneSectionsBackup: Cutscene['sections'] + } -function backupPlayerInventory(player: Player) { - const hotbarSlots: EditingCutscenePlayer['hotbarSlots'] = [] - const container = forEachHotbarSlot(player, (i, container) => { - hotbarSlots[i] = container.getItem(i) - container.setItem(i, undefined) - }) + /** Map of player id to player editing cutscene */ + const EditingCutscene = new Map() - for (const [slot, item] of Object.values(controls)) { - container.setItem(slot, item) + function backupPlayerInventoryAndCutscene(player: Player, cutscene: Cutscene) { + EditingCutscene.set(player.id, { + hotbarSlots: backupPlayerInventory(player), + position: Vec.floor(player.location), + cutsceneSectionsBackup: cutscene.sections.slice(), + }) + + cutscene.sections = [] } - return hotbarSlots -} + function backupPlayerInventory(player: Player) { + const hotbarSlots: EditingCutscenePlayer['hotbarSlots'] = [] + const container = forEachHotbarSlot(player, (i, container) => { + hotbarSlots[i] = container.getItem(i) + container.setItem(i, undefined) + }) + + for (const [slot, item] of Object.values(controls)) { + container.setItem(slot, item) + } -/** - * Iterates over the player's hotbar slots and performs a specified action on each slot. - * - * @param player - Target player to get hotbar from - * @param callback - Callback function that will be called for each hotbar slot. It takes two arguments: the index of - * the current slot (from 0 to 8) and the container` object belonging to the player. - */ -function forEachHotbarSlot(player: Player, callback: (i: number, container: Container) => void) { - const { container } = player - if (!container) throw new ReferenceError('Player has no container!') - - for (let i = 0; i < 9; i++) { - callback(i, container) + return hotbarSlots } - return container -} + /** + * Iterates over the player's hotbar slots and performs a specified action on each slot. + * + * @param player - Target player to get hotbar from + * @param callback - Callback function that will be called for each hotbar slot. It takes two arguments: the index of + * the current slot (from 0 to 8) and the container` object belonging to the player. + */ + function forEachHotbarSlot(player: Player, callback: (i: number, container: Container) => void) { + const { container } = player + if (!container) throw new ReferenceError('Player has no container!') + + for (let i = 0; i < 9; i++) { + callback(i, container) + } + + return container + } +}) diff --git a/src/lib/cutscene/menu.ts b/src/lib/cutscene/menu.ts index d74b6b17..f2baf4c8 100644 --- a/src/lib/cutscene/menu.ts +++ b/src/lib/cutscene/menu.ts @@ -1,45 +1,45 @@ import { Player } from '@minecraft/server' +import { PersistentSet } from 'lib/database/persistent-set' import { ActionForm } from 'lib/form/action' import { ArrayForm } from 'lib/form/array' +import { ModalForm } from 'lib/form/modal' import { form } from 'lib/form/new' import { i18n, noI18n } from 'lib/i18n/text' -import { is } from 'lib/roles' import { Cutscene } from './cutscene' -import { editCatcutscene } from './edit' +import { cutsceneEdit } from './edit' new Cutscene('test', 'Test') export const cutscene = new Command('cutscene') .setDescription(i18n`Катсцена`) - .setPermissions('member') + .setPermissions('helper') .executes(ctx => { - if (is(ctx.player.id, 'curator')) selectCutsceneMenu(ctx.player) - else Command.getHelpForCommand(cutscene, ctx) + selectCutsceneMenu(ctx.player) }) -cutscene - .overload('exit') - .setDescription(i18n`Выход из катсцены`) - .executes(ctx => { - const cutscene = Cutscene.getCurrent(ctx.player) - if (!cutscene) return ctx.error(i18n.error`Вы не находитесь в катсцене!`) - - cutscene.exit(ctx.player) - }) - -cutscene - .overload('play') - .setPermissions('techAdmin') - .string('name', false) - .executes((ctx, name) => { - const cutscene = Cutscene.all.get(name) - if (!cutscene) return ctx.error([...Cutscene.all.keys()].join('\n')) +const cutscenes = new PersistentSet('cutscenesIds') - cutscene.play(ctx.player) - }) +cutscenes.onLoad(() => { + for (const c of cutscenes) new Cutscene(c, c) +}) function selectCutsceneMenu(player: Player) { new ArrayForm(noI18n`Катсцены`, [...Cutscene.all.values()]) + .addCustomButtonBeforeArray(f => { + const cutscene = Cutscene.getCurrent(player) + if (cutscene) { + f.button('Выйти из текущей сцены', () => cutscene.exit(player)) + } + + f.button('Добавить', () => { + new ModalForm('Добавить катсцену').addTextField('Название', '').show(player, (ctx, id) => { + if (cutscenes.has(id)) ctx.error('Имя занято') + cutscenes.add(id) + const cutscene = new Cutscene(id, id) + manageCutsceneMenu({ cutscene }).show(player) + }) + }) + }) .description(noI18n`Список доступных для редактирования катсцен:`) .button(cutscene => [cutscene.id, manageCutsceneMenu({ cutscene }).show]) .show(player) @@ -47,10 +47,19 @@ function selectCutsceneMenu(player: Player) { const manageCutsceneMenu = form.params<{ cutscene: Cutscene }>((f, { player, params: { cutscene } }) => { const dots = cutscene.sections.reduce((count, section) => (section ? count + section.points.length : count), 0) + const created = cutscenes.has(cutscene.id) f.title(cutscene.id) .body(noI18n`Секций: ${cutscene.sections.length}\nТочек: ${dots}`) .button(ActionForm.backText, () => selectCutsceneMenu(player)) - .button(noI18n`Редактировать`, () => editCatcutscene(player, cutscene)) + .button(noI18n`Редактировать`, () => cutsceneEdit.editCatcutscene(player, cutscene)) .button(noI18n`Воспроизвести`, () => cutscene.play(player)) + + if (created) { + f.ask(noI18n.error`Удалить`, noI18n.error`Удалить`, () => { + Cutscene.all.delete(cutscene.id) + cutscenes.delete(cutscene.id) + player.success() + }) + } }) diff --git a/src/lib/database/abstract.ts b/src/lib/database/abstract.ts index ce0bbf60..bf3b1e7d 100644 --- a/src/lib/database/abstract.ts +++ b/src/lib/database/abstract.ts @@ -10,10 +10,11 @@ export interface Table { delete(key: Key): boolean size: number keys(): MapIterator - values(): Value[] - valuesImmutable(): MapIterator> + values(): Immutable[] + valuesIterator(): MapIterator> entries(): [Key, Value][] entriesImmutable(): MapIterator<[Key, Immutable]> + onLoad(waiter: (value: void) => void): void } export function table(name: string): Table @@ -72,10 +73,22 @@ export class MemoryTable extends ProxyDataba this.value = new Map(Object.entries(tableData)) as Map } } + + protected loaded = true + + onLoad(waiter: (value: void) => void): void { + waiter() + } } if (__TEST__) { - class TestDatabase extends ProxyDatabase {} + class TestDatabase extends ProxyDatabase implements Table { + protected loaded = true + + onLoad(waiter: (value: void) => void): void { + waiter() + } + } configureDatabase({ tables: TestDatabase.tables, diff --git a/src/modules/commands/db.ts b/src/lib/database/command.ts similarity index 79% rename from src/modules/commands/db.ts rename to src/lib/database/command.ts index 193cef67..ed40b0c5 100644 --- a/src/modules/commands/db.ts +++ b/src/lib/database/command.ts @@ -1,12 +1,12 @@ /* i18n-ignore */ - import { Player, system, world } from '@minecraft/server' -import { ArrayForm, ROLES, getRole, inspect, util } from 'lib' import { UnknownTable, getProvider } from 'lib/database/abstract' import { ActionForm } from 'lib/form/action' +import { ArrayForm } from 'lib/form/array' import { ModalForm } from 'lib/form/modal' import { i18n, noI18n } from 'lib/i18n/text' -import { stringifyBenchmarkResult } from './stringifyBenchmarkReult' +import { ROLES, getRole } from 'lib/roles' +import { inspect } from 'lib/util' new Command('db') .setDescription('Просматривает базу данных') @@ -145,38 +145,3 @@ function changeValue( onChange(newValue, key) }) } - -const cmd = new Command('benchmark') - .setAliases('bench') - .setDescription('Показывает время работы серверных систем') - .setPermissions('techAdmin') - -cmd - .string('type', true) - .boolean('pathes', true) - .boolean('sort', true) - .array('output', ['form', 'chat', 'log'], true) - .executes((ctx, type = 'timers', timerPathes = false, sort = true, output = 'form') => { - if (!(type in util.benchmark.results)) - return ctx.error( - 'Неизвестный тип бенчмарка! Доступные типы: \n §f' + Object.keys(util.benchmark.results).join('\n '), - ) - - const result = stringifyBenchmarkResult({ type: type, timerPathes, sort }) - - switch (output) { - case 'form': { - const show = () => { - new ActionForm('Benchmark', result) - .button('Refresh', null, show) - .button('Exit', null, () => void 0) - .show(ctx.player) - } - return show() - } - case 'chat': - return ctx.reply(result) - case 'log': - return console.log(result) - } - }) diff --git a/src/lib/database/inventory.ts b/src/lib/database/inventory.ts index 02089890..2db643fe 100644 --- a/src/lib/database/inventory.ts +++ b/src/lib/database/inventory.ts @@ -382,6 +382,11 @@ export class InventoryStore { if (!keepInventory) entity.container?.clearAll() } + set(key: string, inventory: Inventory) { + this.inventories.set(key, inventory) + this.requestSave() + } + /** * Checks if key was saved into this store * diff --git a/src/lib/database/item-stack.test.ts b/src/lib/database/item-stack.test.ts index 955008ce..ab2fc8ce 100644 --- a/src/lib/database/item-stack.test.ts +++ b/src/lib/database/item-stack.test.ts @@ -1,7 +1,7 @@ import 'lib/extensions/enviroment' -import { defaultLang } from 'lib/assets/lang' import { ItemLoreSchema } from './item-stack' +import { defaultLang } from 'lib/assets/lang' describe('item stack', () => { it('should create item', () => { @@ -67,26 +67,4 @@ describe('item stack', () => { ] `) }) - - it('should have right types', () => { - const schema = new ItemLoreSchema('test 3') - .property('test', String) - .property('owned', Boolean) - .property('key', String) - - .build() - - const { storage } = schema.create(defaultLang, { - test: '', - owned: true, - key: '', - }) - - // @ts-expect-error Expect this to not allow arbitrary keys - storage.lol - - expectTypeOf(storage.key).toBeString() - expectTypeOf(storage.owned).toBeBoolean() - expectTypeOf(storage.key).toBeString() - }) }) diff --git a/src/lib/database/item-stack.ts b/src/lib/database/item-stack.ts index 27d5326a..9fc41565 100644 --- a/src/lib/database/item-stack.ts +++ b/src/lib/database/item-stack.ts @@ -97,8 +97,6 @@ export class ItemLoreSchema { static loreSchemaId = 'lsid' - public readonly aha!: T - constructor( private properties: Schema, private prepareItem: (lang: Language, itemStack: Item, storage: ParsedSchema) => void, diff --git a/src/lib/database/migrations.ts b/src/lib/database/migrations.ts index 4ec53e26..e57957be 100644 --- a/src/lib/database/migrations.ts +++ b/src/lib/database/migrations.ts @@ -1,14 +1,17 @@ import { system } from '@minecraft/server' +import { onLoad } from 'lib/utils/load-ref' import { table } from './abstract' const database = table('databaseMigrations') export function migration(name: string, migrateFN: VoidFunction) { - if (!database.get(name)) { + onLoad(() => { + if (database.has(name)) return + system.delay(() => { if (database.get(name)) return migrateFN() database.set(name, true) }) - } + }) } diff --git a/src/lib/database/persistent-set.ts b/src/lib/database/persistent-set.ts index 10e0c1f3..ee8238a3 100644 --- a/src/lib/database/persistent-set.ts +++ b/src/lib/database/persistent-set.ts @@ -1,3 +1,4 @@ +import { onLoad } from 'lib/utils/load-ref' import { LongDynamicProperty } from './properties' export class LimitedSet extends Set { @@ -17,9 +18,15 @@ export class PersistentSet extends LimitedSet { protected limit = 1_000, ) { super() - this.load() + for (const key in LimitedSet.prototype) { + ;(this as Record)[key] = () => { + throw new Error(`PersistentSet<${id}> is not yet loaded!`) + } + } } + onLoad = onLoad(() => this.load()).onLoad + private load() { const id = `PersistentSet<${this.id}>:` try { @@ -28,12 +35,20 @@ export class PersistentSet extends LimitedSet { if (!Array.isArray(values)) return console.warn(`${id} Dynamic property is not array, it is:`, values) values.forEach(e => this.add(e as T)) + + for (const [key, value] of Object.entries(LimitedSet.prototype)) (this as Record)[key] = value } catch (error) { console.error(`${id} Failed to load:`, error) + + for (const key in LimitedSet.prototype) { + ;(this as Record)[key] = () => { + throw new Error(`PersistentSet<${id}> Failed to load: ${error}`) + } + } } } - save() { + protected save() { LongDynamicProperty.set(this.id, JSON.stringify([...this])) return this } diff --git a/src/lib/database/player.ts b/src/lib/database/player.ts index fff387df..6025db0e 100644 --- a/src/lib/database/player.ts +++ b/src/lib/database/player.ts @@ -1,7 +1,7 @@ import { Player, world, type PlayerDatabase } from '@minecraft/server' import { expand } from 'lib/extensions/extend' import { i18n } from 'lib/i18n/text' -import { DEFAULT_ROLE } from 'lib/roles' +import { DEFAULT_ROLE } from '../roles/index' import { Table, table } from './abstract' declare module '@minecraft/server' { @@ -48,7 +48,11 @@ declare module '@minecraft/server' { } expand(Player, { - database: table('player', () => ({ role: DEFAULT_ROLE, inv: 'spawn', survival: {} })), + database: table('player', () => ({ + role: DEFAULT_ROLE, + inv: 'spawn', + survival: {}, + })), name(id) { if (!id) return void 0 diff --git a/src/lib/database/properties.ts b/src/lib/database/properties.ts index fc492dbb..d539e1d3 100644 --- a/src/lib/database/properties.ts +++ b/src/lib/database/properties.ts @@ -1,6 +1,7 @@ import { world } from '@minecraft/server' import { ProxyDatabase } from 'lib/database/proxy' -import { i18n, noI18n } from 'lib/i18n/text' +import { noI18n } from 'lib/i18n/text' +import { onLoad } from 'lib/utils/load-ref' import { DatabaseDefaultValue, DatabaseError, UnknownTable, configureDatabase } from './abstract' import { DatabaseUtils } from './utils' @@ -13,14 +14,17 @@ class DynamicPropertyDB extends Pr ) { super(id, defaultValue) if (id in DynamicPropertyDB.tables) throw new DatabaseError(`Table ${this.id} already initialized!`) - this.init() + DynamicPropertyDB.tables[id] = this as UnknownTable } - private init() { + onLoad = onLoad(() => this.load()).onLoad + + private load() { // Init try { this.value = new Map(this.restore(LongDynamicProperty.get(this.id) as Record)) + this.loaded = true } catch (error) { console.error(new DatabaseError(noI18n`Failed to init table '${this.id}': ${error}`)) } @@ -56,7 +60,22 @@ export class LongDynamicProperty { world.setDynamicProperty(propertyId, strings.length) for (const [i, string] of strings.entries()) { - world.setDynamicProperty(`${propertyId}${separator}${i}`, string) + try { + world.setDynamicProperty(`${propertyId}${separator}${i}`, string) + } catch (e) { + console.error( + 'DATABASE SAVE FAIL', + propertyId, + 'index', + i, + 'of', + strings.length, + 'SIZE', + string.length, + 'error:', + e, + ) + } } } diff --git a/src/lib/database/proxy.ts b/src/lib/database/proxy.ts index 65b3b850..1264150c 100644 --- a/src/lib/database/proxy.ts +++ b/src/lib/database/proxy.ts @@ -8,7 +8,7 @@ const PROXY_TARGET = Symbol('proxy_target') type DynamicObject = Record type ProxiedDynamicObject = DynamicObject & { [IS_PROXIED]?: boolean } -export class ProxyDatabase implements Table { +export abstract class ProxyDatabase implements Table { static tables: Record = {} constructor( @@ -18,6 +18,8 @@ export class ProxyDatabase impleme ProxyDatabase.tables[id] = this as UnknownTable } + abstract onLoad(waiter: (value: void) => void): void + get size(): number { return this.value.size } @@ -35,6 +37,7 @@ export class ProxyDatabase impleme } getImmutable(key: Key): Immutable { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) const value = this.value.get(key) if (this.defaultValue && typeof value === 'undefined') { this.value.set(key, this.defaultValue(key)) @@ -45,37 +48,42 @@ export class ProxyDatabase impleme } delete(key: Key): boolean { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) const deleted = this.value.delete(key) if (deleted) this.needSave() return deleted } set(key: Key, value: Value): void { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) this.value.set(key, value) this.needSave() } keys(): MapIterator { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) return this.value.keys() } - values(): Value[] { - const values: Value[] = [] - for (const value of this.value.values()) values.push(value) - return values + values() { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) + return [...this.value.values()] as Immutable[] } - valuesImmutable() { + valuesIterator() { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) return this.value.values() as MapIterator> } entries(): [Key, Value][] { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) const entries: [Key, Value][] = [] for (const [key, value] of this.value.entries()) entries.push([key, this.wrap(value, '') as Value]) return entries } entriesImmutable(): MapIterator<[Key, Immutable]> { + if (!this.loaded) throw new Error(`Proxy table ${this.id} is not yet loaded!`) return this.value.entries() as MapIterator<[Key, Immutable]> } @@ -140,6 +148,8 @@ export class ProxyDatabase impleme protected value = new Map() + protected loaded = false + private proxyCache = new WeakMap() protected restore(from: Record) { diff --git a/src/lib/database/scoreboard.ts b/src/lib/database/scoreboard.ts index 5348d4f6..7583af4c 100644 --- a/src/lib/database/scoreboard.ts +++ b/src/lib/database/scoreboard.ts @@ -3,6 +3,7 @@ import { defaultLang } from 'lib/assets/lang' import { expand } from 'lib/extensions/extend' import { i18nShared } from 'lib/i18n/text' import { capitalize } from 'lib/util' +import { onLoad } from 'lib/utils/load-ref' declare module '@minecraft/server' { namespace ScoreNames { @@ -165,13 +166,15 @@ export class ScoreboardDB { return objective } - scoreboard + scoreboard!: ScoreboardObjective constructor( public id: string, displayName: string = id, ) { - this.scoreboard = ScoreboardDB.objective(id, displayName) + onLoad(() => { + this.scoreboard = ScoreboardDB.objective(id, displayName) + }) } set(id: Entity | string, value: number) { diff --git a/src/lib/database/utils.ts b/src/lib/database/utils.ts index a7fc4f6e..0892d369 100644 --- a/src/lib/database/utils.ts +++ b/src/lib/database/utils.ts @@ -2,6 +2,7 @@ import { Entity, StructureSaveMode, system, world } from '@minecraft/server' import { noI18n } from 'lib/i18n/text' +import { onLoad } from 'lib/utils/load-ref' import { Vec } from 'lib/vector' interface TableEntity { @@ -23,7 +24,7 @@ export class DatabaseUtils { static chunkRegexp = /.{1,50}/g - static propertyChunkRegexp = /.{1,32767}/g + static propertyChunkRegexp = /.{1,20000}/g private static allEntities: TableEntity[] @@ -54,31 +55,30 @@ export class DatabaseUtils { .filter(e => e.tableName !== 'NOTDB') } - private static readonly tablesDimension = world.overworld - private static tables(): TableEntity[] { if (typeof this.allEntities !== 'undefined') return this.allEntities this.allEntities = this.getEntities() if (this.allEntities.length < 1) { console.warn(noI18n`§6Не удалось найти базы данных. Попытка загрузить бэкап...`) - - world.overworld - .getEntities({ - location: DatabaseUtils.entityLocation, - type: DatabaseUtils.entityTypeId, - maxDistance: 2, - }) - - .forEach(e => e.remove()) - - world.structureManager.place(this.backupName, this.tablesDimension, this.entityLocation) - this.allEntities = this.getEntities() - - if (this.allEntities.length < 1) { - console.warn(noI18n`§cНе удалось загрузить базы данных из бэкапа.`) - return [] - } else console.warn(`Бэкап успешно загружен! Всего баз данных: ${this.allEntities.length}`) + if (world.structureManager.get(this.backupName)) { + world.overworld + .getEntities({ + location: DatabaseUtils.entityLocation, + type: DatabaseUtils.entityTypeId, + maxDistance: 2, + }) + + .forEach(e => e.remove()) + + world.structureManager.place(this.backupName, world.overworld, this.entityLocation) + this.allEntities = this.getEntities() + + if (this.allEntities.length < 1) { + console.warn(noI18n`§cНе удалось загрузить базы данных из бэкапа.`) + return [] + } else console.warn(`Бэкап успешно загружен! Всего баз данных: ${this.allEntities.length}`) + } else console.error('Backup does not exists, initializing empty tables...') } return this.allEntities @@ -121,7 +121,7 @@ export class DatabaseUtils { world.structureManager.delete(this.backupName) world.structureManager.createFromWorld( this.backupName, - this.tablesDimension, + world.overworld, this.entityLocation, this.entityLocation, { includeBlocks: false, includeEntities: true, saveMode: StructureSaveMode.World }, @@ -134,6 +134,6 @@ export class DatabaseUtils { } } -world.afterEvents.worldLoad.subscribe(() => { +onLoad(() => { world.overworld.runCommand('tickingarea add 0 -64 0 0 200 0 database true') }) diff --git a/src/lib/enchantments.ts b/src/lib/enchantments.ts index 587608b3..8e069384 100644 --- a/src/lib/enchantments.ts +++ b/src/lib/enchantments.ts @@ -6,7 +6,6 @@ import { enchantmentsJson } from './assets/enchantments' import { Core } from './extensions/core' const location = { x: 0, y: -10, z: 0 } -const dimension = world.overworld export const Enchantments = { custom: {} as Record>>, @@ -15,6 +14,8 @@ export const Enchantments = { } function load() { + const dimension = world.overworld + let expecting = enchantmentsJson.items as number for (let i = 1; i <= enchantmentsJson.files; i++) { const structure = `mystructure:generated/${i}` diff --git a/src/lib/extensions/core.ts b/src/lib/extensions/core.ts index 7d4a602b..e650dcda 100644 --- a/src/lib/extensions/core.ts +++ b/src/lib/extensions/core.ts @@ -1,4 +1,5 @@ import { Player, system, world } from '@minecraft/server' +import { onLoad } from 'lib/utils/load-ref' import { EventLoader, EventSignal } from '../event-signal' /** Core server features */ @@ -16,30 +17,32 @@ export const Core = { } if (!__VITEST__) { - system.run(function waiter() { - const entities = world.overworld.getEntities() - if (entities.length < 1) { - // No entity found, re-run waiter - return system.run(waiter) - } - - try { - EventLoader.load(Core.afterEvents.worldLoad) - } catch (e) { - console.error(e) - } - }) + onLoad(() => { + system.run(function waiter() { + const entities = world.overworld.getEntities() + if (entities.length < 1) { + // No entity found, re-run waiter + return system.run(waiter) + } - system.afterEvents.scriptEventReceive.subscribe( - data => { - if (data.id === 'SERVER:SAY') { - world.say(decodeURI(data.message)) + try { + EventLoader.load(Core.afterEvents.worldLoad) + } catch (e) { + console.error(e) } - }, - { - namespaces: ['SERVER'], - }, - ) + }) + + system.afterEvents.scriptEventReceive.subscribe( + data => { + if (data.id === 'SERVER:SAY') { + world.say(decodeURI(data.message)) + } + }, + { + namespaces: ['SERVER'], + }, + ) + }) } else { EventLoader.load(Core.afterEvents.worldLoad) } diff --git a/src/lib/extensions/enviroment.ts b/src/lib/extensions/enviroment.ts index 67c5ee80..95c908df 100644 --- a/src/lib/extensions/enviroment.ts +++ b/src/lib/extensions/enviroment.ts @@ -205,8 +205,10 @@ function getTimezone(language?: Language) { switch (language) { case Language.ru_RU: return 3 - default: + case Language.bg_BG: return 0 + default: + return 3 } } @@ -217,7 +219,7 @@ Date.prototype.toYYYYMMDD = function (lang) { const year = date.getFullYear() const month = (date.getMonth() + 1).toString().padStart(2, '0') const day = date.getDate().toString().padStart(2, '0') - return `${day}-${month}-${year}` + return `${day}.${month}.${year}` } Date.prototype.toHHMM = function (lang) { diff --git a/src/lib/extensions/on-screen-display.ts b/src/lib/extensions/on-screen-display.ts index 2021c2d3..fb9a194a 100644 --- a/src/lib/extensions/on-screen-display.ts +++ b/src/lib/extensions/on-screen-display.ts @@ -1,5 +1,6 @@ import { Player, RawMessage, ScreenDisplay, system, world } from '@minecraft/server' import { ScreenDisplaySymbol } from 'lib/extensions/player' +import { onLoad } from 'lib/utils/load-ref' import { fromMsToTicks, fromTicksToMs } from 'lib/utils/ms' import { WeakPlayerMap } from 'lib/weak-player-storage' @@ -219,7 +220,7 @@ const actionbarLock = new WeakPlayerMap<{ priority: ActionbarPriority; expires: const defaultOptions = { fadeInDuration: 0, fadeOutDuration: 0, stayDuration: 0 } const defaultTitleOptions = { ...defaultOptions, stayDuration: -1 } -run() +onLoad(run) function run() { system.run(() => { diff --git a/src/lib/extensions/system.ts b/src/lib/extensions/system.ts index 238301ee..71f5aa91 100644 --- a/src/lib/extensions/system.ts +++ b/src/lib/extensions/system.ts @@ -1,5 +1,6 @@ import { system, System, world } from '@minecraft/server' import stringifyError from 'lib/utils/error' +import { LoadRef } from 'lib/utils/load-ref' import { capitalize, util } from '../util' import { expand } from './extend' @@ -64,15 +65,24 @@ expand(System.prototype, { runJob(generator, name) { const id = name ?? stringifyError.parent() + const source = stringifyError.stack.get() return super.runJob( (function* runJobWrapper() { - let v - do { - const end = util.benchmark(id, 'job') - v = generator.next() - end() - yield - } while (!v.done) + try { + let v + do { + const end = util.benchmark(id, 'job') + v = generator.next() + if ((v.value as unknown) instanceof Promise) + (v.value as unknown as Promise).catch((e: unknown) => + console.error('Error in async job', name, e, source), + ) + end() + yield + } while (!v.done) + } catch (e) { + console.error('Error in job', name, e, source) + } })(), ) }, @@ -105,7 +115,11 @@ expand(System.prototype, { function jobInterval() { system.runJob( (function* job() { - for (const _ of callback()) yield + try { + for (const _ of callback()) yield + } catch (e) { + console.error('Error in job interval', e) + } if (stopped) return if (tickInterval === 0) system.delay(jobInterval) else system.runTimeout(jobInterval, 'jobInterval', tickInterval) @@ -152,6 +166,7 @@ function Timer( TIMERS_PATHES[visualId] = path function timer() { + if (type !== 'timeout' && !LoadRef.loadFinished) return util.catch(fn, capitalize(type)) } diff --git a/src/lib/extensions/world.ts b/src/lib/extensions/world.ts index f66cc4aa..f9620847 100644 --- a/src/lib/extensions/world.ts +++ b/src/lib/extensions/world.ts @@ -1,6 +1,6 @@ -import { World, world } from '@minecraft/server' +import { Dimension, World, world } from '@minecraft/server' import { MinecraftDimensionTypes } from '@minecraft/vanilla-data' -import { stringify } from '../util' +import { onLoad } from 'lib/utils/load-ref' import { expand } from './extend' declare module '@minecraft/server' { @@ -16,26 +16,37 @@ declare module '@minecraft/server' { */ logOnce(type: string, ...messages: unknown[]): void - /** Prints data using world.say() and parses any object to string using toStr method. */ - debug(...data: unknown[]): void overworld: Dimension end: Dimension nether: Dimension } } +onLoad(() => { + expand(World.prototype, { + overworld: world.getDimension(MinecraftDimensionTypes.Overworld), + nether: world.getDimension(MinecraftDimensionTypes.Nether), + end: world.getDimension(MinecraftDimensionTypes.TheEnd), + }) +}) + expand(World.prototype, { say: world.sendMessage.bind(world), - overworld: world.getDimension(MinecraftDimensionTypes.Overworld), - nether: world.getDimension(MinecraftDimensionTypes.Nether), - end: world.getDimension(MinecraftDimensionTypes.TheEnd), - debug(...data: unknown[]) { - this.say(data.map(stringify).join(' ')) + get overworld() { + throw new Error('Dimensions are not available') + return undefined as unknown as Dimension + }, + get nether() { + throw new Error('Dimensions are not available') + return undefined as unknown as Dimension + }, + get end() { + throw new Error('Dimensions are not available') + return undefined as unknown as Dimension }, - logOnce(name, ...data: unknown[]) { if (logs.has(name)) return - world.debug(...data) + console.log(name, ...data) logs.add(name) }, }) diff --git a/src/lib/form/action.ts b/src/lib/form/action.ts index c34c4a50..d48e87b7 100644 --- a/src/lib/form/action.ts +++ b/src/lib/form/action.ts @@ -3,7 +3,6 @@ import { ActionFormData, ActionFormResponse } from '@minecraft/server-ui' import { Language } from 'lib/assets/lang' import { ask } from 'lib/form/message' import { i18n, noI18n } from 'lib/i18n/text' -import { util } from 'lib/util' import { NewFormCallback } from './new' import { BUTTON, showForm } from './utils' @@ -82,7 +81,7 @@ export class ActionForm { } /** - * Adds back button to the form. Alias to {@link ActionForm.button} + * Adds back button to the form. Alias to {@link button} * * @param backCallback - Callback function that will be called when back button is pressed. */ @@ -92,7 +91,7 @@ export class ActionForm { } /** - * Adds ask button to the form. Alias to {@link ActionForm.button} + * Adds ask button to the form. Alias to {@link button} * * Ask is alias to {@link ask} * @@ -133,12 +132,21 @@ export class ActionForm { if (response === false || !(response instanceof ActionFormResponse) || typeof response.selection === 'undefined') return false - const callback = this.buttons[response.selection]?.callback - if (typeof callback === 'function') { - util.catch(() => callback(player, () => this.show(player)) as void) + const button = this.buttons[response.selection] + if (__TEST__) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await button?.callback!(player, () => this.show(player)) return true + } else { + try { + if (typeof button?.callback !== 'function') throw new Error('Callback is undefined') + button.callback(player, () => this.show(player)) + return true + } catch (e) { + console.error('OLD FORM BUTTON ERROR', player.name, button?.text, button?.callback, e) + player.fail(noI18n.error`Old button error: ${button?.text}, erorr: ${e}. Сообщите администрации.`) + return false + } } - - return false } } diff --git a/src/lib/form/chest.ts b/src/lib/form/chest.ts index 723f20fe..3526091f 100644 --- a/src/lib/form/chest.ts +++ b/src/lib/form/chest.ts @@ -1,7 +1,7 @@ import { BlockPermutation, ItemPotionComponent, ItemStack, Player } from '@minecraft/server' import { ActionFormData, ActionFormResponse } from '@minecraft/server-ui' -import { MinecraftItemTypes, MinecraftPotionLiquidTypes } from '@minecraft/vanilla-data' +import { MinecraftItemTypes, MinecraftPotionDeliveryTypes } from '@minecraft/vanilla-data' import { Items, totalCustomItems } from 'lib/assets/custom-items' import { textureData } from 'lib/assets/texture-data' import { translateTypeId } from 'lib/i18n/lang' @@ -44,8 +44,9 @@ export function getAuxTextureOrPotionAux(itemStack: ItemStack) { const potion = itemStack.getComponent(ItemPotionComponent.componentId) if (!potion) return getAuxOrTexture(MinecraftItemTypes.Potion) - const { potionEffectType: effect, potionLiquidType: liquid } = potion - const type = liquid.id !== MinecraftPotionLiquidTypes.Regular ? '_' + liquid.id.toLowerCase() : '' + // TODO ENSure it works correctly + const { potionEffectType: effect, potionDeliveryType: delivery } = potion + const type = delivery.id !== MinecraftPotionDeliveryTypes.Consume ? '_' + delivery.id.toLowerCase() : '' const effectId = (effect.id[0] ?? '').toLowerCase() + effect.id diff --git a/src/lib/form/lore.ts b/src/lib/form/lore.ts index e1c2584c..1d98f050 100644 --- a/src/lib/form/lore.ts +++ b/src/lib/form/lore.ts @@ -2,6 +2,7 @@ import { Player } from '@minecraft/server' import { table } from 'lib/database/abstract' import { i18n } from 'lib/i18n/text' import { form, NewFormCallback, NewFormCreator } from './new' +import { QuestForm } from './quest' interface LoreFormDb { seen: string[] @@ -11,7 +12,7 @@ type AddFn = (f: NewFormCreator) => void export type LF = Omit -export class LoreForm { +export class LoreForm extends QuestForm { static db = table('loreForm', () => ({ seen: [] })) static list: LoreForm[] = [] @@ -22,10 +23,11 @@ export class LoreForm { constructor( protected id: string, - protected form: NewFormCreator, - protected player: Player, - protected back: NewFormCallback, + form: NewFormCreator, + player: Player, + back: NewFormCallback, ) { + super(form, player, back) this.db = LoreForm.db.get(`${id} ${player.id}`) LoreForm.list.push(this) } diff --git a/src/lib/form/message.ts b/src/lib/form/message.ts index 55fb5d96..a524b58f 100644 --- a/src/lib/form/message.ts +++ b/src/lib/form/message.ts @@ -92,14 +92,14 @@ export class MessageForm { /** Shows MessageForm to the player */ export function ask( player: Player, - text: Text, + messageFormBody: Text, yesText: Text, yesAction?: VoidFunction, noText: Text = i18n`Отмена`, noAction?: VoidFunction, ) { return new Promise(resolve => { - new MessageForm(i18n`Вы уверены?`.to(player.lang), text.to(player.lang)) + new MessageForm(i18n`Вы уверены?`.to(player.lang), messageFormBody.to(player.lang)) .setButton1(yesText.to(player.lang), () => { yesAction?.() resolve(true) diff --git a/src/lib/form/modal.ts b/src/lib/form/modal.ts index 6e128ed3..6f6cb264 100644 --- a/src/lib/form/modal.ts +++ b/src/lib/form/modal.ts @@ -195,6 +195,10 @@ export class ModalForm v return this } + submitButton(text: string) { + this.form.submitButton(text) + } + /** * Shows this form to a player * diff --git a/src/lib/form/new.ts b/src/lib/form/new.ts index 71a992f1..b8f7836e 100644 --- a/src/lib/form/new.ts +++ b/src/lib/form/new.ts @@ -1,10 +1,10 @@ import { Player } from '@minecraft/server' import { ActionFormData, ActionFormResponse } from '@minecraft/server-ui' +import { defaultLang } from 'lib/assets/lang' import { ActionForm } from 'lib/form/action' import { ask } from 'lib/form/message' -import { FormCallback, showForm } from 'lib/form/utils' +import { showForm } from 'lib/form/utils' import { i18n, noI18n } from 'lib/i18n/text' -import { Quest } from 'lib/quest' import { doNothing } from 'lib/util' export type NewFormCallback = (player: Player, back?: NewFormCallback) => unknown @@ -43,6 +43,8 @@ class Form { private buttons: NewFormCallback[] = [] + private buttonText: string[] = [] + /** * Adds a button to this form * @@ -98,18 +100,12 @@ class Form { this.form.button(text.to(this.player.lang), icon ?? undefined) this.buttons.push(finalCallback) + this.buttonText.push(text.to(defaultLang)) return this } - quest(quest: Quest, textOverride?: Text, descriptionOverride?: Text) { - const rendered = quest.button.render(this.player, () => this.show(), descriptionOverride) - if (!rendered) return - - this.button(textOverride && rendered[0] === quest.name ? textOverride : rendered[0], rendered[1], rendered[2]) - } - /** - * Adds ask button to the form. Alias to {@link Form.button} + * Adds ask button to the form. Alias to {@link button} * * Ask is alias to {@link ask} * @@ -144,17 +140,17 @@ class Form { `Callback for ${response.selection} does not exists, only ${this.buttons.length} callbacks are available`, ) - if (typeof callback === 'function') { - if (__TEST__) { - // Call right here to throw error + if (__TEST__) { + await callback(this.player, this.show) + } else { + try { + if (typeof callback !== 'function') throw new Error('Callback is undefined') await callback(this.player, this.show) - } else { - try { - await callback(this.player, this.show) - } catch (e) { - new FormCallback(this.form, this.player, this.show).error(String(e)) - console.error('Form error', e) - } + } catch (e) { + console.error('FORM BUTTON ERROR', this.player.name, this.buttonText[response.selection], callback, e) + this.player.fail( + noI18n.error`Button error: ${this.buttonText[response.selection]}, erorr: ${e}. Сообщите администрации.`, + ) } } } @@ -187,8 +183,31 @@ export class ShowForm { title(player: Player) { const form = new Form(player) - this.create(form, { player, back: doNothing, params: this.params, self: doNothing }) - return form.currentTitle ?? 'No title' + const error = new Error('STOP FORM CREATION WE GOT TITLE') + let title: undefined | Text + + try { + this.create( + Object.setPrototypeOf( + { + title(f) { + title = f + throw error + }, + } satisfies Partial, + form, + ) as Form, + { + player, + back: doNothing, + params: this.params, + self: doNothing, + }, + ) + } catch (e) { + if (e !== error) throw e + } + return title ?? 'No title' } get command() { diff --git a/src/lib/form/quest.ts b/src/lib/form/quest.ts new file mode 100644 index 00000000..df93f5db --- /dev/null +++ b/src/lib/form/quest.ts @@ -0,0 +1,18 @@ +import { Quest } from 'lib/quest' +import { FormContext, NewFormCallback, NewFormCreator } from './new' +import { Player } from '@minecraft/server' + +export class QuestForm { + constructor( + protected form: NewFormCreator, + protected player: Player, + protected back: NewFormCallback, + ) {} + + quest(quest: Quest, textOverride?: Text, descriptionOverride?: Text) { + const rendered = quest.button.render(this.player, () => this.back, descriptionOverride) + if (!rendered) return + + this.form.button(textOverride && rendered[0] === quest.name ? textOverride : rendered[0], rendered[1], rendered[2]) + } +} diff --git a/src/lib/form/select-player.ts b/src/lib/form/select-player.ts index ad617317..d9fe1051 100644 --- a/src/lib/form/select-player.ts +++ b/src/lib/form/select-player.ts @@ -1,6 +1,7 @@ import { Player, world } from '@minecraft/server' import { ArrayForm } from 'lib/form/array' import { BUTTON } from 'lib/form/utils' +import { getFullname } from 'lib/get-fullname' import { i18n } from 'lib/i18n/text' import { NewFormCallback } from './new' @@ -121,7 +122,7 @@ export function selectPlayer( return players }) .button(({ id, name, online }) => { - return [(online ? '§f' : '§8') + name, () => resolve({ id, name })] + return [getFullname(id, { nameColor: online ? '§f' : '§8' }), () => resolve({ id, name })] }) .back(back) .show(player) diff --git a/src/lib/i18n/intl.test.ts b/src/lib/i18n/intl.test.ts index 7371e90c..918b8962 100644 --- a/src/lib/i18n/intl.test.ts +++ b/src/lib/i18n/intl.test.ts @@ -1,7 +1,7 @@ -import { ms } from 'lib' import { Language, supportedLanguages } from 'lib/assets/lang' import { intlListFormat, intlRemaining } from './intl' import { i18n } from './text' +import { ms } from 'lib/utils/ms' describe('intlListFormat', () => { it('should translate', () => { @@ -62,4 +62,3 @@ describe('intlRemaining', () => { ).toMatchInlineSnapshot(`"1,001 days, 6 hours, 4 minutes"`) }) }) - diff --git a/src/lib/i18n/text.test.ts b/src/lib/i18n/text.test.ts index b18a7baa..01ed24c3 100644 Binary files a/src/lib/i18n/text.test.ts and b/src/lib/i18n/text.test.ts differ diff --git a/src/lib/i18n/text.ts b/src/lib/i18n/text.ts index 152b97ca..c5766c50 100644 --- a/src/lib/i18n/text.ts +++ b/src/lib/i18n/text.ts @@ -1,264 +1,234 @@ -import { Player, RawText } from "@minecraft/server"; -import { defaultLang, Language } from "lib/assets/lang"; -import { extractedTranslatedPlurals } from "lib/assets/lang-messages"; -import { Vec } from "lib/vector"; -import { separateNumberWithDots } from "../util"; -import { stringify } from "../utils/inspect"; -import { ms } from "../utils/ms"; -import { intlPlural, intlRemaining } from "./intl"; +import { Player, RawText } from '@minecraft/server' +import { defaultLang, Language } from 'lib/assets/lang' +import { extractedTranslatedPlurals } from 'lib/assets/lang-messages' +import { Vec } from 'lib/vector' +import { separateNumberWithDots } from '../util' +import { stringify } from '../utils/inspect' +import { ms } from '../utils/ms' +import { intlPlural, intlRemaining } from './intl' import { - I18nMessage, - Message, - RawTextArg, - ServerSideI18nMessage, - SharedI18nMessage, - SharedI18nMessageJoin, -} from "./message"; -export type MaybeRawText = string | RawText; + I18nMessage, + Message, + RawTextArg, + ServerSideI18nMessage, + SharedI18nMessage, + SharedI18nMessageJoin, +} from './message' +export type MaybeRawText = string | RawText declare global { - /** Text that can be displayed on player screen and should support translation */ - type Text = string | Message; - - type SharedText = import("lib/i18n/message").SharedI18nMessage; - - namespace Text { - export interface Colors { - /** Color of strings, objects and other messages */ - unit: string; - /** Color of numbers and bigints */ - num: string; - /** Color of regular template text i18n`Like this one` */ - text: string; - } - - export interface Static { - /** - * @example - * t.time(3000) -> "3 секунды" - */ - time(time: number): Message; - - /** - * @example - * t.time(3000) -> "00:00:03" - * t.time(ms.from('min', 32) + 1000) -> "00:32:01" - * t.time(ms.from('day', 1) + ms.from('min', 32) + 1000) -> "1 д. 00:32:01" - * t.time(ms.from('day', 10000) + ms.from('min', 32) + 1000) -> "10000 д. 00:32:01" - */ - hhmmss(time: number): SharedI18nMessage | string; - - restyle: (colors: Partial) => T; - - style: Text.Colors; - } - - /** "§7Some long text §fwith substring§7 and number §64§7" */ - export type Fn = (text: TemplateStringsArray, ...args: Arg[]) => T; - - export type FnWithJoin = Fn & { join: Fn }; - - interface Modifiers { - /** "§cSome long text §fwith substring§c and number §74§c" */ - error: T; - - /** "§eSome long text §fwith substring§e and number §64§e" */ - warn: T; - - /** "§aSome long text §fwith substring§a and number §64§a" */ - success: T; - - /** "§3Some long text §fwith substring§3 and number §64§3" */ - accent: T; - - /** "§8Some long text §7with substring§8 and number §74§8" */ - disabled: T; - - /** "§r§6Some long text §f§lwith substring§r§6 and number §f4§r§6" */ - header: T; - - /** "Some long text with substring and number 4" */ - nocolor: T; - } - export type Chained> = T & - Static> & - Modifiers>>; - - export type Table = readonly (string | readonly [Text, unknown])[]; - } + /** Text that can be displayed on player screen and should support translation */ + type Text = string | Message + + type SharedText = import('lib/i18n/message').SharedI18nMessage + + namespace Text { + export interface Colors { + /** Color of strings, objects and other messages */ + unit: string + /** Color of numbers and bigints */ + num: string + /** Color of regular template text i18n`Like this one` */ + text: string + } + + export interface Static { + /** + * @example + * t.time(3000) -> "3 секунды" + */ + time(time: number): Message + + /** + * @example + * t.time(3000) -> "00:00:03" + * t.time(ms.from('min', 32) + 1000) -> "00:32:01" + * t.time(ms.from('day', 1) + ms.from('min', 32) + 1000) -> "1 д. 00:32:01" + * t.time(ms.from('day', 10000) + ms.from('min', 32) + 1000) -> "10000 д. 00:32:01" + */ + hhmmss(time: number): SharedI18nMessage | string + + restyle: (colors: Partial) => T + + style: Text.Colors + } + + /** "§7Some long text §fwith substring§7 and number §64§7" */ + export type Fn = (text: TemplateStringsArray, ...args: Arg[]) => T + + export type FnWithJoin = Fn & { join: Fn } + + interface Modifiers { + /** "§cSome long text §fwith substring§c and number §74§c" */ + error: T + + /** "§eSome long text §fwith substring§e and number §64§e" */ + warn: T + + /** "§aSome long text §fwith substring§a and number §64§a" */ + success: T + + /** "§3Some long text §fwith substring§3 and number §64§3" */ + accent: T + + /** "§8Some long text §7with substring§8 and number §74§8" */ + disabled: T + + /** "§r§6Some long text §f§lwith substring§r§6 and number §f4§r§6" */ + header: T + + /** "Some long text with substring and number 4" */ + nocolor: T + } + export type Chained> = T & Static> & Modifiers>> + + export type Table = readonly (string | readonly [Text, unknown])[] + } } export function textTable(table: Text.Table): Message { - return new ServerSideI18nMessage(defaultColors(), (lang) => { - const long = table.length > 5; - return table - .map((v, i) => { - if (typeof v === "string") return ""; - - const [key, value] = v; - return `${i % 2 === 0 && long ? "§f" : "§7"}${key.to( - lang - )}: ${textUnitColorize(value, undefined, lang)}`; - }) - .join("\n"); - }); + return new ServerSideI18nMessage(defaultColors(), lang => { + const long = table.length > 5 + return table + .map((v, i) => { + if (typeof v === 'string') return '' + + const [key, value] = v + return `${i % 2 === 0 && long ? '§f' : '§7'}${key.to(lang)}: ${textUnitColorize(value, undefined, lang)}` + }) + .join('\n') + }) } function createStyle(colors: Text.Colors) { - return Object.freeze(colors); + return Object.freeze(colors) } const styles = { - nocolor: createStyle({ text: "", unit: "", num: "" }), - header: createStyle({ text: "§r§6", num: "§f", unit: "§f§l" }), - error: createStyle({ num: "§7", text: "§c", unit: "§f" }), - warn: createStyle({ num: "§6", text: "§e", unit: "§f" }), - accent: createStyle({ num: "§6", text: "§3", unit: "§f" }), - success: createStyle({ num: "§6", text: "§a", unit: "§f" }), - disabled: createStyle({ num: "§7", text: "§8", unit: "§7" }), -}; - -export const noI18n = createStatic(undefined, undefined, (colors) => { - return function simpleStr(template, ...args) { - return Message.concatTemplateStringsArray( - defaultLang, - template, - args, - colors - ); - } as Text.Chained>; -}); - -export const i18n = createStatic(undefined, undefined, (colors) => { - const i18n = ((template, ...args) => - new I18nMessage(template, args, colors)) as Text.FnWithJoin< - I18nMessage, - unknown - >; - - i18n.join = (template, ...args) => new Message(template, args, colors); - - return i18n as Text.Chained>; -}); - -export const i18nShared = createStatic(undefined, undefined, (colors) => { - const i18n = ((template, ...args) => - new SharedI18nMessage(template, args, colors)) as Text.FnWithJoin< - SharedI18nMessage, - RawTextArg - >; - - i18n.join = (template, ...args) => - new SharedI18nMessageJoin(template, args, colors); - - return i18n as Text.Chained>; -}); - -export const i18nPlural = createStatic(undefined, undefined, (colors) => { - return function i18nPlural(template, n) { - const id = ServerSideI18nMessage.templateToId(template); - return new ServerSideI18nMessage(colors, (l) => { - const translated = - extractedTranslatedPlurals[l]?.[id]?.[intlPlural(l, n)] ?? template; - return ServerSideI18nMessage.concatTemplateStringsArray( - l, - translated, - [n], - colors, - [] - ); - }); - } as Text.Chained< - (template: TemplateStringsArray, n: number) => ServerSideI18nMessage - >; -}); - -function defaultColors( - colors: Partial = {} -): Required { - return { - unit: colors.unit ?? "§f", - text: colors.text ?? "§7", - num: colors.num ?? "§6", - }; + nocolor: createStyle({ text: '', unit: '', num: '' }), + header: createStyle({ text: '§r§6', num: '§f', unit: '§f§l' }), + error: createStyle({ num: '§7', text: '§c', unit: '§f' }), + warn: createStyle({ num: '§6', text: '§e', unit: '§f' }), + accent: createStyle({ num: '§6', text: '§3', unit: '§f' }), + success: createStyle({ num: '§6', text: '§a', unit: '§f' }), + disabled: createStyle({ num: '§7', text: '§8', unit: '§7' }), +} + +export const noI18n = createStatic(undefined, undefined, colors => { + return function simpleStr(template, ...args) { + return Message.concatTemplateStringsArray(defaultLang, template, args, colors) + } as Text.Chained> +}) + +export const i18n = createStatic(undefined, undefined, colors => { + const i18n = ((template, ...args) => new I18nMessage(template, args, colors)) as Text.FnWithJoin + + i18n.join = (template, ...args) => new Message(template, args, colors) + + return i18n as Text.Chained> +}) + +export const i18nShared = createStatic(undefined, undefined, colors => { + const i18n = ((template, ...args) => new SharedI18nMessage(template, args, colors)) as Text.FnWithJoin< + SharedI18nMessage, + RawTextArg + > + + i18n.join = (template, ...args) => new SharedI18nMessageJoin(template, args, colors) + + return i18n as Text.Chained> +}) + +export const i18nPlural = createStatic(undefined, undefined, colors => { + return function i18nPlural(template, n) { + const id = ServerSideI18nMessage.templateToId(template) + return new ServerSideI18nMessage(colors, l => { + const translated = extractedTranslatedPlurals[l]?.[id]?.[intlPlural(l, n)] ?? template + return ServerSideI18nMessage.concatTemplateStringsArray(l, translated, [n], colors, []) + }) + } as Text.Chained<(template: TemplateStringsArray, n: number) => ServerSideI18nMessage> +}) + +function defaultColors(colors: Partial = {}): Required { + return { + unit: colors.unit ?? '§f', + text: colors.text ?? '§7', + num: colors.num ?? '§6', + } } function createStatic>>( - colors: Partial = {}, - modifier = false, - createFn: (colors: Text.Colors) => T + colors: Partial = {}, + modifier = false, + createFn: (colors: Text.Colors) => T, ): T { - const dcolors = defaultColors(colors); - const fn = createFn(dcolors); - fn.style = dcolors; - fn.time = createTime(dcolors); - fn.hhmmss = createTimeHHMMSS(dcolors); - fn.restyle = (colors) => createStatic(colors, false, createFn); - - if (!modifier) { - fn.nocolor = createStatic(styles.nocolor, true, createFn); - fn.header = createStatic(styles.header, true, createFn); - fn.error = createStatic(styles.error, true, createFn); - fn.warn = createStatic(styles.warn, true, createFn); - fn.accent = createStatic(styles.accent, true, createFn); - fn.success = createStatic(styles.success, true, createFn); - fn.disabled = createStatic(styles.disabled, true, createFn); - } - return fn; + const dcolors = defaultColors(colors) + const fn = createFn(dcolors) + fn.style = dcolors + fn.time = createTime(dcolors) + fn.hhmmss = createTimeHHMMSS(dcolors) + fn.restyle = colors => createStatic(colors, false, createFn) + + if (!modifier) { + fn.nocolor = createStatic(styles.nocolor, true, createFn) + fn.header = createStatic(styles.header, true, createFn) + fn.error = createStatic(styles.error, true, createFn) + fn.warn = createStatic(styles.warn, true, createFn) + fn.accent = createStatic(styles.accent, true, createFn) + fn.success = createStatic(styles.success, true, createFn) + fn.disabled = createStatic(styles.disabled, true, createFn) + } + return fn } -const dayMs = ms.from("day", 1); -function createTimeHHMMSS(colors: Text.Colors): Text.Static["hhmmss"] { - return (n) => { - const hhmmss = new Date(n).toHHMMSS(); - if (n <= dayMs) return hhmmss; +const dayMs = ms.from('day', 1) +function createTimeHHMMSS(colors: Text.Colors): Text.Static['hhmmss'] { + return n => { + const hhmmss = new Date(n).toHHMMSS() + if (n <= dayMs) return hhmmss - const days = ~~(n / dayMs); - return i18nShared.restyle(colors)`${days} д. ${hhmmss}`; - }; + const days = ~~(n / dayMs) + return i18nShared.restyle(colors)`${days} д. ${hhmmss}` + } } -function createTime(colors: Text.Colors): Text.Static["time"] { - return (ms) => new ServerSideI18nMessage(colors, (l) => intlRemaining(l, ms)); +function createTime(colors: Text.Colors): Text.Static['time'] { + return ms => new ServerSideI18nMessage(colors, l => intlRemaining(l, ms)) } export function textUnitColorize( - v: unknown, - { unit, num }: Text.Colors = defaultColors(), - lang: Language | false + v: unknown, + { unit, num }: Text.Colors = defaultColors(), + lang: Language | false, ): string { - switch (typeof v) { - case "string": - if (v.includes("§l")) return unit + v + "§r"; - return unit + v; - case "undefined": - return ""; - case "object": - if (v instanceof Message) { - if (!lang) { - throw new TypeError( - `Text unit colorize cannot translate Message '${v.id}' if no locale was given!` - ); - } - - const vstring = v.to(lang); - return vstring.startsWith("§") ? vstring : unit + vstring; - } - if (v instanceof Player) { - return unit + v.name; - } else if (Vec.isVec(v)) { - return Vec.string(v, true); - } else return stringify(v); - - case "number": - return `${num}${separateNumberWithDots(v)}`; - case "symbol": - case "function": - case "bigint": - return "§c<>"; - case "boolean": - return (v ? i18n.nocolor`§fДа` : i18n.nocolor`§cНет`).to( - lang || defaultLang - ); - } + switch (typeof v) { + case 'string': + if (v.includes('§l')) return unit + v + '§r' + return unit + v + case 'undefined': + return '' + case 'object': + if (v instanceof Message) { + if (!lang) { + throw new TypeError(`Text unit colorize cannot translate Message '${v.id}' if no locale was given!`) + } + + const vstring = v.to(lang) + return vstring.startsWith('§') ? vstring : unit + vstring + } + if (v instanceof Player) { + return unit + v.name + } else if (Vec.isVec(v)) { + return Vec.string(v, true) + } else return stringify(v) + + case 'number': + return `${num}${separateNumberWithDots(v)}` + case 'symbol': + case 'function': + case 'bigint': + return '§c<>' + case 'boolean': + return (v ? i18n.nocolor`§fДа` : i18n.nocolor`§cНет`).to(lang || defaultLang) + } } diff --git a/src/lib/lib.d.ts b/src/lib/lib.d.ts index 3e1ea67d..8555176c 100644 --- a/src/lib/lib.d.ts +++ b/src/lib/lib.d.ts @@ -1,5 +1,5 @@ import * as mc from '@minecraft/server' -import '../../tools/defines' +import '../../tools/defines.d.ts' declare global { type VoidFunction = () => void @@ -94,15 +94,6 @@ type Narrowable = string | number | bigint | boolean declare module '@minecraft/server' { interface PlayerDatabase { name?: string | undefined - readonly role: Role - prevRole?: Role - quests?: import('./quest/quest').Quest.DB - achivs?: import('./achievements/achievement').Achievement.DB - join?: { - position?: number[] - stage?: number - } - unlockedPortals?: string[] } } diff --git a/src/lib/load/message1.ts b/src/lib/load/message1.ts index ba464668..a5de1efd 100644 --- a/src/lib/load/message1.ts +++ b/src/lib/load/message1.ts @@ -1,7 +1,5 @@ -import { world } from '@minecraft/server' - if (__GIT__) console.info('§7' + __GIT__) const message = '§9> §fReloading script...' if (!__VITEST__) console.info(message) -if (!__RELEASE__) world.say(message) +// if (!__RELEASE__) world.say(message) diff --git a/src/lib/load/message2.ts b/src/lib/load/message2.ts index 81cd160f..8a552dd1 100644 --- a/src/lib/load/message2.ts +++ b/src/lib/load/message2.ts @@ -8,8 +8,10 @@ system.delay(() => { if (__GIT__) import('lib/roles').then(({ is }) => { - for (const player of world.getAllPlayers()) - if (is(player.id, 'techAdmin')) player.tell(`§sCommit: §f${__GIT__.replace(/^Commit: /, '')}`) + world.afterEvents.worldLoad.subscribe(() => { + for (const player of world.getAllPlayers()) + if (is(player.id, 'techAdmin')) player.tell(`§sCommit: §f${__GIT__.replace(/^Commit: /, '')}`) + }) }) globalThis.loaded = 0 diff --git a/src/lib/load/watchdog.ts b/src/lib/load/watchdog.ts index e02a4786..45f6e81c 100644 --- a/src/lib/load/watchdog.ts +++ b/src/lib/load/watchdog.ts @@ -7,10 +7,6 @@ declare global { globalThis.loaded = Date.now() -//@ts-expect-error Define global intl if not defined -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -globalThis.Intl ??= {} - const reasons: Record = { Hang: 'Скрипт завис', StackOverflow: 'Стэк переполнен', diff --git a/src/lib/location.test.ts b/src/lib/location.test.ts index 79fa85ed..7148ddad 100644 --- a/src/lib/location.test.ts +++ b/src/lib/location.test.ts @@ -1,6 +1,6 @@ import { Player } from '@minecraft/server' -import { Vec } from 'lib' import 'lib/database/scoreboard' +import { Vec } from 'lib/vector' import { location, locationWithRadius, locationWithRotation, migrateLocationName } from './location' import { Group } from './rpg/place' import { Settings } from './settings' diff --git a/src/lib/location.ts b/src/lib/location.ts index 8b26f6a4..b9869f89 100644 --- a/src/lib/location.ts +++ b/src/lib/location.ts @@ -2,9 +2,10 @@ import { Player, TeleportOptions, Vector3, system, world } from '@minecraft/serv import { isEmpty } from 'lib/util' import { Vec, VecSymbol } from 'lib/vector' import { EventLoaderWithArg } from './event-signal' -import { i18n, noI18n } from './i18n/text' +import { noI18n } from './i18n/text' import { Place } from './rpg/place' import { Settings } from './settings' +import { onLoad } from './utils/load-ref' import { VectorInDimension } from './utils/point' interface LocationCommon { @@ -46,7 +47,7 @@ class Location { onChange: () => location.load(true), } - location.load() + onLoad(() => location.load()) location.firstLoad = true // Set floored value on reload @@ -166,42 +167,48 @@ export const locationWithRotation = LocationWithRotation.creator< /** Creates reference to a location that can be changed via settings command */ export const locationWithRadius = LocationWithRadius.creator() -system.delay(() => { - for (const [k, d] of Settings.worldDatabase.entries()) { - if (!Object.keys(d).length) { - Settings.worldDatabase.delete(k) +onLoad(() => { + system.delay(() => { + for (const [k, d] of Settings.worldDatabase.entries()) { + if (!Object.keys(d).length) { + Settings.worldDatabase.delete(k) + } } - } + }) }) /** Migration helper */ export function migrateLocationName(oldGroup: string, oldName: string, newGroup: string, newName: string) { - const group = Settings.worldDatabase.get(oldGroup) - const location = group[oldName] - if (typeof location !== 'undefined') { - console.debug(i18n`Migrating location ${oldGroup}:${oldName} to ${newGroup}:${newName}`) - - Settings.worldDatabase.get(newGroup)[newName] = location - - Reflect.deleteProperty(Settings.worldDatabase.get(oldGroup), oldName) - } else if (!Settings.worldDatabase.get(newGroup)[newName]) { - console.warn( - i18n.error`No location found at ${oldGroup}:${oldName}. Group: ${isEmpty(group) ? [...Settings.worldDatabase.keys()] : Object.keys(group)}`, - ) - } + onLoad(() => { + const group = Settings.worldDatabase.get(oldGroup) + const location = group[oldName] + if (typeof location !== 'undefined') { + console.debug(`Migrating location ${oldGroup}:${oldName} to ${newGroup}:${newName}`) + + Settings.worldDatabase.get(newGroup)[newName] = location + + Reflect.deleteProperty(Settings.worldDatabase.get(oldGroup), oldName) + } else if (!Settings.worldDatabase.get(newGroup)[newName]) { + console.warn( + noI18n.warn`No location found at ${oldGroup}:${oldName}. Group: ${isEmpty(group) ? [...Settings.worldDatabase.keys()] : Object.keys(group)}`, + ) + } + }) } export function migrateLocationGroup(from: string, to: string) { - const group = Settings.worldDatabase.get(from) - if (typeof group !== 'undefined') { - console.debug(i18n`Migrating group ${from} to ${to}`) - - Settings.worldDatabase.set(to, { ...Settings.worldDatabase.get(to), ...group }) - - Settings.worldDatabase.delete(from) - } else { - console.warn( - i18n.error`No group found for migration: ${from} -> ${to}. Groups: ${[...Settings.worldDatabase.keys()]}`, - ) - } + onLoad(() => { + const group = Settings.worldDatabase.get(from) + if (typeof group !== 'undefined') { + console.debug(noI18n`Migrating group ${from} to ${to}`) + + Settings.worldDatabase.set(to, { ...Settings.worldDatabase.get(to), ...group }) + + Settings.worldDatabase.delete(from) + } else { + console.warn( + noI18n.warn`No group found for migration: ${from} -> ${to}. Groups: ${[...Settings.worldDatabase.keys()]}`, + ) + } + }) } diff --git a/src/lib/mail/command.ts b/src/lib/mail/command.ts new file mode 100644 index 00000000..afabdcd6 --- /dev/null +++ b/src/lib/mail/command.ts @@ -0,0 +1,197 @@ +import { Player, world } from '@minecraft/server' +import { MinecraftItemTypes } from '@minecraft/vanilla-data' +import { ActionForm } from 'lib/form/action' +import { ArrayForm } from 'lib/form/array' +import { ask } from 'lib/form/message' +import { ModalForm } from 'lib/form/modal' +import { BUTTON } from 'lib/form/utils' +import { i18n } from 'lib/i18n/text' +import { Mail } from 'lib/mail' +import { Join } from 'lib/player-join' +import { is } from 'lib/roles' +import { Settings } from 'lib/settings' +import { Rewards } from 'lib/utils/rewards' + +const command = new Command('mail') + .setDescription(i18n`Посмотреть входящие сообщения почты`) + .setPermissions('member') + .executes(ctx => mailMenu(ctx.player)) + +const mailGroup = [i18n`Почта\n§7Прочтение сообщения, инфо при входе`, 'mail'] as const + +const getSettings = Settings.player(...mailGroup, { + mailReadOnOpen: { + name: i18n`Читать письмо при открытии`, + description: i18n`Помечать ли письмо прочитанным при открытии`, + value: true, + }, + mailClaimOnDelete: { + name: i18n`Собирать награды при удалении`, + description: i18n`Собирать ли награды при удалении письма`, + value: true, + }, +}) + +const getJoinSettings = Settings.player(...Join.getPlayerSettings.extend, { + unreadMails: { + name: i18n`Почта`, + description: i18n`Показывать ли при входе сообщение с кол-вом непрочитанных`, + value: true, + }, +}) + +world.afterEvents.playerSpawn.subscribe(({ player }) => { + if (!getJoinSettings(player).unreadMails) return + + const unreadCount = Mail.getUnreadMessagesCount(player.id) + if (unreadCount === 0) return + + player.info(i18n.join`${i18n.header`Почта:`} ${i18n`У вас ${unreadCount} непрочитанных сообщений!`} ${command}`) +}) + +export function mailMenu(player: Player, back?: VoidFunction) { + const letters = Mail.getLetters(player.id) + new ArrayForm(i18n`Почта`.badge(Mail.getUnreadMessagesCount(player.id)), letters) + .filters({ + unread: { + name: i18n`Непрочитанные`, + description: i18n`Показывать только непрочитанные сообщения`, + value: false, + }, + unclaimed: { + name: i18n`Несобранные награды`, + description: i18n`У письма есть несобранные награды`, + value: false, + }, + sort: { + name: i18n`Сортировать по`, + value: [ + ['date', i18n`Дате`], + ['name', i18n`Имени`], + ], + }, + }) + .addCustomButtonBeforeArray((f, _, back) => { + if (letters.length) { + f.button('Прочитать все\n§7(и собрать награды если есть)', () => { + Mail.readAllAndClaimRewards(player) + player.success() + mailMenu(player) + }) + } else { + f.button('Все прочитано', back) + } + + if (is(player.id, 'moderator')) { + f.button('Объявление', BUTTON['+'], () => { + new ModalForm('Объявление для всего сервера') + .addTextField('Заголовок', 'вы крутые там д0а') + .addTextField('Строка 1', 'мы вас поздравляем') + .addTextField('Строка2', 'вы теперь можете') + .addTextField('Строка3', 'читать все сообщения в почте') + .addTextField('Строка 4', 'да') + .addTextField('Строка5', 'вот.') + .addSlider('Алмазов за прочтение', 0, 100, 1, 5) + .show(player, (ctx, ...args) => { + const diamonds = args.pop() as number + const rewards = new Rewards() + if (diamonds) rewards.item(MinecraftItemTypes.Diamond, diamonds) + Mail.sendMultiple( + [...Player.database.keys()], + i18n`${args[0]}`, + i18n`${args.slice(1).filter(Boolean).join('\n')}`, + rewards, + ) + mailMenu(player) + }) + }) + } + }) + .button(({ letter, index }) => { + const name = `${letter.read ? '§7' : '§f'}${letter.title.replace(/§./g, '')}${letter.read ? '\n§8' : '§c*\n§7'}${letter.content.replace(/§./g, '')}` + return [ + name, + () => { + letterDetailsMenu({ letter, index }, player) + if (getSettings(player).mailReadOnOpen) Mail.readMessage(player.id, index) + }, + ] + }) + .sort((keys, filters) => { + if (filters.unread) keys = keys.filter(letter => !letter.letter.read) + + if (filters.unclaimed) keys = keys.filter(letter => !letter.letter.rewardsClaimed) + + filters.sort === 'name' + ? keys.sort((letterA, letterB) => letterA.letter.title.localeCompare(letterB.letter.title)) + : keys.reverse() + + return keys + }) + .back(back) + .show(player) +} + +function letterDetailsMenu( + { letter, index }: ReturnType<(typeof Mail)['getLetters']>[number], + player: Player, + back = () => mailMenu(player), + message = '', +) { + const settings = getSettings(player) + // TODO Fix collors + // TODO Rewrite to use new form + const form = new ActionForm( + letter.title, + i18n`${message}${letter.content}\n\n§l§fНаграды:§r\n${Rewards.restore(letter.rewards).toString(player)}`.to( + player.lang, + ), + ).addButtonBack(back, player.lang) + + if (!letter.rewardsClaimed && letter.rewards.length) + if (player.database.inv !== 'anarchy') { + form.button(i18n.disabled`Забрать награду`.to(player.lang), () => + letterDetailsMenu( + { letter, index }, + player, + back, + i18n.error`Вы не можете забрать награды не находясь на анархии`.to(player.lang), + ), + ) + } else { + form.button(i18n`Забрать награду`.to(player.lang), () => { + Mail.claimRewards(player, index) + letterDetailsMenu( + { letter, index }, + player, + back, + message + i18n.success`Награда успешно забрана!\n\n`.to(player.lang), + ) + }) + } + + if (!letter.read && !settings.mailReadOnOpen) + form.button(i18n`Пометить как прочитанное`.to(player.lang), () => { + Mail.readMessage(player.id, index) + back() + }) + + let deleteDescription = i18n.error`Удалить письмо?`.to(player.lang) + if (!letter.rewardsClaimed) { + if (getSettings(player).mailClaimOnDelete) { + deleteDescription += i18n` Все награды будут собраны автоматически`.to(player.lang) + } else { + deleteDescription += i18n` Вы потеряете все награды, прикрепленные к письму!`.to(player.lang) + } + } + + form.button(i18n.error`Удалить письмо`.to(player.lang), null, () => { + ask(player, deleteDescription, i18n`Удалить`, () => { + if (getSettings(player).mailClaimOnDelete) Mail.claimRewards(player, index) + Mail.deleteMessage(player, index) + back() + }) + }) + + form.show(player) +} diff --git a/src/lib/mail.test.ts b/src/lib/mail/index.test.ts similarity index 96% rename from src/lib/mail.test.ts rename to src/lib/mail/index.test.ts index bdce007c..c97392d5 100644 --- a/src/lib/mail.test.ts +++ b/src/lib/mail/index.test.ts @@ -3,7 +3,7 @@ import 'lib/extensions/player' import { Mail } from 'lib/mail' import { Rewards } from 'lib/utils/rewards' import { TEST_clearDatabase } from 'test/utils' -import { i18n } from './i18n/text' +import { i18n } from '../i18n/text' describe('mail', () => { beforeEach(() => { diff --git a/src/lib/mail.ts b/src/lib/mail/index.ts similarity index 86% rename from src/lib/mail.ts rename to src/lib/mail/index.ts index c1109caf..f5f9748c 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail/index.ts @@ -1,10 +1,11 @@ import { Player } from '@minecraft/server' import { Rewards } from 'lib/utils/rewards' -import { defaultLang } from './assets/lang' -import { table } from './database/abstract' -import { Message } from './i18n/message' -import { i18n, noI18n } from './i18n/text' +import { defaultLang } from '../assets/lang' +import { table } from '../database/abstract' +import { Message } from '../i18n/message' +import { i18n, noI18n } from '../i18n/text' +import './command' /** A global letter is a letter sent to multiple players */ interface GlobalLetter { @@ -44,6 +45,7 @@ export class Mail { this.dbPlayers .get(playerId) // TODO Use player offline lang once added + .push({ read: false, title: title.to(defaultLang), @@ -55,16 +57,16 @@ export class Mail { private static inform(playerId: string, title: Message) { const player = Player.getById(playerId) - if (player) player.info(i18n`${i18n.header`Почта`}: ${title}, просмотреть: .mail`) + if (player) player.info(i18n`${i18n.header`Почта`}: ${title}, просмотреть: /mail`) } /** * Sends a mail to multiple players * - * @param playerIds The recievers - * @param title The letter title - * @param content The letter content - * @param rewards The attached rewards + * @param {string[]} playerIds The recievers + * @param {string} title The letter title + * @param {string} content The letter content + * @param {Rewards} rewards The attached rewards */ static sendMultiple(playerIds: readonly string[], title: Message, content: Message, rewards = new Rewards()) { let id = new Date().toISOString() @@ -158,6 +160,17 @@ export class Mail { letter.read = true } + static readAllAndClaimRewards(player: Player) { + for (const { index, letter } of this.getLetters(player.id)) { + try { + this.readMessage(player.id, index) + this.claimRewards(player, index) + } catch (e) { + console.error('Failed to read and claim:', player.name, index, letter, e) + } + } + } + /** * Deletes a message from a player's mailbox * diff --git a/src/lib/player-join.ts b/src/lib/player-join.ts index 9a0fbda7..1fe76f6a 100644 --- a/src/lib/player-join.ts +++ b/src/lib/player-join.ts @@ -1,43 +1,36 @@ import { Player, system, world } from '@minecraft/server' -import { sendPacketToStdout } from 'lib/bds/api' import { EventSignal } from 'lib/event-signal' -import { i18n, noI18n } from 'lib/i18n/text' +import { i18n } from 'lib/i18n/text' import { Settings } from 'lib/settings' import { util } from 'lib/util' import { Core } from './extensions/core' import { ActionbarPriority } from './extensions/on-screen-display' -import { getFullname } from './get-fullname' +import { Singleton } from './utils/singleton' +import { WeakPlayerMap } from './weak-player-storage' -class JoinBuilder { - config = { - /** Array with strings to show on join. They will change every second. You can use $ from animation.vars */ - title_animation: { - stages: ['» $title «', '» $title «'], - /** @type {Record} */ - vars: { title: `${Core.name}§r§f` }, - }, - actionBar: '', // Optional - subtitle: i18n.nocolor`Добро пожаловать!`, // Optional - messages: { - air: i18n.nocolor`§8Очнулся в воздухе`, - ground: i18n.nocolor`§8Проснулся`, - sound: 'break.amethyst_cluster', - }, +export declare namespace Join { + interface Database { + position?: number[] + stage?: number } - onMoveAfterJoin = new EventSignal<{ player: Player; joinTimes: number; firstJoin: boolean }>() + type Where = 'air' | 'ground' +} - onFirstTimeSpawn = new EventSignal() +export abstract class Join extends Singleton { + static onMoveAfterJoin = new EventSignal<{ player: Player; joinTimes: number; firstJoin: boolean }>() + + constructor() { + super() + system.runPlayerInterval(player => this.onInterval(player), 'joinInterval', 20) - eventsDefaultSubscribers = { - time: this.onMoveAfterJoin.subscribe(({ player, firstJoin }) => { - if (!firstJoin) player.tell(i18n.nocolor`${timeNow()}, ${player.name}!\n§r§3Время §b• §3${shortTime()}`) - }, -1), - playerSpawn: world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { - if (!initialSpawn) return - this.setPlayerJoinPosition(player) - EventSignal.emit(this.onFirstTimeSpawn, player) - }), + new Command('join') + .setDescription(i18n`Имитирует первый вход`) + .setPermissions('techAdmin') + .executes(ctx => { + const player = ctx.player + this.emitFirstJoin(player) + }) } private playerAt(player: Player) { @@ -46,130 +39,154 @@ class JoinBuilder { return [location.x, location.y, location.z, rotation.x, rotation.y].map(Math.floor) } - setPlayerJoinPosition(player: Player) { - player.database.join ??= {} + /** Used when you need to e.g. teleport users when they join */ + playerSpawnEventSubscriber = world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { + if (!initialSpawn) return + this.setPlayerJoinPosition(player) + }) + /** Used when you need to e.g. teleport users when they join */ + setPlayerJoinPosition(player: Player) { if (!player.isValid) return - player.database.join.position = this.playerAt(player) + this.joinPositions.set(player, { position: this.playerAt(player) }) } - constructor() { - system.runPlayerInterval( - player => { - if (!player.isValid) return - const db = player.database.join - - if (Array.isArray(db?.position)) { - const time = util.benchmark('joinInterval', 'join') - const notMoved = Array.equals(db.position, this.playerAt(player)) - - if (notMoved) { - // Player still stays at joined position... - if (player.isOnGround || player.isFlying) { - // Player will not move, show animation - db.stage = db.stage ?? -1 - db.stage++ - if (isNaN(db.stage) || db.stage >= Join.config.title_animation.stages.length) db.stage = 0 - - // Creating title - let title = Join.config.title_animation.stages[db.stage] ?? '' - for (const [key, value] of Object.entries(Join.config.title_animation.vars)) { - title = title.replace('$' + key, value) - } - - // Show actionBar - if (Join.config.actionBar) { - player.onScreenDisplay.setActionBar(Join.config.actionBar, ActionbarPriority.Highest) - } - - player.onScreenDisplay.setHudTitle(title, { - fadeInDuration: 0, - fadeOutDuration: 20, - stayDuration: 40, - subtitle: Join.config.subtitle.to(player.lang), - }) - } else { - // Player joined in air - this.join(player, 'air') - } - } else { - // Player moved on ground - this.join(player, 'ground') - } - - time() - } - }, - 'joinInterval', - 20, - ) - - new Command('join') - .setDescription(i18n`Имитирует первый вход`) - .setPermissions('member') - .executes(ctx => { - const player = ctx.player - this.emitFirstJoin(player) - }) + isJoining(player: Player) { + return this.joinPositions.has(player) } - private join(player: Player, where: 'air' | 'ground') { - delete player.database.join - player.scores.joinTimes++ + protected joinPositions = new WeakPlayerMap({ removeOnLeave: true }) - const message = Join.config.messages[where] + private onInterval(player: Player) { + if (!player.isValid) return + const db = this.joinPositions.get(player) - __SERVER__ && - sendPacketToStdout('joinOrLeave', { - name: player.name, - role: getFullname(player, { name: false }), - status: 'move', - where, - print: noI18n.nocolor`${'§l§f' + player.name} ${getFullname(player, { name: false })}: ${message}`, - }) + if (Array.isArray(db?.position)) { + const time = util.benchmark('joinInterval', 'join') + const notMoved = Array.equals(db.position, this.playerAt(player)) - for (const other of world.getPlayers()) { - if (other.id === player.id) continue + if (notMoved) { + if (player.isOnGround || player.isFlying) this.notMovingInterval?.(player, db) + else this.joinedAt(player, 'air') + } else this.joinedAt(player, 'ground') - const settings = this.settings(other) - if (settings.sound) other.playSound(Join.config.messages.sound) - if (settings.message) other.tell(i18n.nocolor.join`§7${player.name} ${message}`) + time() } + } - EventSignal.emit(this.onMoveAfterJoin, { + private joinedAt(player: Player, where: Join.Where) { + this.joinPositions.delete(player) + player.scores.joinTimes++ + + this.onJoinMove(where, player) + + EventSignal.emit(Join.onMoveAfterJoin, { player, joinTimes: player.scores.joinTimes, firstJoin: player.scores.joinTimes === 1, }) } - settings = Settings.player(i18n`Вход\n§7Все действия, связанные со входом`, 'join', { - message: { name: i18n`Сообщение`, description: i18n`о входе других игроков`, value: true }, - sound: { name: i18n`Звук`, description: i18n`при входе игроков`, value: true }, + protected notMovingInterval?(player: Player, db: Join.Database): void + + protected abstract onJoinMove(where: Join.Where, player: Player): void + + /** Used when you test how join looks or when you add /wipe like command */ + emitFirstJoin(player: Player) { + EventSignal.emit(Join.onMoveAfterJoin, { player, joinTimes: 1, firstJoin: true }) + } + + static getPlayerSettings = Settings.player(i18n`Вход\n§7Все действия, связанные со входом`, 'join', { time: { name: i18n`Время`, description: i18n`при входе`, value: true }, }) - emitFirstJoin(player: Player) { - EventSignal.emit(this.onMoveAfterJoin, { player, joinTimes: 1, firstJoin: true }) + protected timeListener = Join.onMoveAfterJoin.subscribe(({ player, firstJoin }) => { + if (!firstJoin && Join.getPlayerSettings(player).time) + player.tell(i18n.nocolor`${this.timeNow()}, ${player.name}!\n§r§3Время §b• §3${this.shortTime()}`) + }, -1) + + /** Выводит строку времени */ + private timeNow(): Text { + const time = new Date(Date()).getHours() + 3 + if (time < 6) return i18n`§9Доброй ночи` + if (time < 12) return i18n`§6Доброе утро` + if (time < 18) return i18n`§bДобрый день` + return i18n`§3Добрый вечер` + } + + // TODO Use date.toHHMMSS + /** Выводит время в формате 00:00 */ + private shortTime(): string { + const time = new Date(Date()) + time.setHours(time.getHours() + 3) + return `${time.getHours()}:${String(time.getMinutes()).padStart(2, '0')}` } } -export const Join = new JoinBuilder() +export abstract class JoinWithTitle extends Join { + config = { + /** Array with strings to show on join. They will change every second. You can use $ from animation.vars */ + titleAnimation: { + stages: ['» $title «', '» $title «'], + vars: { title: `${Core.name}§r§f` } as Record, + }, + actionBar: '', // Optional + subtitle: i18n.nocolor`Добро пожаловать!`, // Optional + } + + protected notMovingInterval(player: Player, db: Join.Database): void { + if (this.config.titleAnimation.stages.length) { + db.stage = db.stage ?? -1 + db.stage++ + if (isNaN(db.stage) || db.stage >= this.config.titleAnimation.stages.length) db.stage = 0 + + // Creating title + let title = this.config.titleAnimation.stages[db.stage] ?? '' + for (const [key, value] of Object.entries(this.config.titleAnimation.vars)) { + title = title.replace('$' + key, value) + } + + player.onScreenDisplay.setHudTitle(title, { + fadeInDuration: 0, + fadeOutDuration: 20, + stayDuration: 40, + subtitle: this.config.subtitle.to(player.lang), + }) + } -/** Выводит строку времени */ -function timeNow(): Text { - const time = new Date(Date()).getHours() + 3 - if (time < 6) return i18n`§9Доброй ночи` - if (time < 12) return i18n`§6Доброе утро` - if (time < 18) return i18n`§bДобрый день` - return i18n`§3Добрый вечер` + // Show actionBar + if (this.config.actionBar) { + player.onScreenDisplay.setActionBar(this.config.actionBar, ActionbarPriority.Highest) + } + } } -// TODO Use date.toHHMMSS -/** Выводит время в формате 00:00 */ -function shortTime(): string { - const time = new Date(Date()) - time.setHours(time.getHours() + 3) - const min = String(time.getMinutes()) - return `${time.getHours()}:${min.length == 2 ? min : '0' + min}` +export abstract class JoinWithMessage extends JoinWithTitle { + protected messages = { + air: i18n.nocolor`§8Очнулся в воздухе`, + ground: i18n.nocolor`§8Проснулся`, + } + + protected sound = 'break.amethyst_cluster' + + protected onJoinMove(where: Join.Where, player: Player) { + const message = this.messages[where] + + this.onJoinMoveMessage(player, where, message) + + for (const other of world.getPlayers()) { + if (other.id === player.id) continue + + const settings = this.getPlayerSettingsWithMessage(other) + if (settings.sound) other.playSound(this.sound) + if (settings.message) other.tell(i18n.nocolor.join`§7${player.name} ${message}`) + } + } + + abstract onJoinMoveMessage(player: Player, where: Join.Where, message: Text): void + + getPlayerSettingsWithMessage = Settings.player(...Join.getPlayerSettings.extend, { + message: { name: i18n`Сообщение`, description: i18n`о входе других игроков`, value: true }, + sound: { name: i18n`Звук`, description: i18n`при входе игроков`, value: true }, + }) } diff --git a/src/lib/player-move.ts b/src/lib/player-move.ts index a4842023..c0581a2a 100644 --- a/src/lib/player-move.ts +++ b/src/lib/player-move.ts @@ -2,6 +2,7 @@ import { Player, ShortcutDimensions, system, world } from '@minecraft/server' import type { Region } from 'lib/region' import { Vec } from 'lib/vector' import { EventSignal } from './event-signal' +import { onLoad } from './utils/game' import { VectorInDimension } from './utils/point' import { WeakPlayerMap } from './weak-player-storage' @@ -31,8 +32,10 @@ export function anyPlayerNearRegion(region: Region, radius: number) { return false } -// Do it sync on first run because some of the funcs above use it sync. It will start interval too -for (const _ of jobPlayerPosition()) void 0 +onLoad(() => { + // Do it sync on first run because some of the funcs above use it sync. It will start interval too + for (const _ of jobPlayerPosition()) void 0 +}) function jobInterval() { system.delay(() => system.runJob(jobPlayerPosition())) diff --git a/src/lib/portals.ts b/src/lib/portals.ts index 38649b96..edae1844 100644 --- a/src/lib/portals.ts +++ b/src/lib/portals.ts @@ -41,6 +41,7 @@ export class Portal { }: { lockAction?: LockActionCheckOptions; fadeScreen?: boolean; title?: string } = {}, updateHud?: VoidFunction, ) { + console.log('Portal teleport') if (!this.canTeleport(player, lockAction)) return if (fadeScreen) this.fadeScreen(player) diff --git a/src/lib/quest/quest.ts b/src/lib/quest/quest.ts index b52150a0..963064b4 100644 --- a/src/lib/quest/quest.ts +++ b/src/lib/quest/quest.ts @@ -12,6 +12,12 @@ import { QuestButton } from './button' import { PlayerQuest } from './player' import { QS } from './step' +declare module '@minecraft/server' { + interface PlayerDatabase { + quests?: Quest.DB + } +} + export declare namespace Quest { interface DB { active: { id: string; i: number; db?: unknown }[] diff --git a/src/lib/recurring-event.ts b/src/lib/recurring-event.ts index bfdae62d..0ddca771 100644 --- a/src/lib/recurring-event.ts +++ b/src/lib/recurring-event.ts @@ -4,6 +4,7 @@ import { fromMsToTicks } from 'lib/utils/ms' import { table } from './database/abstract' import { setDefaults } from './database/defaults' import later, { Later } from './utils/later' +import { onLoad } from './utils/load-ref' later.runtime = { setTimeout: (fn, delayMs) => system.runTimeout(fn, 'laterSetTimeout', fromMsToTicks(delayMs)), @@ -31,11 +32,11 @@ type RecurringEventCallback = (storage: T, ct export class RecurringEvent { static db = table('recurringEvents', () => ({ lastRun: '', storage: {} })) - protected db: DB - protected schedule: Later.Schedule - protected interval: Later.Timer + protected db!: DB + + protected interval!: Later.Timer stop() { this.interval.clear() @@ -57,12 +58,14 @@ export class RecurringEvent { { runAfterOffline = false }: RecurringOptions = {}, ) { this.schedule = later.schedule(scheduleData) - this.db = RecurringEvent.db.get(id) as DB - this.db.storage = setDefaults(this.db.storage, this.createStorage()) + onLoad(() => { + this.db = RecurringEvent.db.get(id) as DB + this.db.storage = setDefaults(this.db.storage, this.createStorage()) - this.interval = later.setInterval(this.run.bind(this), scheduleData) + this.interval = later.setInterval(this.run.bind(this), scheduleData) - if (runAfterOffline) this.run(this.db.lastRun === this.getLastRunDate().toString()) + if (runAfterOffline) this.run(this.db.lastRun === this.getLastRunDate().toString()) + }) } protected run(restoreAfterOffline = false) { diff --git a/src/lib/region/areas/area.ts b/src/lib/region/areas/area.ts index d50182c3..a2482e40 100644 --- a/src/lib/region/areas/area.ts +++ b/src/lib/region/areas/area.ts @@ -1,6 +1,6 @@ import { Dimension, system, world } from '@minecraft/server' -import { i18n, noI18n } from 'lib/i18n/text' -import { stringifyError } from 'lib/util' +import { noI18n } from 'lib/i18n/text' +import { stringifyError, util } from 'lib/util' import { AbstractPoint } from 'lib/utils/point' import { Vec } from 'lib/vector' @@ -18,19 +18,18 @@ export abstract class Area { static loaded = false - static asSaveableArea(this: T) { + static asSaveableArea(this: T, type: string) { const b = this as AreaWithType - world.afterEvents.worldLoad.subscribe(() => { - b.type = new (this as unknown as AreaCreator)({}).type + b.type = type + ;(b.prototype as Area).type = type - if ((this as unknown as typeof Area).loaded) { - throw new Error( - `Registering area type ${b.type} failed. Regions are already restored from json. Registering area should occur on the import-time.`, - ) - } + if ((this as unknown as typeof Area).loaded) { + throw new Error( + `Registering area type ${b.type} failed. Regions are already restored from json. Registering area should occur on the import-time.`, + ) + } - ;(this as unknown as typeof Area).areas.push(b as unknown as AreaWithType) - }) + ;(this as unknown as typeof Area).areas.push(b as unknown as AreaWithType) return b } @@ -39,7 +38,9 @@ export abstract class Area { const area = Area.areas.find(e => e.type === a.t) if (!area) { - console.warn(i18n`[Area][Database] No area found for ${a.t}. Maybe you forgot to register kind or import file?`) + console.warn( + noI18n.warn`[Area][Database] No area found for ${a.t}. Maybe you forgot to register kind or import file?`, + ) return } @@ -51,7 +52,7 @@ export abstract class Area { public dimensionType: DimensionType = 'overworld', ) {} - abstract type: string + type!: string /** Checks if the point is inside the area */ isIn(point: AbstractPoint) { @@ -103,11 +104,13 @@ export abstract class Area { } forEachVector( - callback: (vector: Vector3, isIn: boolean, dimension: Dimension) => void | Promise | symbol, + callback: (vector: Vector3, isIn: boolean, dimension: Dimension) => void | Promise, yieldEach = 10, ) { const { edges, dimension } = this const isIn = (vector: Vector3) => this.isIn({ location: vector, dimensionType: this.dimensionType }) + const { max, min } = this.dimension.heightRange + const stack = new Error().stack return new Promise((resolve, reject) => { system.runJob( @@ -115,7 +118,9 @@ export abstract class Area { try { let i = 0 for (const vector of Vec.forEach(...edges)) { - callback(vector, isIn(vector), dimension) + if (vector.y < min || vector.y > max) continue + + util.catch(() => callback(vector, isIn(vector), dimension), 'Area.forEachVector', stack) i++ if (i % yieldEach === 0) yield } diff --git a/src/lib/region/areas/chunk-cube.ts b/src/lib/region/areas/chunk-cube.ts index 5ed9d2a2..20802a01 100644 --- a/src/lib/region/areas/chunk-cube.ts +++ b/src/lib/region/areas/chunk-cube.ts @@ -19,8 +19,6 @@ class ChunkCube extends Area { super(database, dimensionType) } - type = 'c' - isNear(point: AbstractPoint, distance: number): boolean { const { location: vector, dimensionType } = toPoint(point) if (!this.isOurDimension(dimensionType)) return false @@ -60,4 +58,4 @@ class ChunkCube extends Area { } } -export const ChunkCubeArea = ChunkCube.asSaveableArea() +export const ChunkCubeArea = ChunkCube.asSaveableArea('c') diff --git a/src/lib/region/areas/cut.ts b/src/lib/region/areas/cut.ts index 62097f76..f34f1102 100644 --- a/src/lib/region/areas/cut.ts +++ b/src/lib/region/areas/cut.ts @@ -14,8 +14,6 @@ interface CutDatabase extends JsonObject { } class Cut extends Area { - type = 'cut' - protected parent?: Area constructor(database: CutDatabase, dimensionType?: DimensionType) { @@ -63,4 +61,4 @@ class Cut extends Area { } } -export const CutArea = Cut.asSaveableArea() +export const CutArea = Cut.asSaveableArea('cut') diff --git a/src/lib/region/areas/cylinder.ts b/src/lib/region/areas/cylinder.ts index f691a525..fa1b1fc8 100644 --- a/src/lib/region/areas/cylinder.ts +++ b/src/lib/region/areas/cylinder.ts @@ -3,8 +3,6 @@ import { Vec, VecXZ } from 'lib/vector' import { Area } from './area' class Cylinder extends Area<{ center: { x: number; z: number; y: number }; radius: number; yradius: number }> { - type = 'ss' - isNear(point: AbstractPoint, distance: number): boolean { const { location: vector, dimensionType } = toPoint(point) if (!this.isOurDimension(dimensionType)) return false @@ -45,4 +43,4 @@ class Cylinder extends Area<{ center: { x: number; z: number; y: number }; radiu } } -export const CylinderArea = Cylinder.asSaveableArea() +export const CylinderArea = Cylinder.asSaveableArea('ss') diff --git a/src/lib/region/areas/flattened-sphere.ts b/src/lib/region/areas/flattened-sphere.ts index dcff38e0..a4679b51 100644 --- a/src/lib/region/areas/flattened-sphere.ts +++ b/src/lib/region/areas/flattened-sphere.ts @@ -8,8 +8,6 @@ class FlattenedSphere extends Area<{ rx: number ry: number }> { - type = 'fs' - isNear(point: AbstractPoint, distance: number): boolean { const { location: vector, dimensionType } = toPoint(point) if (!this.isOurDimension(dimensionType)) return false @@ -69,4 +67,4 @@ class FlattenedSphere extends Area<{ } } -export const FlattenedSphereArea = FlattenedSphere.asSaveableArea() +export const FlattenedSphereArea = FlattenedSphere.asSaveableArea('fs') diff --git a/src/lib/region/areas/rectangle.ts b/src/lib/region/areas/rectangle.ts index 71d0ff48..f74ba62f 100644 --- a/src/lib/region/areas/rectangle.ts +++ b/src/lib/region/areas/rectangle.ts @@ -19,8 +19,6 @@ class Rectangle extends Area { super(database, dimensionType) } - type = 'rect' - isNear(point: AbstractPoint, distance: number): boolean { const { location: vector, dimensionType } = toPoint(point) if (!this.isOurDimension(dimensionType)) return false @@ -53,4 +51,4 @@ class Rectangle extends Area { } } -export const RectangleArea = Rectangle.asSaveableArea() +export const RectangleArea = Rectangle.asSaveableArea('rect') diff --git a/src/lib/region/areas/sphere.ts b/src/lib/region/areas/sphere.ts index 3cbee436..16ff711b 100644 --- a/src/lib/region/areas/sphere.ts +++ b/src/lib/region/areas/sphere.ts @@ -3,8 +3,6 @@ import { Vec } from 'lib/vector' import { Area } from './area' class Sphere extends Area<{ center: { x: number; z: number; y: number }; radius: number }> { - type = 's' - isNear(point: AbstractPoint, distance: number): boolean { const { location: vector, dimensionType } = toPoint(point) if (!this.isOurDimension(dimensionType)) return false @@ -40,4 +38,4 @@ class Sphere extends Area<{ center: { x: number; z: number; y: number }; radius: } } -export const SphereArea = Sphere.asSaveableArea() +export const SphereArea = Sphere.asSaveableArea('s') diff --git a/src/lib/region/big-structure.ts b/src/lib/region/big-structure.ts index 714c9a8f..fdf77d5a 100644 --- a/src/lib/region/big-structure.ts +++ b/src/lib/region/big-structure.ts @@ -26,11 +26,14 @@ export class BigRegionStructure extends RegionStructure { false, regionId, Array.isArray(saved) ? (saved as BigStructureSaved[]) : undefined, + false, // entities ) } get exists(): boolean { - return !!world.structureManager.get(`mystructure:${this.bigStructure.prefix}|0`) + return ( + !!world.structureManager.get(`mystructure:${this.bigStructure.prefix}|0`) && this.bigStructure.toJSON().length > 0 + ) } protected get bigStructurePos() { diff --git a/src/lib/region/command.ts b/src/lib/region/command.ts index df4d4eba..d910fb72 100644 --- a/src/lib/region/command.ts +++ b/src/lib/region/command.ts @@ -1,4 +1,4 @@ -import { GameMode, LocationInUnloadedChunkError, MolangVariableMap, Player, system, world } from '@minecraft/server' +import { GameMode, LocationInUnloadedChunkError, MolangVariableMap, Player, system } from '@minecraft/server' import 'lib/command' import { Cooldown } from 'lib/cooldown' import { table } from 'lib/database/abstract' @@ -13,18 +13,20 @@ import { regionForm } from './form' export const regionTypes: { name: string; region: typeof Region; creatable: boolean; displayName: boolean }[] = [] export function registerRegionType(name: string, region: typeof Region, creatable = true, displayName = !creatable) { + // Unique to each region type + if (region.regions === Region.regions) region.regions = [] + regionTypes.push({ name, region, creatable, displayName }) } -const command = new Command('region') +new Command('region') .setDescription(i18n`Управляет регионами`) - .setPermissions('techAdmin') + .setPermissions('admin') .setGroup('public') .executes(regionForm.command) -command - .overload('permdebug') - .setPermissions('everybody') +new Command('regionpermdebug') + .setPermissions('admin') .setGroup('test') .executes(ctx => { Region.permissionDebug = !Region.permissionDebug @@ -51,9 +53,8 @@ const tpdb = table<{ type: string; i: number; enabled: boolean }>('regionTpTest' i: 0, enabled: false, })) -command - .overload('tp') - .setPermissions('techAdmin') +new Command('regiontp') + .setPermissions('admin') .setDescription('Входит в режим телепортации по группе регионов. Полезно для поиска данжа') .setGroup('test') .executes(ctx => { @@ -125,9 +126,8 @@ function updateTpTitle(player: Player) { const db = table<{ enabled: boolean }>('regionBorders', () => ({ enabled: false })) -command - .overload('borders') - .executes(ctx => ctx.player.tell(noI18n`Borders enabled: ${db.get(ctx.player.id).enabled}`)) +new Command('regionborders') + .setPermissions('admin') .boolean('toggle', true) .executes((ctx, newValue = !db.get(ctx.player.id).enabled) => { ctx.player.tell(noI18n`${db.get(ctx.player.id).enabled} -> ${newValue}`) @@ -135,43 +135,44 @@ command db.get(ctx.player.id).enabled = newValue }) -const variables = new MolangVariableMap() -variables.setColorRGBA('color', { red: 0, green: 1, blue: 0, alpha: 0 }) - system.runInterval( () => { if (!db.values().some(e => e.enabled)) return - const players = world.getAllPlayers() - for (const region of Region.getAll()) { - if (!(region.area instanceof SphereArea)) continue + for (const [playerId, value] of db.entriesImmutable()) { + if (!value.enabled) continue + const player = Player.getById(playerId) + if (!player) continue + + const regions = Region.getNear(player, 30) + + const variables = new MolangVariableMap() + variables.setColorRGBA('color', { red: 0, green: 1, blue: 0, alpha: 0 }) - const playersNearRegion = players.filter(e => region.area.isNear(e, 30)) - if (!playersNearRegion.length) continue + for (const region of regions) { + if (!(region.area instanceof SphereArea)) continue - let skip = 0 - region.area.forEachVector((vector, isIn) => { - skip++ - if (skip % 2 === 0) return - if (!Region.getAll().includes(region)) return // deleted + let skip = 0 + region.area.forEachVector((vector, isIn) => { + skip++ + if (skip % 2 === 0) return + if (!Region.getAll().includes(region)) return // deleted - try { - const r = Vec.distance(region.area.center, vector) - if (isIn && r > region.area.radius - 1) { - for (const player of playersNearRegion) { - if (!player.isValid) continue - if (!db.get(player.id).enabled) continue + try { + const r = Vec.distance(region.area.center, vector) + if (isIn && r > region.area.radius - 1) { + if (!player.isValid) return player.spawnParticle('minecraft:wax_particle', vector, variables) } + } catch (e) { + if (e instanceof LocationInUnloadedChunkError) return + throw e } - } catch (e) { - if (e instanceof LocationInUnloadedChunkError) return - throw e - } - }, 100) + }, 200) + } } }, 'region borders', - 40, + 60, ) diff --git a/src/lib/region/config.ts b/src/lib/region/config.ts index a0599e6c..9648f0f1 100644 --- a/src/lib/region/config.ts +++ b/src/lib/region/config.ts @@ -1,6 +1,7 @@ import { BlockTypes, Entity } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEntityTypes } from '@minecraft/vanilla-data' import { CustomEntityTypes } from 'lib/assets/custom-entity-types' +import { onLoad } from 'lib/utils/load-ref' /** All doors and switches in minecraft */ export const DOORS: string[] = [] @@ -14,16 +15,18 @@ export const SWITCHES: string[] = [] /** All gates in minecraft */ export const GATES: string[] = [] -const blocks = BlockTypes.getAll() +onLoad(() => { + const blocks = BlockTypes.getAll() -function fill(target: string[], filter: (params: { id: string }) => boolean) { - for (const value of blocks) if (filter(value)) target.push(value.id) -} + function fill(target: string[], filter: (params: { id: string }) => boolean) { + for (const value of blocks) if (filter(value)) target.push(value.id) + } -fill(DOORS, e => e.id.endsWith('door')) -fill(TRAPDOORS, e => e.id.endsWith('trapdoor')) -fill(SWITCHES, e => /button|lever$/.test(e.id)) -fill(GATES, e => e.id.includes('fence_gate')) + fill(DOORS, e => e.id.endsWith('door')) + fill(TRAPDOORS, e => e.id.endsWith('trapdoor')) + fill(SWITCHES, e => /button|lever$/.test(e.id)) + fill(GATES, e => e.id.includes('fence_gate')) +}) /** A list of all containers a item could be in */ export const BLOCK_CONTAINERS = [ diff --git a/src/lib/region/database.test.ts b/src/lib/region/database.test.ts index 5707ab60..8614e471 100644 --- a/src/lib/region/database.test.ts +++ b/src/lib/region/database.test.ts @@ -1,4 +1,3 @@ -import { Region, RegionIsSaveable } from 'lib' import { ChunkCubeArea } from './areas/chunk-cube' import { SphereArea } from './areas/sphere' import { @@ -8,6 +7,8 @@ import { restoreRegionFromJSON, TEST_clearSaveableRegions, } from './database' +import { RegionIsSaveable } from './kinds/region' +import { Region } from './kinds/region' class TestK1Region extends Region { method() {} diff --git a/src/lib/region/database.ts b/src/lib/region/database.ts index af7a977a..88f0ad80 100644 --- a/src/lib/region/database.ts +++ b/src/lib/region/database.ts @@ -7,7 +7,7 @@ import { Area } from './areas/area' import './areas/cut' import { SphereArea } from './areas/sphere' import { RegionEvents } from './events' -import { RegionIsSaveable, type Region, type RegionPermissions } from './kinds/region' +import { Region, RegionIsSaveable, type RegionPermissions } from './kinds/region' export type RLDB = JsonObject | undefined @@ -43,7 +43,7 @@ export const RegionDatabase = table('region-v2', () => ({ permissions: {}, })) -system.delay(() => { +RegionDatabase.onLoad(() => { system.runJob( (function* regionRestore() { let i = 0 @@ -70,6 +70,9 @@ export function registerSaveableRegion(kind: string, region: typeof Region) { // @ts-expect-error Yes, we ARE breaking typescript region.prototype[RegionIsSaveable] = true + // Unique to each region type + if (region.regions === Region.regions) region.regions = [] + kinds.push(region) } @@ -96,10 +99,10 @@ export function restoreRegionFromJSON([key, regionImmutable]: [string, Immutable const area = Area.fromJson(region.a) if (!area) return - if (!area.isValid()) { - console.warn('[Region][Database] Area', area.toString(), 'is invalid') - return - } + // if (!area.isValid()) { + // console.warn('[Region][Database] Area', area.toString(), 'is invalid') + // return + // } return kind.create(area, region, key) } diff --git a/src/lib/region/explosion.ts b/src/lib/region/explosion.ts new file mode 100644 index 00000000..0c1752bc --- /dev/null +++ b/src/lib/region/explosion.ts @@ -0,0 +1,14 @@ +import { Block, world } from '@minecraft/server' +import { Region } from './kinds/region' +import { SafeAreaRegion } from './kinds/safe-area' + +world.beforeEvents.explosion.subscribe(event => { + event.setImpactedBlocks(event.getImpactedBlocks().filter(canBlockExplode)) +}) + +function canBlockExplode(block: Block) { + const region = Region.getAt(block) + if (region instanceof SafeAreaRegion) return false + + return true +} diff --git a/src/lib/region/form.ts b/src/lib/region/form.ts index 40c02bb8..3ee16aa6 100644 --- a/src/lib/region/form.ts +++ b/src/lib/region/form.ts @@ -1,7 +1,12 @@ import { Player, world } from '@minecraft/server' import { parseArguments, parseLocationArguments } from 'lib/command/utils' +import { ArrayForm } from 'lib/form/array' +import { ModalForm } from 'lib/form/modal' import { form, NewFormCallback, NewFormCreator } from 'lib/form/new' +import { BUTTON, FormCallback } from 'lib/form/utils' import { i18n, noI18n, textTable } from 'lib/i18n/text' +import { inspect } from 'lib/utils/inspect' +import { Vec } from 'lib/vector' import { Area } from './areas/area' import { ChunkCubeArea } from './areas/chunk-cube' import { CylinderArea } from './areas/cylinder' @@ -10,11 +15,6 @@ import { RectangleArea } from './areas/rectangle' import { SphereArea } from './areas/sphere' import { regionTypes } from './command' import { Region } from './kinds/region' -import { ArrayForm } from 'lib/form/array' -import { BUTTON, FormCallback } from 'lib/form/utils' -import { ModalForm } from 'lib/form/modal' -import { Vec } from 'lib/vector' -import { inspect } from 'lib/utils/inspect' export const regionForm = form((f, { player, self }) => { f.title(noI18n`Управление регионами`) @@ -77,20 +77,43 @@ function regionList( }) .show(player) } -const selectArea = form.params<{ onSelect: (area: Area) => NewFormCallback; title: Text }>( + +let getPlayerSelection = (player: Player): { min: Vector3; max: Vector3 } | undefined => { + return +} + +import('../../modules/world-edit/lib/world-edit').then(({ WorldEdit }) => { + getPlayerSelection = player => WorldEdit.forPlayer(player).selection +}) + +export const selectArea = form.params<{ onSelect: (area: Area) => NewFormCallback; title: Text }>( (f, { player, self, params: { onSelect: onS, title } }) => { function onSelect(area: Area) { onS(area)(player, self) } f.title(title) + + const selection = getPlayerSelection(player) + if (selection) { + f.button( + noI18n.accent`Выделенная зона (${Vec.size(selection.min, selection.max)})\n(куб без ограничения высоты)`, + () => onSelect(new ChunkCubeArea({ from: selection.min, to: selection.max }, player.dimension.type)), + ) + + f.button(noI18n.accent`Выделенная зона (${Vec.size(selection.min, selection.max)})\n(куб)`, () => + onSelect(new RectangleArea({ from: selection.min, to: selection.max }, player.dimension.type)), + ) + } + f.button(noI18n`Сфера`, BUTTON['+'], () => { new ModalForm(noI18n`Сфера`) .addTextField(noI18n`Центр`, '~~~', '~~~') - .addSlider(noI18n`Радиус`, 1, 100, 1) + .addSlider(noI18n`Радиус`, 1, 200, 1) .show(player, (ctx, rawCenter, radius) => { const center = parseLocationFromForm(ctx, rawCenter, player) if (!center) return + // TODO: Just ignore the underradius if (center.y - radius <= -64) return player.fail( i18n`Нельзя создать регион, область которого ниже -64 (y: ${center.y} radius: ${radius} result: ${center.y - radius})`, @@ -183,7 +206,7 @@ const regionStructureForm = form.params<{ region: Region; title: Text }>((f, { p }) if (exists) f.ask(noI18n`§cУдалить структуру`, noI18n`§cУдалить`, () => region.structure?.delete()) }) -const editRegion = form.params<{ region: Region; displayName: boolean }>( +export const editRegion = form.params<{ region: Region; displayName: boolean }>( (f, { player, back, self, params: { region, displayName } }) => { const title = displayName ? (region.displayName ?? region.creator.name) : region.name f.title(title) diff --git a/src/lib/region/index.ts b/src/lib/region/index.ts index b1f676d1..07718c67 100644 --- a/src/lib/region/index.ts +++ b/src/lib/region/index.ts @@ -9,7 +9,6 @@ import { } from '@minecraft/server' import { MinecraftEntityTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' import { CustomEntityTypes } from 'lib/assets/custom-entity-types' -import { Items } from 'lib/assets/custom-items' import { PlayerEvents, PlayerProperties } from 'lib/assets/player-json' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { i18n, noI18n } from 'lib/i18n/text' @@ -31,13 +30,13 @@ import { TRAPDOORS, } from './config' import { RegionEvents } from './events' +import './explosion' import { Region } from './kinds/region' export * from './command' export * from './config' export * from './database' -export * from './kinds/boss-arena' export * from './kinds/region' export * from './kinds/road' export * from './kinds/safe-area' @@ -109,7 +108,7 @@ actionGuard((player, region, context) => { if (typeId === MinecraftItemTypes.EnderPearl) return ent.includes(MinecraftEntityTypes.EnderPearl) if (typeId === MinecraftItemTypes.WindCharge) return ent.includes(MinecraftEntityTypes.WindChargeProjectile) if (typeId === MinecraftItemTypes.Snowball) return ent.includes(MinecraftEntityTypes.Snowball) - if (typeId === Items.Fireball) return ent.includes(CustomEntityTypes.Fireball) + if (typeId === CustomEntityTypes.Fireball) return ent.includes(CustomEntityTypes.Fireball) } } }, ActionGuardOrder.ProjectileUsePrevent) @@ -180,10 +179,14 @@ world.afterEvents.entitySpawn.subscribe(({ entity }) => { if ((NOT_MOB_ENTITIES.includes(typeId) && typeId !== 'minecraft:item') || !entity.isValid) return const region = Region.getAt(entity) + if (!region) return // Allow entity spawn outside of region by default - if (isForceSpawnInRegionAllowed(entity) || (typeId === 'minecraft:item' && region?.permissions.allowedAllItem)) return - if (!region || region.permissions.allowedEntities === 'all' || region.permissions.allowedEntities.includes(typeId)) - return + const { allowedAllItem, allowedEntities, disallowedFamilies } = region.permissions + + if (isForceSpawnInRegionAllowed(entity)) return + if (allowedAllItem && typeId === 'minecraft:item') return + if (allowedEntities === 'all' || allowedEntities.includes(typeId)) return + if (disallowedFamilies?.length && entity.matches({ excludeFamilies: disallowedFamilies })) return entity.remove() }) diff --git a/src/lib/region/kinds/minearea.ts b/src/lib/region/kinds/minearea.ts index e40c28c4..5c2e4ee1 100644 --- a/src/lib/region/kinds/minearea.ts +++ b/src/lib/region/kinds/minearea.ts @@ -64,6 +64,7 @@ export class MineareaRegion extends RegionWithStructure { if (this.restoringStructurePromise) return this.restoringStructurePromise this.restoringStructurePromise = this.internalRestoreStructure(eachVectorCallback) + this.restoringStructurePromise.catch((e: unknown) => console.error('MineareaRegion.restoreStructure', e)) const result = await this.restoringStructurePromise delete this.restoringStructurePromise return result diff --git a/src/lib/region/kinds/region.test.ts b/src/lib/region/kinds/region.test.ts index 6621893f..b36e9b2f 100644 --- a/src/lib/region/kinds/region.test.ts +++ b/src/lib/region/kinds/region.test.ts @@ -1,9 +1,10 @@ -import { RegionDatabase, registerSaveableRegion } from 'lib' import { createPoint } from 'lib/utils/point' import { Vec } from 'lib/vector' import { TEST_clearDatabase, TEST_createPlayer } from 'test/utils' import { SphereArea } from '../areas/sphere' import { Region } from './region' +import { registerSaveableRegion } from '../database' +import { RegionDatabase } from '../database' describe('Region', () => { beforeAll(() => { diff --git a/src/lib/region/kinds/region.ts b/src/lib/region/kinds/region.ts index bcbbaada..304672cd 100644 --- a/src/lib/region/kinds/region.ts +++ b/src/lib/region/kinds/region.ts @@ -33,6 +33,8 @@ export interface RegionPermissions extends Record[1] = {}, key?: string, ): InstanceType { - // Make region list actually specific to class - if (this !== Region && this.regionsListType !== this.name) { - this.regions = [] - this.regionsListType = this.name - } + // // Make region list actually specific to class + // if (this !== Region && this.regionsListType !== this.name) { + // this.regions = [] + // this.regionsListType = this.name + // } // if (!area.isValid()) throw new Error('Area ' + area.toString() + 'is invalid') @@ -96,10 +98,10 @@ export class Region { if (!key) { // We are creating new region and should save it region.save() - region.onCreate() + util.catch(() => region.onCreate(), `${this.name}.onCreate`) } else { // Restoring region with existing key - region.onRestore() + util.catch(() => region.onRestore(), `${this.name}.onCreate`) } if (area.radius) this.chunkQuery.add(region) diff --git a/src/lib/region/structure.ts b/src/lib/region/structure.ts index 569f1405..41a7dd00 100644 --- a/src/lib/region/structure.ts +++ b/src/lib/region/structure.ts @@ -95,7 +95,7 @@ export class RegionStructure { yieldEach?: number, ) { const structure = world.structureManager.get(this.id) - if (!structure) throw new ReferenceError('No structure found!') + if (!structure) throw new ReferenceError(`No structure found! ${this.id}`) const [from] = this.region.area.edges const offset = this.offset ? { x: this.offset, y: this.offset, z: this.offset } : undefined diff --git a/src/lib/roles.test.ts b/src/lib/roles.test.ts deleted file mode 100644 index 014b4e23..00000000 --- a/src/lib/roles.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { GameMode, Player } from '@minecraft/server' -import { TEST_createPlayer } from 'test/utils' -import { getRole, is, setRole } from './roles' - -describe('roles auto switch gamemode', () => { - it('should switch gamemode', () => { - const player = TEST_createPlayer() - - expect(player.getGameMode()).toBe(GameMode.Survival) - - expect(getRole(player)).toBe('member') - expect(getRole(player.id)).toBe(getRole(player)) - - setRole(player, 'admin') - expect(getRole(player.id)).toBe('admin') - expect(player.getGameMode()).toBe(GameMode.Survival) - - setRole(player, 'spectator') - expect(player.getGameMode()).toBe(GameMode.Spectator) - - setRole(player, 'member') - expect(player.getGameMode()).toBe(GameMode.Survival) - }) - - it('should return valid role', () => { - // @ts-expect-error - const player = new Player() as Player - - // @ts-expect-error - player.database.role = 'oldrole' - - expect(getRole(player)).toBe('member') - }) - - it('should not throw for unknown player', () => { - setRole('unknown', 'tester') - }) -}) - -describe('roles is', () => { - it('should test is', () => { - const player = TEST_createPlayer() - - expect(is(player.id, 'admin')).toBe(false) - expect(is(player.id, 'member')).toBe(true) - expect(is(player.id, 'spectator')).toBe(true) - - setRole(player, 'admin') - expect(is(player.id, 'admin')).toBe(true) - expect(is(player.id, 'builder')).toBe(true) - expect(is(player.id, 'chefAdmin')).toBe(false) - }) -}) diff --git a/src/modules/commands/role.ts b/src/lib/roles/command.ts similarity index 94% rename from src/modules/commands/role.ts rename to src/lib/roles/command.ts index 20fd51c6..996a4641 100644 --- a/src/modules/commands/role.ts +++ b/src/lib/roles/command.ts @@ -1,15 +1,15 @@ import { Player, world } from '@minecraft/server' -import { FormCallback, ROLES, getRole, setRole } from 'lib' import { ArrayForm } from 'lib/form/array' import { ModalForm } from 'lib/form/modal' +import { FormCallback } from 'lib/form/utils' import { i18n } from 'lib/i18n/text' -import { WHO_CAN_CHANGE } from 'lib/roles' +import { getRole, ROLES, setRole, WHO_CAN_CHANGE } from 'lib/roles' const FULL_HIERARCHY = Object.keys(ROLES) function canChange(who: Role, target: Role, allowSame = false) { if (allowSame && who === target) return true - if (who === 'creator') return true + if (who === 'creator' || who === 'techAdmin') return true return FULL_HIERARCHY.indexOf(who) < FULL_HIERARCHY.indexOf(target) } @@ -19,8 +19,7 @@ const command = new Command('role') .setPermissions('everybody') .executes(ctx => roleMenu(ctx.player)) -const restoreRole = command - .overload('restore') +const restoreRole = new Command('rolerestore') .setDescription(i18n`Восстанавливает вашу роль`) .setPermissions(p => !!p.database.prevRole) .executes(ctx => { @@ -70,7 +69,7 @@ function roleMenu(player: Player) { const button = this.button?.([player.id, player.database], { sort: 'role' }, form, back) if (button) - form.button(i18n`§3Сменить мою роль\n§7(Восстановить потом: §f.role restore§7)`.to(player.lang), button[1]) + form.button(i18n`§3Сменить мою роль\n§7(Восстановить потом: §f/rolerestore§7)`.to(player.lang), button[1]) }) .button(([id, { role, name: dbname }], _, form) => { const target = players.find(e => e.id === id) ?? id diff --git a/src/lib/roles.ts b/src/lib/roles/index.ts similarity index 87% rename from src/lib/roles.ts rename to src/lib/roles/index.ts index 77b41221..c921baa4 100644 --- a/src/lib/roles.ts +++ b/src/lib/roles/index.ts @@ -1,14 +1,22 @@ -import { GameMode, Player, ScriptEventSource, system, world } from '@minecraft/server' +import { GameMode, Player, ScriptEventSource, system } from '@minecraft/server' import { EventSignal } from 'lib/event-signal' -import { isKeyof } from 'lib/util' -import { Core } from './extensions/core' -import { i18n, noI18n } from './i18n/text' +import { Core } from '../extensions/core' +import { i18n, noI18n } from '../i18n/text' +import { isKeyof } from '../util' +import('./command') declare global { /** Any known role */ type Role = keyof typeof ROLES } +declare module '@minecraft/server' { + interface PlayerDatabase { + readonly role: Role + prevRole?: Role + } +} + /** The roles that are in this server */ export const ROLES = { creator: i18n.nocolor`§aРуководство`, @@ -50,7 +58,7 @@ const PERMISSIONS: Record = { * * Also known as role hierarchy */ -export const WHO_CAN_CHANGE: Role[] = ['creator', 'curator', 'techAdmin', 'chefAdmin', 'admin', 'grandBuilder'] +export const WHO_CAN_CHANGE: Role[] = ['creator', 'techAdmin', 'admin', 'moderator', 'helper'] /** * Checks if player has permissions for performing role actions. (e.g. if player role is above or equal) @@ -83,9 +91,10 @@ export function is(playerID: string, role: Role) { * @returns Player role */ export function getRole(playerID: Player | string): Role { - if (playerID instanceof Player) playerID = playerID.id + if (playerID === 'server') return 'creator' - const role = Player.database.getImmutable(playerID).role + const id = playerID instanceof Player ? playerID.id : playerID + const role = Player.database.getImmutable(id).role if (!Object.keys(ROLES).includes(role)) return 'member' return role @@ -130,16 +139,6 @@ Core.beforeEvents.roleChange.subscribe(({ newRole, oldRole, player }) => { } }) -// Set spectator gamemode on join with spectator role -world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { - if (player.isSimulated()) return - if (initialSpawn) { - if (player.database.role === 'spectator') { - player.setGameMode(GameMode.Spectator) - } - } -}) - /* istanbul ignore next */ if (!__VITEST__) { // Allow recieving roles from scriptevent function run by console diff --git a/src/lib/roles/r.ts b/src/lib/roles/r.ts new file mode 100644 index 00000000..d4649695 --- /dev/null +++ b/src/lib/roles/r.ts @@ -0,0 +1 @@ +import './index' diff --git a/src/lib/rpg/airdrop.ts b/src/lib/rpg/airdrop.ts index 56ef53d9..91155ec0 100644 --- a/src/lib/rpg/airdrop.ts +++ b/src/lib/rpg/airdrop.ts @@ -8,7 +8,6 @@ import { createLogger } from 'lib/utils/logger' import { toPoint } from 'lib/utils/point' import { Vec } from 'lib/vector' import { table } from '../database/abstract' -import { Core } from '../extensions/core' import { Temporary } from '../temporary' import { isLocationError } from '../utils/game' import { MinimapNpc, resetMinimapNpcPosition, setMinimapNpcPosition } from './minimap' @@ -300,7 +299,7 @@ const findAndRemove = (arr: Entity[], id: string) => { if (i !== -1) return arr.splice(i, 1)[0] } -Core.afterEvents.worldLoad.subscribe(() => { +Airdrop.db.onLoad(() => { for (const [key, saved] of Airdrop.db.entries()) { if (typeof saved === 'undefined') continue const loot = LootTable.instances.get(saved.loot) diff --git a/src/lib/rpg/boss.ts b/src/lib/rpg/boss.ts index 36bcd761..2660e5b5 100644 --- a/src/lib/rpg/boss.ts +++ b/src/lib/rpg/boss.ts @@ -126,10 +126,10 @@ export class Boss { if (Array.isArray(this.options.allowedEntities)) this.options.allowedEntities.push(options.typeId, MinecraftEntityTypes.Player) - const areadb = Boss.arenaDb.get(this.options.place.id) - this.location = location(options.place) this.location.onLoad.subscribe(center => { + const areadb = Boss.arenaDb.get(this.options.place.id) + this.check() const area = (areadb?.area ? Area.fromJson(areadb.area) : undefined) ?? diff --git a/src/lib/rpg/custom-item.ts b/src/lib/rpg/custom-item.ts index 58302cc9..91bbb2bd 100644 --- a/src/lib/rpg/custom-item.ts +++ b/src/lib/rpg/custom-item.ts @@ -1,14 +1,15 @@ -import { ItemStack, system } from '@minecraft/server' +import { ItemStack } from '@minecraft/server' import { Items } from 'lib/assets/custom-items' import { defaultLang } from 'lib/assets/lang' import { translateTypeId } from 'lib/i18n/lang' import { i18n } from 'lib/i18n/text' +import { MaybeRef, onLoad } from 'lib/utils/game' -export const customItems: ItemStack[] = [] +export const customItems: MaybeRef[] = [] -class CustomItem { - constructor(public id: string) { - system.run(() => this.onBuild()) +export class CustomItem { + constructor(protected _typeId?: string) { + onLoad(() => this.onBuild()) } protected onBuild() { @@ -16,8 +17,6 @@ class CustomItem { customItems.push(this.cache) } - protected _typeId: string | undefined - typeId(typeId: string) { this._typeId = typeId return this @@ -38,7 +37,7 @@ class CustomItem { } get itemStack() { - if (!this._typeId) throw new TypeError('No type id specified for custom item ' + this.id) + if (!this._typeId) throw new TypeError('No type id specified for custom item') const item = new ItemStack(this._typeId).setInfo( this._nameTag && `§6${this._nameTag}`, diff --git a/src/lib/rpg/leaderboard.ts b/src/lib/rpg/leaderboard.ts index afc9bcb5..faadb9e6 100644 --- a/src/lib/rpg/leaderboard.ts +++ b/src/lib/rpg/leaderboard.ts @@ -85,6 +85,7 @@ export class Leaderboard { } update() { + if (this.entity.isValid) this.entity.teleport(this.info.location, { dimension: world[this.info.dimension] }) Leaderboard.db.set(this.entity.id, this.info) } @@ -174,7 +175,7 @@ system.runInterval( } }, 'leaderboardsInterval', - 40, + 100, ) const types = ['', i18nShared`к`, i18nShared`млн`, i18nShared`млрд`, i18nShared`трлн`] diff --git a/src/modules/commands/leaderboard.ts b/src/lib/rpg/leaderboard/command.ts similarity index 98% rename from src/modules/commands/leaderboard.ts rename to src/lib/rpg/leaderboard/command.ts index 28a66b0b..45725eb3 100644 --- a/src/modules/commands/leaderboard.ts +++ b/src/lib/rpg/leaderboard/command.ts @@ -4,8 +4,8 @@ import { Player, world } from '@minecraft/server' import { ActionForm } from 'lib/form/action' import { ModalForm } from 'lib/form/modal' import { BUTTON } from 'lib/form/utils' -import { Leaderboard, LeaderboardInfo } from 'lib/rpg/leaderboard' import { Vec } from 'lib/vector' +import { Leaderboard, LeaderboardInfo } from './index' new Command('leaderboard') .setAliases('leaderboards', 'lb') diff --git a/src/lib/rpg/leaderboard/index.ts b/src/lib/rpg/leaderboard/index.ts new file mode 100644 index 00000000..b6391594 --- /dev/null +++ b/src/lib/rpg/leaderboard/index.ts @@ -0,0 +1,192 @@ +import { + Entity, + Player, + RawMessage, + RawText, + ScoreboardObjective, + ScoreboardScoreInfo, + system, + world, +} from '@minecraft/server' +import { CustomEntityTypes } from 'lib/assets/custom-entity-types' +import { defaultLang } from 'lib/assets/lang' +import { table } from 'lib/database/abstract' +import { scoreboardDisplayNames } from 'lib/database/scoreboard' +import { i18n, i18nShared } from 'lib/i18n/text' +import { isKeyof } from 'lib/util' +import { Vec } from 'lib/vector' +import './command' + +export interface LeaderboardInfo { + style: keyof typeof Leaderboard.styles + objective: string + displayName: string + location: Vector3 + dimension: DimensionType +} + +const biggest = (a: ScoreboardScoreInfo, b: ScoreboardScoreInfo) => b.score - a.score +const smallest = (a: ScoreboardScoreInfo, b: ScoreboardScoreInfo) => a.score - b.score + +export class Leaderboard { + static db = table('leaderboard') + + static tag = 'LEADERBOARD' + + static entityId = CustomEntityTypes.FloatingText + + static formatScore(objectiveId: string, score: number, convertToMetricNumbers = false) { + if (objectiveId.endsWith('SpeedRun')) return i18n.hhmmss(score) + if (objectiveId.endsWith('Time')) return i18n.hhmmss(score * 2.5) + if (objectiveId.endsWith('Date')) return new Date(score * 1000).format() + if (convertToMetricNumbers) return toMetricNumbers(score) + else return score + } + + static styles = { + gray: { objName: '7', fill1: '7', fill2: 'f', pos: '7', nick: 'f', score: '7' }, + white: { objName: 'f', fill1: 'f', fill2: 'f', pos: 'f', nick: 'f', score: 'f' }, + green: { objName: 'a', fill1: '2', fill2: '3', pos: 'a', nick: 'f', score: 'a' }, + } + + private static untypedStyles = this.styles as Record> + + static all = new Map() + + static createLeaderboard({ + objective, + location, + dimension = 'overworld', + style = 'green', + displayName = objective, + }: LeaderboardInfo) { + const entity = world[dimension].spawnEntity(Leaderboard.entityId, Vec.floor(location)) + entity.nameTag = 'updating...' + entity.addTag(Leaderboard.tag) + + return new Leaderboard(entity, { style, objective, location, dimension, displayName }) + } + + /** Creates manager of Leaderboard */ + constructor( + public entity: Entity, + public info: LeaderboardInfo, + ) { + const previous = Leaderboard.all.get(entity.id) + if (previous) return previous + + this.update() + Leaderboard.all.set(entity.id, this) + } + + remove() { + Reflect.deleteProperty(Leaderboard.db, this.entity.id) + Leaderboard.all.delete(this.entity.id) + this.entity.remove() + } + + update() { + if (this.entity.isValid) this.entity.teleport(this.info.location, { dimension: world[this.info.dimension] }) + Leaderboard.db.set(this.entity.id, this.info) + } + + private objective?: ScoreboardObjective + + set scoreboard(v) { + this.objective = v + } + + get scoreboard() { + return ( + this.objective ?? + (this.objective = + world.scoreboard.getObjective(this.info.objective) ?? + world.scoreboard.addObjective(this.info.objective, this.info.displayName)) + ) + } + + get name(): string { + const id = this.objective?.id + if (!id) return 'noname' + if (isKeyof(id, scoreboardDisplayNames)) return scoreboardDisplayNames[id].to(defaultLang) + return this.scoreboard.displayName.toString() + } + + get nameRawText(): RawText | RawMessage { + const id = this.objective?.id + if (!id) return { text: 'noname' } + if (isKeyof(id, scoreboardDisplayNames)) return scoreboardDisplayNames[id].toRawText() + return { text: this.scoreboard.displayName.toString() } + } + + updateLeaderboard() { + if (!this.entity.isValid) return + + // const npc = this.entity.getComponent(EntityComponentTypes.Npc) + // if (!npc) return + + const scoreboard = this.scoreboard + const id = this.scoreboard.id + const name = this.name + const style = Leaderboard.untypedStyles[this.info.style] ?? Leaderboard.styles.gray + const filler = `§${style.fill1}-§${style.fill2}-`.repeat(10) + + // const rawtext: RawMessage[] = [{ text: `§l${style.objName}` }, name, { text: `\n§l${filler}§r\n` }] + let leaderboard = `§l§${style.objName}${name}\n§l${filler}§r\n` + for (const [i, scoreInfo] of scoreboard + .getScores() + .sort(id.endsWith('SpeedRun') ? smallest : biggest) + .slice(0, 10) + .entries()) { + const { pos: t, nick: n, score: s } = style + + const name = Player.nameOrUnknown(scoreInfo.participant.displayName) + + // rawtext.push({ text: `§${t}#${i + 1}§r §${n}${name}§r §${s}` }) + const score = Leaderboard.formatScore(id, scoreInfo.score, true) + leaderboard += `§${t}#${i + 1}§r §${n}${name}§r §${s}${typeof score === 'number' ? score : score.to(defaultLang)}§r\n` + // rawtext.push( + // typeof score === 'string' || typeof score === 'number' ? { text: score.toString() } : score.toRawText(), + // ) + // rawtext.push({ text: '§r\n' }) + } + + this.entity.nameTag = leaderboard + // npc.name = '' + // npc.name = JSON.stringify({ rawtext }) + } +} + +system.runInterval( + () => { + for (const [id, leaderboard] of Leaderboard.db.entriesImmutable()) { + if (typeof leaderboard === 'undefined') continue + const info = Leaderboard.all.get(id) + + if (info?.entity) { + if (info.entity.isValid) info.updateLeaderboard() + } else { + const entity = world[leaderboard.dimension] + .getEntities({ location: leaderboard.location, tags: [Leaderboard.tag], type: Leaderboard.entityId }) + .find(e => e.id === id) + + if (!entity || !entity.isValid || typeof leaderboard === 'undefined') continue + new Leaderboard(entity, leaderboard).updateLeaderboard() + } + } + }, + 'leaderboardsInterval', + 100, +) + +const types = ['', i18nShared`к`, i18nShared`млн`, i18nShared`млрд`, i18nShared`трлн`] + +/** This will display in text in thousands, millions and etc... For ex: "1400 -> "1.4k", "1000000" -> "1M", etc... */ +function toMetricNumbers(value: number) { + const exp = (Math.log10(value) / 3) | 0 + + if (exp === 0) return value.toString() + + const scaled = value / Math.pow(10, exp * 3) + return i18nShared.nocolor.join`${scaled.toFixed(1)}${exp > 5 ? `E${exp}` : types[exp]}` +} diff --git a/src/lib/rpg/loot-table.ts b/src/lib/rpg/loot-table.ts index dfe61977..efd284be 100644 --- a/src/lib/rpg/loot-table.ts +++ b/src/lib/rpg/loot-table.ts @@ -6,6 +6,7 @@ import { EventSignal } from 'lib/event-signal' import { inspect, isKeyof, pick } from 'lib/util' import { copyAllItemPropertiesExceptEnchants } from 'lib/utils/game' import { selectByChance } from './random' +import { CustomItem } from './custom-item' type RandomCostMap = Record<`${number}...${number}` | number, Percent> type Percent = `${number}%` @@ -54,18 +55,18 @@ export class Loot { * @param type Keyof MinecraftItemTypes */ item(type: Exclude | Items) { - this.create(new ItemStack(isKeyof(type, MinecraftItemTypes) ? MinecraftItemTypes[type] : type)) + this.create(() => new ItemStack(isKeyof(type, MinecraftItemTypes) ? MinecraftItemTypes[type] : type)) return this } - itemStack(item: ItemStack | (() => ItemStack)) { - this.create(item) + itemStack(item: CustomItem | (() => ItemStack)) { + this.create(item instanceof CustomItem ? () => item.itemStack : item) return this } - private create(itemStack: ItemStack | (() => ItemStack)) { + private create(itemStack: () => ItemStack) { if (this.current) this.items.push(this.current) this.current = { itemStack, weight: 100, amount: [1], damage: [0], enchantments: {}, custom: [] } } @@ -236,7 +237,7 @@ export class LootTable { let i = length return Array.from({ length }, () => { i-- - if (air > 0) return air--, undefined + if (air > 0) return (air--, undefined) air = Math.randomInt(0, i - (explictItems.length + randomizableItems.length)) diff --git a/src/lib/rpg/menu.ts b/src/lib/rpg/menu.ts index ebad2b3c..f5f07620 100644 --- a/src/lib/rpg/menu.ts +++ b/src/lib/rpg/menu.ts @@ -1,13 +1,13 @@ -import { ContainerSlot, EquipmentSlot, ItemLockMode, ItemStack, ItemTypes, Player, world } from '@minecraft/server' +import { ContainerSlot, EquipmentSlot, ItemLockMode, ItemStack, Player, world } from '@minecraft/server' import { InventoryInterval } from 'lib/action' import { Items } from 'lib/assets/custom-items' import { form } from 'lib/form/new' -import { i18n, i18nShared, noI18n } from 'lib/i18n/text' +import { i18n, noI18n } from 'lib/i18n/text' import { util } from 'lib/util' +import { onLoad } from 'lib/utils/load-ref' import { Vec } from 'lib/vector' import { WeakPlayerMap, WeakPlayerSet } from 'lib/weak-player-storage' import { MinimapNpc, resetMinimapNpcPosition, setMinimapEnabled, setMinimapNpcPosition } from './minimap' -import { Language } from 'lib/assets/lang' export class Menu { static settings: [Text, string] = [i18n`Меню\n§7Разные настройки интерфейсов и меню в игре`, 'menu'] @@ -23,9 +23,11 @@ export class Menu { return item.clone() } - static itemStack = this.createItem() + static itemStack = onLoad(() => this.createItem()) - static item = createPublicGiveItemCommand('menu', this.itemStack, another => this.isMenu(another), i18n`меню`, false) + static item = onLoad(() => + createPublicGiveItemCommand('menu', this.itemStack.value, another => this.isMenu(another), i18n`меню`, false), + ) static { world.afterEvents.itemUse.subscribe(({ source: player, itemStack }) => { @@ -45,7 +47,7 @@ export class Menu { } static isMenu(slot: Pick) { - return this.isCompass(slot) || slot.typeId === this.itemStack.typeId + return this.isCompass(slot) || slot.typeId === this.itemStack.value.typeId } } @@ -66,9 +68,11 @@ export class Compass { } } - private static items = new Array(32).fill(null).map((_, i) => { - return Menu.createItem(`${Items.CompassPrefix}${i}`) - }) + private static items = onLoad(() => + new Array(32).fill(null).map((_, i) => { + return Menu.createItem(`${Items.CompassPrefix}${i}`) + }), + ) /** Map of player as key and compass target as value */ private static players = new WeakPlayerMap() @@ -106,7 +110,7 @@ export class Compass { const target = this.players.get(player) if (!target || player.database.inv === 'spawn') { - if (Menu.isCompass(slot)) slot.setItem(Menu.itemStack) + if (Menu.isCompass(slot)) slot.setItem(Menu.itemStack.value) return } @@ -129,7 +133,7 @@ export class Compass { const angle = Math.atan2(sin, cos) const i = Math.floor((16 * angle) / Math.PI + 16) || 0 - if (typeof i === 'number') return this.items[i] + if (typeof i === 'number') return this.items.value[i] } } diff --git a/src/lib/rpg/minimap.ts b/src/lib/rpg/minimap.ts index 71a8bd69..b8b535cb 100644 --- a/src/lib/rpg/minimap.ts +++ b/src/lib/rpg/minimap.ts @@ -1,5 +1,6 @@ import { Player, world } from '@minecraft/server' import { playerJson, PlayerProperties } from 'lib/assets/player-json' +import { onLoad } from 'lib/utils/load-ref' export function setMinimapEnabled(player: Player, status: boolean) { player.setProperty(PlayerProperties['lw:minimap'], status) @@ -32,7 +33,9 @@ world.afterEvents.playerSpawn.subscribe(event => { resetAllMinimaps(event.player) }) -world.getAllPlayers().forEach(resetAllMinimaps) +onLoad(() => { + world.getAllPlayers().forEach(resetAllMinimaps) +}) function resetAllMinimaps(player: Player) { resetMinimapNpcPosition(player, MinimapNpc.Airdrop) diff --git a/src/lib/rpg/newbie.ts b/src/lib/rpg/newbie.ts index b1c63b31..08ecbb6a 100644 --- a/src/lib/rpg/newbie.ts +++ b/src/lib/rpg/newbie.ts @@ -1,128 +1,113 @@ -import { EntityDamageCause, Player, system, world } from "@minecraft/server"; -import { PlayerProperties } from "lib/assets/player-json"; -import { Cooldown } from "lib/cooldown"; -import { ask } from "lib/form/message"; -import { i18n } from "lib/i18n/text"; -import { Join } from "lib/player-join"; -import { createLogger } from "lib/utils/logger"; -import { ms } from "lib/utils/ms"; +import { EntityDamageCause, Player, system, world } from '@minecraft/server' +import { PlayerProperties } from 'lib/assets/player-json' +import { Cooldown } from 'lib/cooldown' +import { ask } from 'lib/form/message' +import { i18n } from 'lib/i18n/text' +import { Join } from 'lib/player-join' +import { createLogger } from 'lib/utils/logger' +import { ms } from 'lib/utils/ms' -const newbieTime = ms.from("hour", 2); +const newbieTime = ms.from('hour', 2) -const property = PlayerProperties["lw:newbie"]; +const property = PlayerProperties['lw:newbie'] export function isNewbie(player: Player) { - return !!player.database.survival.newbie; + return !!player.database.survival.newbie } export function askForExitingNewbieMode( - player: Player, - reason: Text, - callback: VoidFunction, - back: VoidFunction = () => player.success(i18n`Успешно отменено`) + player: Player, + reason: Text, + callback: VoidFunction, + back: VoidFunction = () => player.success(i18n`Успешно отменено`), ) { - if (!isNewbie(player)) return callback(); + if (!isNewbie(player)) return callback() - ask( - player, - i18n`Если вы совершите это действие, вы потеряете статус новичка: + ask( + player, + i18n`Если вы совершите это действие, вы потеряете статус новичка: - Другие игроки смогут наносить вам урон - Другие игроки смогут забирать ваш лут после смерти`, - i18n.error`Я больше не новичок`, - () => { - exitNewbieMode(player, reason); - callback(); - }, - i18n`НЕТ, НАЗАД`, - back - ); + i18n.error`Я больше не новичок`, + () => { + exitNewbieMode(player, reason) + callback() + }, + i18n`НЕТ, НАЗАД`, + back, + ) } -const logger = createLogger("Newbie"); +const logger = createLogger('Newbie') function exitNewbieMode(player: Player, reason: Text) { - if (!isNewbie(player)) return; + if (!isNewbie(player)) return - player.warn(i18n.warn`Вы ${reason}, поэтому вышли из режима новичка.`); - delete player.database.survival.newbie; - player.setProperty(property, false); + player.warn(i18n.warn`Вы ${reason}, поэтому вышли из режима новичка.`) + delete player.database.survival.newbie + player.setProperty(property, false) - logger.player(player).info`Exited newbie mode because ${reason}`; + logger.player(player).info`Exited newbie mode because ${reason}` } export function enterNewbieMode(player: Player, resetAnarchyOnlineTime = true) { - player.database.survival.newbie = 1; - if (resetAnarchyOnlineTime) player.scores.anarchyOnlineTime = 0; - player.setProperty(property, true); + player.database.survival.newbie = 1 + if (resetAnarchyOnlineTime) player.scores.anarchyOnlineTime = 0 + player.setProperty(property, true) } -Join.onFirstTimeSpawn.subscribe(enterNewbieMode); Join.onMoveAfterJoin.subscribe(({ player }) => { - const value = isNewbie(player); - if (value !== player.getProperty(property)) - player.setProperty(property, value); -}); - -const damageCd = new Cooldown(ms.from("min", 1), false); - -world.afterEvents.entityHurt.subscribe( - ({ hurtEntity, damage, damageSource: { damagingEntity, cause } }) => { - if (!(hurtEntity instanceof Player)) return; - if (damage === -17179869184) return; - - const health = hurtEntity.getComponent("health"); - const denyDamage = () => { - logger.player(hurtEntity) - .info`Recieved damage ${damage}, health ${health?.currentValue}, with cause ${cause}`; - if (health) health.setCurrentValue(health.currentValue + damage); - hurtEntity.teleport(hurtEntity.location); - }; - - if ( - hurtEntity.database.survival.newbie && - cause === EntityDamageCause.fireTick - ) { - denyDamage(); - } else if ( - damagingEntity instanceof Player && - damagingEntity.database.survival.newbie - ) { - if (damageCd.isExpired(damagingEntity)) { - denyDamage(); - askForExitingNewbieMode( - damagingEntity, - i18n`ударили игрока`, - () => void 0, - () => damagingEntity.info(i18n`Будь осторожнее в следующий раз.`) - ); - } else { - exitNewbieMode(damagingEntity, i18n.warn`снова ударили игрока`); - } - } - } -); - -new Command("newbie") - .setPermissions("member") - .setDescription(i18n`Используйте, чтобы выйти из режима новичка`) - .executes((ctx) => { - if (isNewbie(ctx.player)) { - askForExitingNewbieMode( - ctx.player, - i18n`использовали команду`, - () => void 0 - ); - } else return ctx.error(i18n`Вы не находитесь в режиме новичка.`); - }) - .overload("set") - .setPermissions("techAdmin") - .setDescription(i18n`Вводит в режим новичка`) - .executes((ctx) => { - enterNewbieMode(ctx.player); - ctx.player.success(); - }); - -system.runPlayerInterval((player) => { - if (isNewbie(player) && player.scores.anarchyOnlineTime * 2.5 > newbieTime) - exitNewbieMode(player, i18n.warn`провели на анархии больше 2 часов`); -}, "newbie mode exit"); + const value = isNewbie(player) + if (value !== player.getProperty(property)) player.setProperty(property, value) +}) + +const damageCd = new Cooldown(ms.from('min', 1), false) + +world.afterEvents.entityHurt.subscribe(({ hurtEntity, damage, damageSource: { damagingEntity, cause } }) => { + if (!(hurtEntity instanceof Player)) return + if (damage === -17179869184) return + + const health = hurtEntity.getComponent('health') + const denyDamage = () => { + logger.player(hurtEntity).info`Recieved damage ${damage}, health ${health?.currentValue}, with cause ${cause}` + if (health) health.setCurrentValue(health.currentValue + damage) + hurtEntity.teleport(hurtEntity.location) + } + + if (hurtEntity.database.survival.newbie && cause === EntityDamageCause.fireTick) { + denyDamage() + } else if (damagingEntity instanceof Player && damagingEntity.database.survival.newbie) { + if (damageCd.isExpired(damagingEntity)) { + denyDamage() + askForExitingNewbieMode( + damagingEntity, + i18n`ударили игрока`, + () => void 0, + () => damagingEntity.info(i18n`Будь осторожнее в следующий раз.`), + ) + } else { + exitNewbieMode(damagingEntity, i18n.warn`снова ударили игрока`) + } + } +}) + +new Command('newbie') + .setPermissions('member') + .setDescription(i18n`Используйте, чтобы выйти из режима новичка`) + .executes(ctx => { + if (isNewbie(ctx.player)) { + askForExitingNewbieMode(ctx.player, i18n`использовали команду`, () => void 0) + } else return ctx.error(i18n`Вы не находитесь в режиме новичка.`) + }) + .overload('set') + .setPermissions('techAdmin') + .setDescription(i18n`Вводит в режим новичка`) + .executes(ctx => { + enterNewbieMode(ctx.player) + ctx.player.success() + }) + +system.runPlayerInterval(player => { + if (isNewbie(player) && player.scores.anarchyOnlineTime * 2.5 > newbieTime) + exitNewbieMode(player, i18n.warn`провели на анархии больше 2 часов`) +}, 'newbie mode exit') diff --git a/src/lib/scheduled-block-place.ts b/src/lib/scheduled-block-place.ts index 9aceb7d2..0ab1df8f 100644 --- a/src/lib/scheduled-block-place.ts +++ b/src/lib/scheduled-block-place.ts @@ -215,7 +215,13 @@ function* scheduledBlockPlaceJob() { function timeout() { system.runTimeout(() => system.runJob(scheduledBlockPlaceJob()), 'scheduled block place', 10) } -timeout() +DB.overworld.onLoad(() => { + DB.nether.onLoad(() => { + DB.end.onLoad(() => { + timeout() + }) + }) +}) let debugLogging = false @@ -240,7 +246,7 @@ const scheduledDimensionForm = ( system.runJob( (function* placeNow() { let i = 0 - for (const immutableSchedule of schedules.valuesImmutable()) { + for (const immutableSchedule of schedules.valuesIterator()) { if (!immutableSchedule) continue i++ if (i % 100 === 0) yield diff --git a/src/lib/settings.ts b/src/lib/settings.ts index ef7948c5..e3338b66 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -24,9 +24,19 @@ interface ConfigMeta { [SETTINGS_GROUP_NAME]?: Text } +// TODO Create global PaidFeaturesProvider +type SettingsPayCheck = ((player: Player) => boolean) & { onFail: PlayerCallback } + export type SettingsConfig = Record< string, - { name: Text; description?: Text; value: T; onChange?: VoidFunction } + { + name: Text + description?: Text + value: T + onChange?: VoidFunction + paid?: SettingsPayCheck + whenNotPaidDefault?: NoInfer + } > & ConfigMeta @@ -172,7 +182,10 @@ export class Settings { configurable: false, enumerable: true, get() { + const paid = player ? (config[prop]?.paid?.(player) ?? true) : true const value = config[prop]?.value + if (!paid) return config[prop]?.whenNotPaidDefault ?? (typeof value === 'boolean' ? !value : value) + if (typeof value === 'undefined') throw new TypeError(`No config value for prop ${prop}`) return ( (database.getImmutable(groupId) as SettingsDatabaseValue | undefined)?.[key] ?? @@ -254,8 +267,9 @@ export function settingsGroupMenu( const store = Settings.parseConfig(storeSource, groupName, config, forRegularPlayer ? player : null) const buttons: [string, (input: string | boolean) => string][] = [] + const displayName = (config[SETTINGS_GROUP_NAME] ?? groupName).to(player.lang) const form = new ModalForm<(ctx: FormCallback, ...options: (string | boolean)[]) => void>( - (config[SETTINGS_GROUP_NAME] ?? groupName).to(player.lang), + `${displayName.split('\n')[0]}`, ) for (const key in config) { @@ -265,6 +279,8 @@ export function settingsGroupMenu( const value = saved ?? setting.value + const paid = setting.paid?.(player) ?? true + const isUnset = typeof saved === 'undefined' const isRequired = (Reflect.get(setting, 'requires') as boolean) && isUnset const isToggle = typeof value === 'boolean' @@ -273,6 +289,8 @@ export function settingsGroupMenu( label += hints[key] ? `${hints[key]}\n` : '' + if (!paid) label += `§cКУПИТЕ ЧТОБЫ ИСПОЛЬЗОВАТЬ\n` + if (isRequired) label += '§c(!) ' label += `§f§l${setting.name.to(player.lang)}§r§f` //§r @@ -337,6 +355,8 @@ export function settingsGroupMenu( ]) } + form.submitButton('Сохранить') + form.show(player, (_, ...settings) => { const hints: Record = {} diff --git a/src/modules/commands/settings.ts b/src/lib/settings/command.ts similarity index 100% rename from src/modules/commands/settings.ts rename to src/lib/settings/command.ts diff --git a/src/lib/settings/index.test.ts b/src/lib/settings/index.test.ts new file mode 100644 index 00000000..ded2efc6 --- /dev/null +++ b/src/lib/settings/index.test.ts @@ -0,0 +1,24 @@ +import { Settings } from '.' + +describe('setting change events', () => { + it('should emit events on change', () => { + const onChange = vi.fn() + const settings = Settings.world('groupName', 'group1', { + name1: { + name: 'name', + description: 'description', + value: true, + onChange, + }, + }) + + settings.name1 = false + expect(onChange).toHaveBeenCalledOnce() + + settings.name1 = true + expect(onChange).toHaveBeenCalledTimes(2) + + settings.name1 = true + expect(onChange).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/lib/settings/index.ts b/src/lib/settings/index.ts new file mode 100644 index 00000000..d04adaea --- /dev/null +++ b/src/lib/settings/index.ts @@ -0,0 +1,422 @@ +import { Player } from '@minecraft/server' +import { ActionForm } from 'lib/form/action' +import { ModalForm } from 'lib/form/modal' +import { FormCallback } from 'lib/form/utils' +import { stringify } from 'lib/util' +import { createLogger } from 'lib/utils/logger' +import { WeakPlayerMap } from 'lib/weak-player-storage' +import { MemoryTable, Table, table } from '../database/abstract' +import { Message } from '../i18n/message' +import { i18n, noI18n } from '../i18n/text' +import stringifyError from '../utils/error' +import './command' + +// TODO refactor(leaftail1880): Move all types under the Settings namespace +// TODO refactor(leaftail1880): Move everything into the lib/settings/ folder + +type DropdownSetting = [value: string, displayText: Text] + +/** Any setting value type */ +type SettingValue = string | boolean | number | DropdownSetting[] + +export const SETTINGS_GROUP_NAME = Symbol('SettingGroupName') + +interface ConfigMeta { + [SETTINGS_GROUP_NAME]?: Text +} + +// TODO Create global PaidFeaturesProvider +type SettingsPayCheck = ((player: Player) => boolean) & { onFail: PlayerCallback } + +export type SettingsConfig = Record< + string, + { + name: Text + description?: Text + value: T + onChange?: VoidFunction + paid?: SettingsPayCheck + whenNotPaidDefault?: NoInfer + } +> & + ConfigMeta + +/** Сonverting true and false to boolean and string[] to string and string literal to plain string */ +/* eslint-disable @typescript-eslint/naming-convention */ +type toPlain = T extends true | false + ? boolean + : T extends string + ? string + : T extends DropdownSetting[] + ? T[number][0] + : T extends number + ? number + : T + +export type SettingsConfigParsed = { -readonly [K in keyof T]: toPlain } + +export type SettingsDatabaseValue = Record +export type SettingsDatabase = Table + +export type PlayerSettingValues = boolean | string | number | DropdownSetting[] + +type WorldSettingsConfig = SettingsConfig & Record + +export class Settings { + /** Creates typical settings database */ + private static createDatabase(name: string) { + return table(name, () => ({})) + } + + static playerDatabase = this.createDatabase('playerOptions') + + static playerConfigs: Record> = {} + + /** + * It creates a proxy object that has the same properties as the `CONFIG` object, but the values are stored in a + * database + * + * @template Config + * @param groupName - The name that shows to players + * @param groupId - The id for the database. + * @param config - This is an object that contains the default values for each option. + * @returns An function that returns object with properties that are getters and setters. + */ + static player>( + groupName: Text, + groupId: string, + config: Config, + ) { + this.insertGroup('playerConfigs', groupName, groupId, config) + + const cache = new WeakPlayerMap() + + const fn = (player: Player): SettingsConfigParsed => { + const cached = cache.get(player) + if (cached) { + return cached as SettingsConfigParsed + } else { + const settings = this.parseConfig( + Settings.playerDatabase, + groupId, + this.playerConfigs[groupId] as Config, + player, + ) + cache.set(player, settings) + return settings + } + } + + fn.groupId = groupId + fn.groupName = groupName + fn.extend = [groupName, groupId] as const + fn.override = (setting: keyof Config, value: Partial[string]>) => { + for (const [k, v] of Object.entries(value)) { + if (config[setting]) (config[setting] as unknown as Record)[k] = v + } + } + + return fn + } + + static worldDatabase = this.createDatabase('worldOptions') + + static worldConfigs: Record = {} + + /** + * It takes a prefix and a configuration object, and returns a proxy that uses the prefix to store the configuration + * object's properties in localStorage + * + * @template Config + * @param groupId - The id for the database. + * @param config - The default values for the options. + * @returns An object with properties that are getters and setters. + */ + static world( + groupName: Text, + groupId: string, + config: Config, + ): SettingsConfigParsed { + this.insertGroup('worldConfigs', groupName, groupId, config) + return this.parseConfig(Settings.worldDatabase, groupId, this.worldConfigs[groupId] as Config) + } + + static worldCommon = [i18n`Общие настройки мира\n§7Чат, спавн и тд`, 'common'] as const + + private static insertGroup( + to: 'worldConfigs' | 'playerConfigs', + groupName: Text, + groupId: string, + config: SettingsConfig, + ) { + if (!(groupId in this[to])) { + this[to][groupId] = config + } else { + this[to][groupId] = { ...config, ...this[to][groupId] } + } + + this[to][groupId][SETTINGS_GROUP_NAME] = groupName + } + + /** + * It creates a proxy object that allows you to access and modify the values of a given object, but the values are + * stored in a database + * + * @param database - The database. + * @param groupId - The group id of the settings + * @param config - This is the default configuration object. It's an object with the keys being the option names and + * the values being the default values. + * @param player - The player object. + * @returns An object with getters and setters + */ + static parseConfig( + database: SettingsDatabase, + groupId: string, + config: Config, + player: Player | null = null, + ) { + const settings = {} + + for (const prop in config) { + const key = player ? `${player.id}:${prop}` : prop + Object.defineProperty(settings, prop, { + configurable: false, + enumerable: true, + get() { + const paid = player ? (config[prop]?.paid?.(player) ?? true) : true + const value = config[prop]?.value + if (!paid) return config[prop]?.whenNotPaidDefault ?? (typeof value === 'boolean' ? !value : value) + + if (typeof value === 'undefined') throw new TypeError(`No config value for prop ${prop}`) + return ( + (database.getImmutable(groupId) as SettingsDatabaseValue | undefined)?.[key] ?? + (Settings.isDropdown(value) ? value[0]?.[0] : value) + ) + }, + set(v: toPlain) { + Settings.set(database, groupId, key, v, config[prop]) + }, + }) + } + + return settings as SettingsConfigParsed + } + + static set( + database: SettingsDatabase, + groupId: string, + key: string, + v: SettingValue, + configProp = Settings.worldConfigs[groupId]?.[key], + ) { + let value = database.get(groupId) + if (typeof value === 'undefined') { + database.set(groupId, {}) + value = database.get(groupId) + } + value[key] = v + configProp?.onChange?.() + database.set(groupId, value) + } + + static isDropdown(v: SettingValue): v is DropdownSetting[] { + return ( + Array.isArray(v) && + v.length > 0 && + v.every( + e => Array.isArray(e) && (typeof e[1] === 'string' || e[1] instanceof Message) && typeof e[0] === 'string', + ) + ) + } +} + +export function settingsModal>( + player: Player, + config: Config, + settingsStorage: SettingsConfigParsed, + back: VoidFunction, +) { + const propertyName = 'modal' + settingsGroupMenu( + player, + propertyName, + false, + {}, + new MemoryTable({ [propertyName]: settingsStorage }, () => ({})), + { [propertyName]: config }, + back, + false, + ) +} + +const logger = createLogger('Settings') + +// TODO ref(leatail1880): Clenup settingsGroupMenu parameters +export function settingsGroupMenu( + player: Player, + groupName: string, + forRegularPlayer: boolean, + hints: Record = {}, + storeSource = forRegularPlayer ? Settings.playerDatabase : Settings.worldDatabase, + configSource = forRegularPlayer ? Settings.playerConfigs : Settings.worldConfigs, + back = forRegularPlayer ? playerSettingsMenu : worldSettingsMenu, + showHintAboutSavedStatus = true, +) { + const displayType = forRegularPlayer ? 'own' : 'world' + const config = configSource[groupName] + if (!config) throw new TypeError(`No config for groupName ${groupName}`) + + const store = Settings.parseConfig(storeSource, groupName, config, forRegularPlayer ? player : null) + const buttons: [string, (input: string | boolean) => string][] = [] + const displayName = (config[SETTINGS_GROUP_NAME] ?? groupName).to(player.lang) + const form = new ModalForm<(ctx: FormCallback, ...options: (string | boolean)[]) => void>( + `${displayName.split('\n')[0]}`, + ) + + for (const key in config) { + const saved = store[key] as string | number | boolean | undefined + const setting = config[key] + if (!setting) throw new TypeError(`No setting for key ${key}`) + + const value = saved ?? setting.value + + const paid = setting.paid?.(player) ?? true + + const isUnset = typeof saved === 'undefined' + const isRequired = (Reflect.get(setting, 'requires') as boolean) && isUnset + const isToggle = typeof value === 'boolean' + + let label = '' + + label += hints[key] ? `${hints[key]}\n` : '' + + if (!paid) label += `§cКУПИТЕ ЧТОБЫ ИСПОЛЬЗОВАТЬ\n` + + if (isRequired) label += '§c(!) ' + label += `§f§l${setting.name.to(player.lang)}§r§f` //§r + + if (setting.description) label += `§i - ${setting.description.to(player.lang)}` + if (isUnset) label += i18n.nocolor`§8(По умолчанию)\n`.to(player.lang) + + if (isToggle) { + form.addToggle(label, value) + } else if (Settings.isDropdown(setting.value)) { + form.addDropdownFromObject(label, Object.fromEntries(setting.value.map(e => [e[0], e[1].to(player.lang)])), { + defaultValueIndex: Settings.isDropdown(value) ? undefined : value, + }) + } else { + const isString = typeof value === 'string' + + if (!isString) { + label += i18n.nocolor`\n§7§lЗначение:§r ${stringify(value)}`.to(player.lang) + label += i18n.nocolor`\n§7§lТип: §r§f${settingTypes[typeof value] ?? typeof value}`.to(player.lang) + } + + form.addTextField(label, i18n`Настройка не изменится`.to(player.lang), isString ? value : JSON.stringify(value)) + } + + buttons.push([ + key, + input => { + try { + if (typeof input === 'undefined' || input === '') return '' + + let result + if (typeof input === 'boolean' || Settings.isDropdown(setting.value)) { + result = input + } else { + switch (typeof setting.value) { + case 'string': + result = input + break + case 'number': + result = Number(input) + if (isNaN(result)) return i18n.error`Введите число!`.to(player.lang) + break + case 'object': + result = JSON.parse(input) as typeof result + + break + } + } + + if (stringify(store[key]) === stringify(result)) return '' + if (typeof result !== 'undefined') { + logger.player(player).info`Changed ${displayType} setting '${groupName} > ${key}' to '${result}'` + store[key] = result + } + + return showHintAboutSavedStatus ? i18n.success`Сохранено!`.to(player.lang) : '' + } catch (error: unknown) { + logger.player(player).info`Changing ${displayType} setting '${groupName} > ${key}' error: ${error}` + + return stringifyError.isError(error) ? `§c${error.message}` : stringify(error) + } + }, + ]) + } + + form.submitButton('Сохранить') + + form.show(player, (_, ...settings) => { + const hints: Record = {} + + for (const [i, setting] of settings.entries()) { + const button = buttons[i] + if (!button) continue + + const [key, callback] = button + const hint = callback(setting) + + if (hint) hints[key] = hint + } + + if (Object.keys(hints).length) { + // Show current menu with hints + self() + } else { + // No hints, go back to previous menu + back(player) + } + + function self() { + settingsGroupMenu(player, groupName, forRegularPlayer, hints, storeSource, configSource, back) + } + }) +} + +const settingTypes: Partial< + Record<'string' | 'number' | 'object' | 'boolean' | 'symbol' | 'bigint' | 'undefined' | 'function', Text> +> = { string: i18n`Строка`, number: i18n`Число`, object: i18n`JSON-Объект`, boolean: i18n`Переключатель` } + +/** Opens player settings menu */ +export function playerSettingsMenu(player: Player, back?: VoidFunction) { + const form = new ActionForm(i18n`§dНастройки`.to(player.lang)) + if (back) form.addButtonBack(back, player.lang) + + for (const groupName in Settings.playerConfigs) { + const name = Settings.playerConfigs[groupName]?.[SETTINGS_GROUP_NAME] + if (name) form.button(name.to(player.lang), () => settingsGroupMenu(player, groupName, true)) + } + + form.show(player) +} + +export function worldSettingsMenu(player: Player) { + const form = new ActionForm(noI18n`§dНастройки мира`) + + for (const [groupId, group] of Object.entries(Settings.worldConfigs)) { + const database = Settings.worldDatabase.get(groupId) + + let unsetCount = 0 + for (const [key, option] of Object.entries(group)) { + if (option.required && typeof database[key] === 'undefined') unsetCount++ + } + + form.button(i18n.nocolor.join`${group[SETTINGS_GROUP_NAME] ?? groupId}`.badge(unsetCount).to(player.lang), () => { + settingsGroupMenu(player, groupId, false) + }) + } + + form.show(player) +} diff --git a/src/lib/shop/buttons/item-modifier.ts b/src/lib/shop/buttons/item-modifier.ts index 0914f63d..738cfc9d 100644 --- a/src/lib/shop/buttons/item-modifier.ts +++ b/src/lib/shop/buttons/item-modifier.ts @@ -2,7 +2,7 @@ import { ContainerSlot, ItemStack, Player } from '@minecraft/server' import { getAuxOrTexture } from 'lib/form/chest' import { ItemFilter, OnSelect, selectItemForm } from 'lib/form/select-item' import { translateEnchantment, translateTypeId } from 'lib/i18n/lang' -import { i18n } from 'lib/i18n/text' +import { i18n } from 'lib/i18n/text' import { Cost, MultiCost, ShouldHaveItemCost } from '../cost' import { ShopForm, ShopFormSection } from '../form' import { ProductName } from '../product' diff --git a/src/lib/shop/buttons/sellable-item.test.ts b/src/lib/shop/buttons/sellable-item.test.ts index 700bccc6..6031851c 100644 --- a/src/lib/shop/buttons/sellable-item.test.ts +++ b/src/lib/shop/buttons/sellable-item.test.ts @@ -2,8 +2,8 @@ import { MinecraftItemTypes } from '@minecraft/vanilla-data' import { TEST_createPlayer, TEST_onFormOpen } from 'test/utils' import { Shop } from '../shop' -import { doNothing } from 'lib' import 'lib/database/scoreboard' +import { doNothing } from 'lib/util' describe('sellableItem', () => { it('should sell items', () => { diff --git a/src/lib/shop/cost.test.ts b/src/lib/shop/cost.test.ts index 1a60e0e9..b5b9c3f9 100644 --- a/src/lib/shop/cost.test.ts +++ b/src/lib/shop/cost.test.ts @@ -81,11 +81,11 @@ describe('MultiCost', () => { `"§c1.000, §cЯблоко §r§f§cx1, §cНезеритовый топор §r§f§cx1, §4§c10§4lvl"`, ) - expect(cost.failed(player)).toMatchInlineSnapshot(` - "§7§60§7/§61.000§7§f§7 - §c§70§c/§71§c §f§cЯблоко§c - §c§70§c/§71§c §f§cНезеритовый топор§c - §cНужно уровней опыта: §710§c, §70§c/§710§c" + expect(cost.failed(player)).toMatchInlineSnapshot(` + "§7§60§7/§61.000§7§f§7 + §c§70§c/§71§c §f§cЯблоко§c + §c§70§c/§71§c §f§cНезеритовый топор§c + §cНужно уровней опыта: §710§c, §70§c/§710§c" `) }) diff --git a/src/lib/shop/cost/item-cost.ts b/src/lib/shop/cost/item-cost.ts index 06ead61e..3820286a 100644 --- a/src/lib/shop/cost/item-cost.ts +++ b/src/lib/shop/cost/item-cost.ts @@ -1,6 +1,5 @@ import { ContainerSlot, EntityComponentTypes, EquipmentSlot, ItemStack, Player } from '@minecraft/server' import { eqSlots } from 'lib/form/select-item' -import { Message } from 'lib/i18n/message' import { i18n, noI18n } from 'lib/i18n/text' import { itemNameXCount } from '../../utils/item-name-x-count' import { Cost } from '../cost' @@ -19,10 +18,11 @@ export class ItemCost extends Cost { * @param amount - Amount of items to search for. */ constructor( - private readonly item: string | ItemStack, + private readonly item: string | ItemStack | (() => ItemStack), private readonly amount = item instanceof ItemStack ? item.amount : 1, protected is = (itemStack: ItemStack) => { if (typeof this.item === 'string') return itemStack.typeId === this.item + if (typeof this.item === 'function') return this.item().is(itemStack) return this.item.is(itemStack) }, ) { @@ -79,7 +79,11 @@ export class ItemCost extends Cost { toString(player: Player, canBuy?: boolean, amount = true): string { return itemNameXCount( - this.item instanceof ItemStack ? this.item : { typeId: this.item, amount: this.amount }, + this.item instanceof ItemStack + ? this.item + : typeof this.item === 'function' + ? this.item() + : { typeId: this.item, amount: this.amount }, canBuy ? '§7' : '§c', amount, player.lang, diff --git a/src/lib/shop/form.ts b/src/lib/shop/form.ts index 03a8e969..8d85e56f 100644 --- a/src/lib/shop/form.ts +++ b/src/lib/shop/form.ts @@ -1,15 +1,13 @@ -import { ContainerSlot, ItemStack, Player } from '@minecraft/server' +import { ContainerSlot, ItemStack, Player, Potions } from '@minecraft/server' import { MinecraftItemTypes, + MinecraftPotionDeliveryTypes as PotionDelivery, MinecraftPotionEffectTypes as PotionEffects, - MinecraftPotionLiquidTypes as PotionLiquids, - MinecraftPotionModifierTypes as PotionModifiers, } from '@minecraft/vanilla-data' import { shopFormula } from 'lib/assets/shop' import { table } from 'lib/database/abstract' import { ActionForm } from 'lib/form/action' import { getAuxOrTexture, getAuxTextureOrPotionAux } from 'lib/form/chest' -import { Message } from 'lib/i18n/message' import { i18n } from 'lib/i18n/text' import { Cost } from 'lib/shop/cost' import { isKeyof } from 'lib/util' @@ -177,8 +175,8 @@ export class ShopForm { return this } - potion(cost: Cost, effect: PotionEffects, modifier = PotionModifiers.Normal, liquid = PotionLiquids.Regular) { - const item = ItemStack.createPotion({ effect, modifier, liquid }) + potion(cost: Cost, effect: PotionEffects, delivery = PotionDelivery.Consume) { + const item = Potions.resolve(effect, delivery) this.itemStack(item, cost, getAuxTextureOrPotionAux(item)) } diff --git a/src/lib/sidebar.ts b/src/lib/sidebar.ts index dcff2907..192750ed 100644 --- a/src/lib/sidebar.ts +++ b/src/lib/sidebar.ts @@ -1,6 +1,7 @@ import { Player } from '@minecraft/server' -import { util, wrap } from 'lib/util' +import { wrap } from 'lib/util' import { ActionbarPriority } from './extensions/on-screen-display' +import { onLoad } from './utils/game' import { WeakPlayerSet } from './weak-player-storage' type Format = @@ -25,7 +26,7 @@ export class Sidebar { static forceHide = new WeakPlayerSet() - content + content!: SidebarVariables getExtra @@ -54,7 +55,9 @@ export class Sidebar { this.name = name this.getExtra = getExtra this.getOptions = getOptions - this.content = this.init(content) + onLoad(() => { + this.content = this.init(content) + }) Sidebar.instances.push(this) } diff --git a/src/lib/util.ts b/src/lib/util.ts index 46aa81de..50ad931c 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -9,17 +9,16 @@ export const util = { /** Runs the given callback safly. If it throws any error it will be handled */ catch(this: void, fn: () => void | Promise, subtype = 'Handled', originalStack?: string) { const prefix = `§6${subtype}: ` + const add = originalStack ? '\n\n' + stringifyError.stack.get(0, originalStack) : '' try { const promise = fn() if (promise instanceof Promise) { promise.catch((e: unknown) => { - console.error(prefix + stringifyError(e as Error, { omitStackLines: 1 })) + console.error(prefix + stringifyError(e as Error, { omitStackLines: 1 }) + add) }) } } catch (e: unknown) { - console.error( - prefix + stringifyError(e as Error, { omitStackLines: 1 }) + (originalStack ? '\n\n' + originalStack : ''), - ) + console.error(prefix + stringifyError(e as Error, { omitStackLines: 1 }) + add) } }, diff --git a/src/modules/commands/stringifyBenchmarkReult.ts b/src/lib/utils/benchmark.ts similarity index 58% rename from src/modules/commands/stringifyBenchmarkReult.ts rename to src/lib/utils/benchmark.ts index 853a1738..bfc3736a 100644 --- a/src/modules/commands/stringifyBenchmarkReult.ts +++ b/src/lib/utils/benchmark.ts @@ -1,6 +1,41 @@ +import { Command } from 'lib/command' import { TIMERS_PATHES } from 'lib/extensions/system' +import { ActionForm } from 'lib/form/action' import { util } from 'lib/util' +new Command('benchmark') + .setAliases('bench') + .setDescription('Показывает время работы интервалов скриптов') + .setPermissions('techAdmin') + .string('type', true) + .boolean('pathes', true) + .boolean('sort', true) + .array('output', ['form', 'chat', 'log'], true) + .executes((ctx, type = 'timers', timerPathes = false, sort = true, output = 'form') => { + if (!(type in util.benchmark.results)) + return ctx.error( + 'Неизвестный тип бенчмарка! Доступные типы: \n §f' + Object.keys(util.benchmark.results).join('\n '), + ) + + const result = stringifyBenchmarkResult({ type: type, timerPathes, sort }) + + switch (output) { + case 'form': { + const show = () => { + new ActionForm('Benchmark', result) + .button('Refresh', null, show) + .button('Exit', null, () => void 0) + .show(ctx.player) + } + return show() + } + case 'chat': + return ctx.reply(result) + case 'log': + return console.log(result) + } + }) + /** * It takes the benchmark results and sorts them by average time, then it prints them out in a nice format * diff --git a/src/lib/utils/big-structure.ts b/src/lib/utils/big-structure.ts index da6753bf..2218642a 100644 --- a/src/lib/utils/big-structure.ts +++ b/src/lib/utils/big-structure.ts @@ -27,6 +27,7 @@ export class BigStructure extends Cuboid { saveOnCreate = true, date = Date.now().toString(32), private structures: BigStructureSaved[] = [], + private entities = false, ) { super(pos1, pos2) this.prefix = `${prefix}|${date}` @@ -52,7 +53,7 @@ export class BigStructure extends Cuboid { } catch {} world.structureManager.createFromWorld(id, this.dimension, min, max, { - includeEntities: false, + includeEntities: this.entities, includeBlocks: true, saveMode: this.saveMode, }) diff --git a/src/lib/utils/error.ts b/src/lib/utils/error.ts index 9539d84b..f6063a4b 100644 --- a/src/lib/utils/error.ts +++ b/src/lib/utils/error.ts @@ -57,7 +57,7 @@ const stringifyError = Object.assign( [/(.*)\(native\)(.*)/, '§8$1(native)$2§f'], [s => (s.includes('lib') ? `§7${s.replace(/§./g, '')}§f` : s)], // [s => (s.startsWith('§7') ? s : s.replace(/:(\d+)/, ':§6$1§f'))], - [/__init \(index\.js:4\)/, ''], + [/__init \(index\.js:8\)/, ''], ] as [RegExp | ((s: string) => string), string?][], /** Parses stack */ @@ -71,6 +71,8 @@ const stringifyError = Object.assign( .join('\n') } + stack = stack.slice(0, 1000) + const stackArray = stack.split('\n') const mappedStack = stackArray diff --git a/src/lib/utils/game.ts b/src/lib/utils/game.ts index 18929a94..a63ea948 100644 --- a/src/lib/utils/game.ts +++ b/src/lib/utils/game.ts @@ -1,3 +1,5 @@ +export * from './load-ref' + import { Block, GameMode, diff --git a/src/lib/utils/item-name-x-count.ts b/src/lib/utils/item-name-x-count.ts index 9e46b378..f2397435 100644 --- a/src/lib/utils/item-name-x-count.ts +++ b/src/lib/utils/item-name-x-count.ts @@ -1,11 +1,10 @@ -import { ItemPotionComponent, ItemStack, Player } from '@minecraft/server' -import { - MinecraftPotionEffectTypes as PotionEffects, - MinecraftPotionModifierTypes as PotionModifiers, -} from '@minecraft/vanilla-data' +import { ItemStack, Player } from '@minecraft/server' +// import { +// MinecraftPotionEffectTypes as PotionEffects, +// MinecraftPotionDeliveryTypes as PotionDelivery, +// } from '@minecraft/vanilla-data' import { Language } from 'lib/assets/lang' import { langToken, translateToken } from 'lib/i18n/lang' -import { i18n } from 'lib/i18n/text' /** Returns \nx */ export function itemNameXCount( @@ -15,17 +14,17 @@ export function itemNameXCount( player: Player | Language, ): string { const locale = player instanceof Player ? player.lang : player - const potion = item instanceof ItemStack && item.getComponent(ItemPotionComponent.componentId) - if (potion) { - const { potionEffectType: effect, potionLiquidType: liquid, potionModifierType: modifier } = potion + // const potion = item instanceof ItemStack && item.getComponent(ItemPotionComponent.componentId) + // if (potion) { + // const { potionEffectType: effect } = potion - const token = langToken(`minecraft:${liquid.id}_${effect.id}_potion`) - const modifierIndex = modifier.id === PotionModifiers.Normal ? 0 : modifier.id === PotionModifiers.Long ? 1 : 2 - const time = potionModifierToTime[effect.id]?.[modifierIndex] - const modifierS = modifierIndexToS[modifierIndex]?.to(locale) ?? '' + // const token = langToken(`minecraft:${liquid.id}_${effect.id}_potion`) + // const modifierIndex = modifier.id === PotionModifiers.Normal ? 0 : modifier.id === PotionModifiers.Long ? 1 : 2 + // const time = potionModifierToTime[effect.id]?.[modifierIndex] + // const modifierS = modifierIndexToS[modifierIndex]?.to(locale) ?? '' - return `${c}${item.nameTag ?? translateToken(token, locale)}${modifierS}${time ? ` §7${time}` : ''}` - } + // return `${c}${item.nameTag ?? translateToken(token, locale)}${modifierS}${time ? ` §7${time}` : ''}` + // } return `${c}${item.nameTag ? (c ? uncolor(item.nameTag) : item.nameTag).replace(/\n.*/, '') : translateToken(langToken(item), locale)}${amount && item.amount ? ` §r§f${c}x${item.amount}` : ''}` } @@ -34,30 +33,58 @@ function uncolor(t: string) { return t.replaceAll(/§./g, '') } -const modifierIndexToS = ['', i18n` (долгое)`, ' II'] - -// TODO Ensure it works properly for all modifiers -const potionModifierToTime: Record = { - [PotionEffects.Healing]: ['0:45', '2:00', '0:22'], - [PotionEffects.Swiftness]: ['3:00', '8:00', '1:30'], - [PotionEffects.FireResistance]: ['3:00', '8:00', ''], - [PotionEffects.NightVision]: ['3:00', '8:00', ''], - [PotionEffects.Strength]: ['3:00', '8:00', '1:30'], - [PotionEffects.Leaping]: ['3:00', '8:00', '1:30'], - [PotionEffects.WaterBreath]: ['3:00', '8:00', ''], - [PotionEffects.Invisibility]: ['3:00', '8:00', ''], - [PotionEffects.SlowFalling]: ['1:30', '4:00', ''], - - [PotionEffects.Poison]: ['0:45', '2:00', '0:22'], - [PotionEffects.Weakness]: ['1:30', '4:00', ''], - [PotionEffects.Slowing]: ['1:30', '4:00', ''], - [PotionEffects.Harming]: ['', '', ''], - [PotionEffects.Wither]: ['0:40', '', ''], - [PotionEffects.Infested]: ['3:00', '', ''], - [PotionEffects.Weaving]: ['3:00', '', ''], - [PotionEffects.Oozing]: ['3:00', '', ''], - [PotionEffects.WindCharged]: ['3:00', '', ''], - - [PotionEffects.TurtleMaster]: ['0:20', '0:40', '0:20'], - [PotionEffects.None]: ['', '', ''], -} satisfies Record +// const modifierIndexToS = ['', i18n` (долгое)`, ' II'] + +// // TODO Ensure it works properly for all modifiers +// const potionModifierToTime: Record = { +// [PotionEffects.Healing]: ['0:45', '2:00', '0:22'], +// [PotionEffects.Swiftness]: ['3:00', '8:00', '1:30'], +// [PotionEffects.FireResistance]: ['3:00', '8:00', ''], +// [PotionEffects.LongFireResistance]: [], + +// [PotionEffects.Nightvision]: ['3:00', '8:00', ''], +// [PotionEffects.Strength]: ['3:00', '8:00', '1:30'], +// [PotionEffects.Leaping]: ['3:00', '8:00', '1:30'], +// [PotionEffects.WaterBreathing]: ['3:00', '8:00', ''], +// [PotionEffects.Invisibility]: ['3:00', '8:00', ''], +// [PotionEffects.SlowFalling]: ['1:30', '4:00', ''], + +// [PotionEffects.Poison]: ['0:45', '2:00', '0:22'], +// [PotionEffects.Weakness]: ['1:30', '4:00', ''], +// [PotionEffects.Slowness]: ['1:30', '4:00', ''], +// [PotionEffects.Harming]: ['', '', ''], +// [PotionEffects.Wither]: ['0:40', '', ''], +// [PotionEffects.Infested]: ['3:00', '', ''], +// [PotionEffects.Weaving]: ['3:00', '', ''], +// [PotionEffects.Oozing]: ['3:00', '', ''], +// [PotionEffects.WindCharged]: ['3:00', '', ''], + +// [PotionEffects.TurtleMaster]: ['0:20', '0:40', '0:20'], +// [PotionEffects.Awkward]: ['', '', ''], +// [PotionEffects.LongInvisibility]: [], +// [PotionEffects.LongLeaping]: [], +// [PotionEffects.LongMundane]: [], +// [PotionEffects.LongNightvision]: [], +// [PotionEffects.LongPoison]: [], +// [PotionEffects.LongRegeneration]: [], +// [PotionEffects.LongSlowFalling]: [], +// [PotionEffects.LongSlowness]: [], +// [PotionEffects.LongStrength]: [], +// [PotionEffects.LongSwiftness]: [], +// [PotionEffects.LongTurtleMaster]: [], +// [PotionEffects.LongWaterBreathing]: [], +// [PotionEffects.LongWeakness]: [], +// [PotionEffects.Mundane]: [], +// [PotionEffects.Regeneration]: [], +// [PotionEffects.StrongHarming]: [], +// [PotionEffects.StrongHealing]: [], +// [PotionEffects.StrongLeaping]: [], +// [PotionEffects.StrongPoison]: [], +// [PotionEffects.StrongRegeneration]: [], +// [PotionEffects.StrongSlowness]: [], +// [PotionEffects.StrongStrength]: [], +// [PotionEffects.StrongSwiftness]: [], +// [PotionEffects.StrongTurtleMaster]: [], +// [PotionEffects.Thick]: [], +// [PotionEffects.Water]: [] +// } satisfies Record diff --git a/src/lib/utils/load-ref.ts b/src/lib/utils/load-ref.ts new file mode 100644 index 00000000..5482eeb9 --- /dev/null +++ b/src/lib/utils/load-ref.ts @@ -0,0 +1,77 @@ +import { system, world } from '@minecraft/server' +import { util } from 'lib/util' +import stringifyError from './error' + +export class LoadRef { + static loadStarted = false + + static loadFinished = false + + static unwrap(v: MaybeRef): T { + return v instanceof LoadRef ? v.value : v + } + + protected static loaders: (() => void)[] = [] + + static { + world.afterEvents.worldLoad.subscribe(() => { + LoadRef.loadStarted = true + system.runJob( + (function* loadRefJob() { + for (const loader of LoadRef.loaders) { + loader() + yield + } + LoadRef.loadFinished = true + })(), + ) + }) + } + + get value(): T { + throw new Error('Value is not yet loaded! Value defined at: \n' + this.stack) + } + + private stack: string + + constructor(loader: () => T) { + this.stack = stringifyError.stack.get() + LoadRef.loaders.push(() => { + util.catch( + () => { + const value = loader() + Object.defineProperty(this, 'value', { value }) + util.catch( + () => { + for (const waiter of this.waiters) waiter(value) + }, + 'LoadRefWaiterError', + this.stack, + ) + }, + 'LoadRefError', + this.stack, + ) + }) + } + + protected waiters: ((value: T) => void)[] = [] + + onLoad = (waiter: (value: T) => void) => { + this.waiters.push(waiter) + } +} + +export type MaybeRef = T | LoadRef + +export function onLoad(loader: () => T) { + if (LoadRef.loadStarted) + return { + value: loader(), + onLoad(v: (v: T) => void) { + v(this.value) + }, + } + + return new LoadRef(loader) +} diff --git a/src/lib/utils/logger.test.ts b/src/lib/utils/logger.test.ts index 95217c9f..4b2a2377 100644 --- a/src/lib/utils/logger.test.ts +++ b/src/lib/utils/logger.test.ts @@ -1,6 +1,6 @@ -import { util } from 'lib' import { TEST_createPlayer } from 'test/utils' import { createLogger } from './logger' +import { util } from 'lib/util' describe('Logger', () => { it('should create logger that prints debug info', () => { diff --git a/src/lib/utils/ms-old.test.ts b/src/lib/utils/ms-old.test.ts new file mode 100644 index 00000000..b47f69c5 --- /dev/null +++ b/src/lib/utils/ms-old.test.ts @@ -0,0 +1,10 @@ +import { ms } from './ms' +import { msold, ngettext } from './ms-old' + +describe('uhh', () => { + it('works', () => { + expect(msold.remaining(ms.from('min', 5) - 1000).type).toMatchInlineSnapshot(`"минут"`) + expect(msold.remaining(ms.from('min', 5)).type).toMatchInlineSnapshot(`"минут"`) + expect(msold.remaining(ms.from('min', 5) + 1000).type).toMatchInlineSnapshot(`"минут"`) + }) +}) diff --git a/src/lib/utils/ms-old.ts b/src/lib/utils/ms-old.ts new file mode 100644 index 00000000..26772e0c --- /dev/null +++ b/src/lib/utils/ms-old.ts @@ -0,0 +1,109 @@ +type Plurals = [one: string, two: string, five: string] +/** + * Gets plural form based on provided number + * + * @param n - Number + * @param forms - Plurals forms in format `1 секунда 2 секунды 5 секунд` + * @returns Plural form. Currently only Russian supported + */ + +export function ngettext(n: number, [one, few, more]: Plurals): string { + if (!Number.isInteger(n)) return more + return [one, few, more][ + n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 + ] as unknown as string +} + +type Time = 'year' | 'month' | 'day' | 'hour' | 'min' | 'sec' | 'ms' + +// eslint-disable-next-line @typescript-eslint/naming-convention +export class msold { + /** + * Parses the remaining time in milliseconds into a more human-readable format + * + * @example + * const {type, value} = ms.remaining(1000) + * console.log(value + ' ' + type) // 1 секунда + * + * @example + * const {type, value} = ms.remaining(1000 * 60 * 2) + * console.log(value + ' ' + type) // 2 минуты + * + * @example + * const {type, value} = ms.remaining(1000 * 60 * 2, { converters: ['sec' ]}) // only convert to sec + * console.log(value + ' ' + type) // 120 секунд + * + * @param ms - Milliseconds to parse from + * @param options - Convertion options + * @param options.converters - List of types to convert to. If some time was not specified, e.g. ms, the most closest + * type will be used + * @returns - An object containing the parsed time and the type of time (e.g. "days", "hours", etc.) + */ + static remaining( + ms: number, + { + converters: converterTypes = ['sec', 'min', 'hour', 'day'], + friction: frictionOverride, + }: { converters?: Time[]; friction?: number } = {}, + ): { value: string; type: string } { + const converters = converterTypes.map(type => this.converters[type]).sort((a, b) => b.time - a.time) + for (const { time, friction = 0, plurals } of converters) { + const value = ms / time + if (~~value >= 1) { + // Replace all 234.0 values to 234 + const parsedTime = value + .toFixed(frictionOverride ?? friction) + .replace(/(\.[1-9]*)0+$/m, '$1') + .replace(/\.$/m, '') + + return { + value: parsedTime, + type: ngettext(parseInt(parsedTime), plurals), + } + } + } + + return { value: ms.toString(), type: 'миллисекунд' } + } + + /** Converts provided time to ms depending on the type */ + static from(type: Time, num: number) { + return this.converters[type].time * num + } + + private static converters: Record = { + ms: { + time: 1, + plurals: ['миллисекунду', 'миллисекунды', 'миллисекунд'], + }, + sec: { + time: 1000, + plurals: ['секунда', 'секунды', 'секунд'], + }, + min: { + time: 1000 * 60, + plurals: ['минуту', 'минуты', 'минут'], + friction: 1, + }, + hour: { + time: 1000 * 60 * 60, + plurals: ['час', 'часа', 'часов'], + friction: 1, + }, + day: { + time: 1000 * 60 * 60 * 24, + plurals: ['день', 'дня', 'дней'], + friction: 2, + }, + month: { + time: 1000 * 60 * 60 * 24 * 30, + plurals: ['месяц', 'месяца', 'месяцев'], + friction: 2, + }, + year: { + time: 1000 * 60 * 60 * 24 * 30 * 12, + plurals: ['год', 'года', 'лет'], + friction: 3, + }, + } +} diff --git a/src/lib/utils/ms.ts b/src/lib/utils/ms.ts index 802a6b46..34507bd5 100644 --- a/src/lib/utils/ms.ts +++ b/src/lib/utils/ms.ts @@ -1,6 +1,6 @@ /* i18n-ignore */ -type Time = 'year' | 'month' | 'day' | 'hour' | 'min' | 'sec' | 'ms' +export type Time = 'year' | 'month' | 'day' | 'hour' | 'min' | 'sec' | 'ms' // eslint-disable-next-line @typescript-eslint/naming-convention export class ms { diff --git a/src/lib/utils/rewards.ts b/src/lib/utils/rewards.ts index 7eb6c39f..9be8d1c2 100644 --- a/src/lib/utils/rewards.ts +++ b/src/lib/utils/rewards.ts @@ -89,7 +89,7 @@ export class Rewards { */ give(player: Player, tell = true): Rewards { for (const reward of this.entries) Rewards.giveOne(player, reward) - if (tell) player.success(i18n`Вы получили награды!`) + if (tell && this.entries.length) player.success(i18n`Вы получили награды!`) return this } diff --git a/src/lib/utils/singleton.test.ts b/src/lib/utils/singleton.test.ts new file mode 100644 index 00000000..16e6e41b --- /dev/null +++ b/src/lib/utils/singleton.test.ts @@ -0,0 +1,43 @@ +import { Singleton } from './singleton' + +describe('Singleton', () => { + it('should create singleton', () => { + class test extends Singleton {} + + new test() + + test.getInstance() + + class b extends Singleton {} + + expect(() => b.getInstance()).toThrow() + + new b() + }) + + it('should use singleton from subclass', () => { + class Parent extends Singleton {} + + class Sub extends Parent {} + + const instance = new Sub() + + expect(() => Parent.getInstance()).not.toThrow() + expect(Parent.getInstance()).toBe(instance) + }) + + it('should use singleton from subclass', () => { + class Parent extends Singleton {} + + class Sub extends Parent {} + + class Sub2 extends Sub {} + + const instance = new Sub2() + + expect(() => Sub.getInstance()).not.toThrow() + expect(Sub.getInstance()).toBe(instance) + expect(() => Parent.getInstance()).not.toThrow() + expect(Parent.getInstance()).toBe(instance) + }) +}) diff --git a/src/lib/utils/singleton.ts b/src/lib/utils/singleton.ts new file mode 100644 index 00000000..958e7d66 --- /dev/null +++ b/src/lib/utils/singleton.ts @@ -0,0 +1,34 @@ +export class Singleton { + private static instance?: Singleton + + private static where?: string + + static getInstance(this: abstract new (...args: any) => T) { + const self = this as unknown as typeof Singleton + if (!self.instance) throw new Error('getInstance: ' + self.name + ' is not initialized!') + return self.instance as T + } + + constructor() { + const singleton = this.constructor as typeof Singleton + if (singleton.instance) { + throw new Error(`${singleton.name} is already initialized! ${singleton.where}`) + } + + const stack = new Error().stack + + singleton.instance = this + singleton.where = stack + + let prototype + let limit = 10 + do { + prototype = Object.getPrototypeOf(prototype ?? singleton) as typeof Singleton + if (prototype === Singleton) break + + prototype.instance ??= this + prototype.where = stack + limit-- + } while (limit) + } +} diff --git a/src/lib/utils/structure.test.ts b/src/lib/utils/structure.test.ts index 681af47e..2d81fe43 100644 --- a/src/lib/utils/structure.test.ts +++ b/src/lib/utils/structure.test.ts @@ -1,5 +1,5 @@ import { StructureRotation } from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { structureLikeRotate, toAbsolute, toRelative } from './structure' describe('structureLikeRotate', () => { diff --git a/src/modules/anticheat/dupe.ts b/src/modules/anticheat/dupe.ts deleted file mode 100644 index f8a9c96c..00000000 --- a/src/modules/anticheat/dupe.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { system, world } from '@minecraft/server' -import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Vec } from 'lib' - -world.afterEvents.pistonActivate.subscribe(event => { - const blocks = event.piston.getAttachedBlocksLocations() - - system.runTimeout( - () => { - if (!event.block.isValid) return - for (const blockl of blocks) { - const block = event.block.dimension.getBlock(blockl) - if (block?.typeId === MinecraftBlockTypes.Hopper) { - const nearbyPlayers = event.block.dimension.getPlayers({ location: event.block.location, maxDistance: 20 }) - console.warn( - `PISTON DUPE DETECTED!!! ${Vec.string(event.block.location)}\n${nearbyPlayers.map(e => e.name).join('\n')}`, - ) - event.block.dimension.createExplosion(event.block.location, 1, { breaksBlocks: true }) - return - } - } - }, - 'piston dupe prevent', - 2, - ) -}) diff --git a/src/modules/anticheat/index.ts b/src/modules/anticheat/index.ts deleted file mode 100644 index a01b86d2..00000000 --- a/src/modules/anticheat/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import './dupe' -import './forbidden-items' -import './whitelist' diff --git a/src/modules/chat/chat.ts b/src/modules/chat/chat.ts deleted file mode 100644 index 3d181dc6..00000000 --- a/src/modules/chat/chat.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { world } from '@minecraft/server' -import { Sounds } from 'lib/assets/custom-sounds' -import { sendPacketToStdout } from 'lib/bds/api' -import { Cooldown } from 'lib/cooldown' -import { table } from 'lib/database/abstract' -import { getFullname } from 'lib/get-fullname' -import { i18n, noI18n } from 'lib/i18n/text' -import { Settings } from 'lib/settings' -import { muteInfo } from './mute' - -export class Chat { - static muteDb = table<{ mutedUntil: number; reason?: string }>('chatMute') - - static settings = Settings.world(...Settings.worldCommon, { - cooldown: { - name: 'Задержка чата (миллисекунды)', - description: '0 что бы отключить', - value: 0, - onChange: () => this.updateCooldown(), - }, - range: { - name: 'Радиус чата', - description: 'Радиус для скрытия сообщений дальних игроков', - value: 30, - }, - capsLimit: { - name: 'Макс больших букв в сообщении', - description: 'Не разрешает отправлять сообщения где слишком много капса', - value: 5, - }, - role: { - name: 'Роли в чате', - value: true, - }, - }) - - static playerSettings = Settings.player(i18n`Чат\n§7Звуки и внешний вид чата`, 'chat', { - sound: { - name: i18n`Звук`, - description: i18n`Звука сообщений от игроков поблизости`, - value: true, - }, - }) - - private static cooldown: Cooldown - - private static updateCooldown() { - this.cooldown = new Cooldown(this.settings.cooldown, true, {}) - } - - static { - this.updateCooldown() - Command.chatSendListener = event => { - if (Command.isCommand(event.message)) return - - try { - if (!this.cooldown.isExpired(event.sender)) return - const player = event.sender - - if (!this.cooldown.isExpired(event.sender)) { - console.log('Spam chat', player.name, event.message) - return - } - - const mute = this.muteDb.getImmutable(event.sender.id) - if (mute) { - if (mute.mutedUntil > Date.now()) { - console.log('Muted chat', player.name, event.message) - return muteInfo(player, mute) - } - } - - const messageText = event.message.replace(/\\n/g, '\n').replace(/§./g, '').trim() - - const caps = messageText.split('').reduce((p, c) => (c !== c.toLowerCase() ? p + 1 : p), 0) - if (caps > this.settings.capsLimit) { - return event.sender.fail(noI18n.error`В сообщении слишком много капса (${caps}/${this.settings.capsLimit})`) - } - - const allPlayers = world.getAllPlayers() - - // Players that are near message sender - const nearPlayers = event.sender.dimension - .getPlayers({ - location: event.sender.location, - maxDistance: this.settings.range, - }) - .filter(e => e.id !== event.sender.id && e.dimension.id === event.sender.dimension.id) - - // Array with ranged players (include sender id) - const nID = nearPlayers.map(e => e.id) - nID.push(event.sender.id) - - // Outranged players - const otherPlayers = allPlayers.filter(e => !nID.includes(e.id)) - const message = `${getFullname(event.sender, { nameColor: '§7', equippment: true })}§r: ${messageText}` - const fullrole = getFullname(event.sender, { name: false }) - - if (__SERVER__) { - // This is handled/parsed by ServerCore - // Dont really want to do request each time here - sendPacketToStdout('chatMessage', { - name: event.sender.name, - role: fullrole, - print: message, - message: messageText, - }) - } - - for (const near of nearPlayers) { - near.tell(message) - if (this.playerSettings(near).sound) near.playSound(Sounds.Click) - } - - for (const outranged of otherPlayers) { - outranged.tell(`${getFullname(event.sender, { nameColor: '§8' })}§7: ${messageText}`) - } - - event.sender.tell(message) - } catch (error) { - console.error(error) - } - } - } -} diff --git a/src/modules/chat/mute.ts b/src/modules/chat/mute.ts deleted file mode 100644 index 3dd1f591..00000000 --- a/src/modules/chat/mute.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Player } from '@minecraft/server' -import { ArrayForm } from 'lib/form/array' -import { ModalForm } from 'lib/form/modal' -import { form } from 'lib/form/new' -import { selectPlayer } from 'lib/form/select-player' -import { getFullname } from 'lib/get-fullname' -import { noI18n } from 'lib/i18n/text' -import { ms } from 'lib/utils/ms' -import { Chat } from './chat' - -export function muteInfo( - player: Player, - mute: { readonly mutedUntil: number; readonly reason?: string | undefined }, -): void { - return player.fail( - noI18n.error`Вы замьючены в чате до ${new Date(mute.mutedUntil).toYYYYMMDD()} ${new Date(mute.mutedUntil).toHHMM()}${mute.reason ? noI18n.error` по причине: ${mute.reason}` : ''}`, - ) -} -new Command('mute') - .setDescription('Заглушить игрока в чате') - .setPermissions('helper') - .executes(ctx => { - selectPlayer(ctx.player, 'замутить').then(e => { - new ModalForm('Мут ' + e.name) - .addTextField('Время', 'введи', '5') - .addDropdownFromObject('Тип времени', { - min: 'Минуты', - hour: 'Часы', - }) - .addTextField('Причина', 'опиши чтобы знал что делать нельзя') - .show(ctx.player, (formctx, timeRaw, type, reason) => { - const time = parseInt(timeRaw) - if (isNaN(time)) return formctx.error(`${timeRaw} это не число`) - - const actualTime = ms.from(type, time) - Chat.muteDb.set(e.id, { mutedUntil: Date.now() + actualTime, reason }) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (e.player) muteInfo(e.player, Chat.muteDb.get(e.id)!) - ctx.player.success() - }) - }) - }) -new Command('unmute') - .setDescription('Вернуть обратно') - .setPermissions('helper') - .executes(ctx => { - new ArrayForm('Муты', Chat.muteDb.entries()) - .button(([id, info]) => { - if (!info) return false - const until = `До: ${new Date(info.mutedUntil).toYYYYMMDD()} ${new Date(info.mutedUntil).toHHMM()}` - return [ - `${getFullname(id)} ${until}\n${info.reason}`, - form((f, { self }) => { - f.title(getFullname(id)) - f.body(`Причина: ${info.mutedUntil}\n${until}`) - f.button('Размутить', () => { - Chat.muteDb.delete(id) - self() - }) - }).show, - ] - }) - .show(ctx.player) - }) diff --git a/src/modules/commands/ban.ts b/src/modules/commands/ban.ts deleted file mode 100644 index 5cec3b70..00000000 --- a/src/modules/commands/ban.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO Implement ban diff --git a/src/modules/commands/camera.ts b/src/modules/commands/camera.ts index 6f620866..c19dbed8 100644 --- a/src/modules/commands/camera.ts +++ b/src/modules/commands/camera.ts @@ -1,5 +1,5 @@ -import { restorePlayerCamera } from 'lib' import { i18n } from 'lib/i18n/text' +import { restorePlayerCamera } from 'lib/utils/game' new Command('camera').setDescription(i18n`Возвращает камеру в исходное состояние`).executes(ctx => { restorePlayerCamera(ctx.player, 1) diff --git a/src/modules/commands/gamemode.ts b/src/modules/commands/gamemode.ts index cfe527e0..24c90533 100644 --- a/src/modules/commands/gamemode.ts +++ b/src/modules/commands/gamemode.ts @@ -2,9 +2,12 @@ import { GameMode } from '@minecraft/server' import { MinecraftEffectTypes } from '@minecraft/vanilla-data' -import { is, isNotPlaying, Temporary } from 'lib' + import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { i18n, noI18n } from 'lib/i18n/text' +import { Temporary } from 'lib/temporary' +import { is } from 'lib/roles' +import { isNotPlaying } from 'lib/utils/game' import { WeakPlayerMap } from 'lib/weak-player-storage' function fastGamemode(mode: GameMode, shorname: string) { diff --git a/src/modules/commands/help.ts b/src/modules/commands/help.ts index b9c9de48..0944ea87 100644 --- a/src/modules/commands/help.ts +++ b/src/modules/commands/help.ts @@ -1,10 +1,12 @@ import { Player } from '@minecraft/server' -import { ROLES, getRole } from 'lib' + import { defaultLang } from 'lib/assets/lang' import { CmdLet } from 'lib/command/cmdlet' import { Command } from 'lib/command/index' import { commandNoPermissions, commandNotFound } from 'lib/command/utils' import { i18n, noI18n } from 'lib/i18n/text' +import { ROLES } from 'lib/roles' +import { getRole } from 'lib/roles' const help = new Command('help') .setDescription(i18n`Выводит список команд`) diff --git a/src/modules/commands/index.ts b/src/modules/commands/index.ts index c1af2255..82b917ff 100644 --- a/src/modules/commands/index.ts +++ b/src/modules/commands/index.ts @@ -1,20 +1,16 @@ import './camera' -import './db' import './gamemode' import './help' import './items' import './kill' -import './leaderboard' -import './mail' import './name' import './pid' import './ping' -import './role' +import './player' import './rtp' import './rules' import './scores' import './send' -import './settings' import './shell' import './sit' import './socials' @@ -22,4 +18,3 @@ import './stats' import './tp' import './version' import './wipe' -import './player' diff --git a/src/modules/commands/items.ts b/src/modules/commands/items.ts index fc8bffc7..5dd0b082 100644 --- a/src/modules/commands/items.ts +++ b/src/modules/commands/items.ts @@ -1,4 +1,6 @@ -import { ArrayForm, langToken, translateToken } from 'lib' +import { langToken } from 'lib/i18n/lang' +import { translateToken } from 'lib/i18n/lang' +import { ArrayForm } from 'lib/form/array' import { noI18n } from 'lib/i18n/text' import { customItems } from 'lib/rpg/custom-item' diff --git a/src/modules/commands/mail.ts b/src/modules/commands/mail.ts deleted file mode 100644 index 27713121..00000000 --- a/src/modules/commands/mail.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Player } from "@minecraft/server"; -import { ActionForm, ArrayForm, Mail, Menu, Settings, ask } from "lib"; -import { i18n, i18nPlural } from "lib/i18n/text"; -import { Join } from "lib/player-join"; -import { Rewards } from "lib/utils/rewards"; - -const command = new Command("mail") - .setDescription(i18n`Посмотреть входящие сообщения почты`) - .setPermissions("member") - .executes((ctx) => mailMenu(ctx.player)); - -const getSettings = Settings.player(...Menu.settings, { - mailReadOnOpen: { - name: i18n`Читать письмо при открытии`, - description: i18n`Помечать ли письмо прочитанным при открытии`, - value: true, - }, - mailClaimOnDelete: { - name: i18n`Собирать награды при удалении`, - description: i18n`Собирать ли награды при удалении письма`, - value: true, - }, -}); - -const getJoinSettings = Settings.player(...Join.settings.extend, { - unreadMails: { - name: i18n`Почта`, - description: i18n`Показывать ли при входе сообщение с кол-вом непрочитанных`, - value: true, - }, -}); - -export function mailMenu(player: Player, back?: VoidFunction) { - new ArrayForm( - i18n`Почта`.badge(Mail.getUnreadMessagesCount(player.id)), - Mail.getLetters(player.id) - ) - .filters({ - unread: { - name: i18n`Непрочитанные`, - description: i18n`Показывать только непрочитанные сообщения`, - value: false, - }, - unclaimed: { - name: i18n`Несобранные награды`, - description: i18n`У письма есть несобранные награды`, - value: false, - }, - sort: { - name: i18n`Соритровать по`, - value: [ - ["date", i18n`Дате`], - ["name", i18n`Имени`], - ], - }, - }) - .button(({ letter, index }) => { - const name = `${letter.read ? "§7" : "§f"}${letter.title}${ - letter.read ? "\n§8" : "§c*\n§7" - }${letter.content}`; - return [ - name, - () => { - letterDetailsMenu({ letter, index }, player); - if (getSettings(player).mailReadOnOpen) - Mail.readMessage(player.id, index); - }, - ]; - }) - .sort((keys, filters) => { - if (filters.unread) keys = keys.filter((letter) => !letter.letter.read); - - if (filters.unclaimed) - keys = keys.filter((letter) => !letter.letter.rewardsClaimed); - - filters.sort === "name" - ? keys.sort((letterA, letterB) => - letterA.letter.title.localeCompare(letterB.letter.title) - ) - : keys.reverse(); - - return keys; - }) - .back(back) - .show(player); -} - -function letterDetailsMenu( - { letter, index }: ReturnType<(typeof Mail)["getLetters"]>[number], - player: Player, - back = () => mailMenu(player), - message = "" -) { - const settings = getSettings(player); - // TODO Fix collors - // TODO Rewrite to use new form - const form = new ActionForm( - letter.title, - i18n`${message}${letter.content}\n\n§l§fНаграды:§r\n${Rewards.restore( - letter.rewards - ).toString(player)}`.to(player.lang) - ).addButtonBack(back, player.lang); - - if (!letter.rewardsClaimed && letter.rewards.length) - if (player.database.inv !== "anarchy") { - form.button(i18n.disabled`Забрать награду`.to(player.lang), () => - letterDetailsMenu( - { letter, index }, - player, - back, - i18n.error`Вы не можете забрать награды не находясь на анархии`.to( - player.lang - ) - ) - ); - } else { - form.button(i18n`Забрать награду`.to(player.lang), () => { - Mail.claimRewards(player, index); - letterDetailsMenu( - { letter, index }, - player, - back, - message + i18n.success`Награда успешно забрана!\n\n`.to(player.lang) - ); - }); - } - - if (!letter.read && !settings.mailReadOnOpen) - form.button(i18n`Пометить как прочитанное`.to(player.lang), () => { - Mail.readMessage(player.id, index); - back(); - }); - - let deleteDescription = i18n.error`Удалить письмо?`.to(player.lang); - if (!letter.rewardsClaimed) { - if (getSettings(player).mailClaimOnDelete) { - deleteDescription += i18n` Все награды будут собраны автоматически`.to( - player.lang - ); - } else { - deleteDescription += - i18n` Вы потеряете все награды, прикрепленные к письму!`.to( - player.lang - ); - } - } - - form.button(i18n.error`Удалить письмо`.to(player.lang), null, () => { - ask(player, deleteDescription, i18n`Удалить`, () => { - if (getSettings(player).mailClaimOnDelete) - Mail.claimRewards(player, index); - Mail.deleteMessage(player, index); - back(); - }); - }); - - form.show(player); -} - -Join.onMoveAfterJoin.subscribe(({ player }) => { - if (!getJoinSettings(player).unreadMails) return; - - const unreadCount = Mail.getUnreadMessagesCount(player.id); - if (unreadCount === 0) return; - - player.info( - i18n.join`${i18n.header`Почта:`} ${i18nPlural`У вас ${unreadCount} непрочитанных сообщений!`} ${command}` - ); -}); diff --git a/src/modules/commands/mute.ts b/src/modules/commands/mute.ts deleted file mode 100644 index f694668b..00000000 --- a/src/modules/commands/mute.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO Implement mute diff --git a/src/modules/commands/pid.ts b/src/modules/commands/pid.ts index 2d6650d7..ffe7201e 100644 --- a/src/modules/commands/pid.ts +++ b/src/modules/commands/pid.ts @@ -1,7 +1,9 @@ import { Player } from '@minecraft/server' -import { is, ModalForm } from 'lib' +import { ModalForm } from 'lib/form/modal' + import { selectPlayer } from 'lib/form/select-player' import { i18n, noI18n } from 'lib/i18n/text' +import { is } from 'lib/roles' new Command('pid') .setDescription(i18n`Выдает ваш айди`) diff --git a/src/modules/commands/player.ts b/src/modules/commands/player.ts index 4a6416cd..79c5e29c 100644 --- a/src/modules/commands/player.ts +++ b/src/modules/commands/player.ts @@ -1,67 +1,58 @@ -import { Player } from "@minecraft/server"; -import { is, Portal, stringify } from "lib"; -import { Achievement } from "lib/achievements/achievement"; -import { LoreForm } from "lib/form/lore"; -import { form } from "lib/form/new"; -import { selectPlayer } from "lib/form/select-player"; -import { i18n } from "lib/i18n/text"; -import { statsForm } from "./stats"; +import { Player } from '@minecraft/server' +import { Achievement } from 'lib/achievements/achievement' +import { LoreForm } from 'lib/form/lore' +import { form } from 'lib/form/new' +import { selectPlayer } from 'lib/form/select-player' +import { i18n } from 'lib/i18n/text' +import { Portal } from 'lib/portals' +import { is } from 'lib/roles' +import { stringify } from 'lib/util' +import { statsForm } from './stats' -new Command("player") - .setAliases("p", "profile") - .setPermissions("everybody") - .setDescription(i18n`Общее меню игрока`) - .executes((ctx) => playerMenu({ targetId: ctx.player.id }).command(ctx)); +new Command('player') + .setAliases('p', 'profile') + .setPermissions('everybody') + .setDescription(i18n`Общее меню игрока`) + .executes(ctx => playerMenu({ targetId: ctx.player.id }).command(ctx)) -const playerMenu = form.params<{ targetId: string }>( - (f, { player, params: { targetId }, self }) => { - const moder = is(player.id, "moderator"); - const db = Player.database.getImmutable(targetId); - f.title(db.name ?? targetId); +const playerMenu = form.params<{ targetId: string }>((f, { player, params: { targetId }, self }) => { + const moder = is(player.id, 'moderator') + const db = Player.database.getImmutable(targetId) + f.title(db.name ?? targetId) - if (moder) { - f.button(i18n`Другие игроки`, () => { - selectPlayer(player, i18n`открыть его меню`.to(player.lang), self).then( - (target) => { - playerMenu({ targetId: target.id }).show(player, self); - } - ); - }); - } - f.button(i18n`Статистика`, statsForm({ targetId })); + if (moder) { + f.button(i18n`Другие игроки`, () => { + selectPlayer(player, i18n`открыть его меню`.to(player.lang), self).then(target => { + playerMenu({ targetId: target.id }).show(player, self) + }) + }) + } + f.button(i18n`Статистика`, statsForm({ targetId })) - f.button( - i18n`Задания` - .badge(db.quests?.active.length) - .size(db.quests?.completed.length), - form((f) => f.body(stringify(db.quests))) - ); - f.button( - form((f) => { - const all = Achievement.list.length; - const completed = db.achivs?.s.filter((e) => !!e.r).length ?? 0; - f.title( - i18n`Достижения ${completed}/${all} (${( - (completed / all) * - 100 - ).toFixed(0)}%%)` - ); - f.body(stringify(db.achivs)); - }) - ); - f.button( - form((f) => { - const portals = db.unlockedPortals; - f.title(i18n`Порталы ${portals?.length ?? 0}/${Portal.portals.size}`); - f.body(portals?.join("\n") ?? ""); - }) - ); - f.button( - form((f) => { - const lore = LoreForm.getAll(targetId); - f.title(i18n`Лор прочитан`.size(lore.length)); - f.body(lore.map((e) => stringify(e)).join("\n")); - }) - ); - } -); + f.button( + i18n`Задания`.badge(db.quests?.active.length).size(db.quests?.completed.length), + form(f => f.body(stringify(db.quests))), + ) + f.button( + form(f => { + const all = Achievement.list.length + const completed = db.achivs?.s.filter(e => !!e.r).length ?? 0 + f.title(i18n`Достижения ${completed}/${all} (${((completed / all) * 100).toFixed(0)}%%)`) + f.body(stringify(db.achivs)) + }), + ) + f.button( + form(f => { + const portals = db.unlockedPortals + f.title(i18n`Порталы ${portals?.length ?? 0}/${Portal.portals.size}`) + f.body(portals?.join('\n') ?? '') + }), + ) + f.button( + form(f => { + const lore = LoreForm.getAll(targetId) + f.title(i18n`Лор прочитан`.size(lore.length)) + f.body(lore.map(e => stringify(e)).join('\n')) + }), + ) +}) diff --git a/src/modules/commands/rtp.ts b/src/modules/commands/rtp.ts index 5793bf91..f559c9aa 100644 --- a/src/modules/commands/rtp.ts +++ b/src/modules/commands/rtp.ts @@ -1,8 +1,9 @@ import { Player, TicksPerSecond } from '@minecraft/server' import { MinecraftEffectTypes } from '@minecraft/vanilla-data' -import { LockAction, Vec } from 'lib' +import { LockAction } from 'lib/action' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { i18n } from 'lib/i18n/text' +import { Vec } from 'lib/vector' import { WeakPlayerMap } from 'lib/weak-player-storage' import { randomLocationInAnarchy } from 'modules/places/anarchy/random-location-in-anarchy' diff --git a/src/modules/commands/scores.ts b/src/modules/commands/scores.ts index 6f9c5aae..cf8eb503 100644 --- a/src/modules/commands/scores.ts +++ b/src/modules/commands/scores.ts @@ -1,12 +1,16 @@ /* i18n-ignore */ import { Player, ScoreboardIdentityType, ScoreboardObjective, world } from '@minecraft/server' -import { ActionForm, BUTTON, Leaderboard, ModalForm, noBoolean } from 'lib' import { defaultLang } from 'lib/assets/lang' import { ScoreboardDB } from 'lib/database/scoreboard' +import { ActionForm } from 'lib/form/action' import { ArrayForm } from 'lib/form/array' +import { ModalForm } from 'lib/form/modal' import { selectPlayer } from 'lib/form/select-player' +import { BUTTON } from 'lib/form/utils' import { i18n, noI18n, textTable } from 'lib/i18n/text' +import { noBoolean } from 'lib/util' +import { Leaderboard } from 'lib/rpg/leaderboard' new Command('scores') .setDescription('Управляет счетом игроков (монеты, листья)') diff --git a/src/modules/commands/send.ts b/src/modules/commands/send.ts index 609685c0..adfbd217 100644 --- a/src/modules/commands/send.ts +++ b/src/modules/commands/send.ts @@ -1,9 +1,11 @@ /* i18n-ignore */ import { Player, ScoreName, world } from '@minecraft/server' -import { ActionForm, Mail, ModalForm } from 'lib' +import { ActionForm } from 'lib/form/action' +import { ModalForm } from 'lib/form/modal' import { createSelectPlayerMenu } from 'lib/form/select-player' import { i18n } from 'lib/i18n/text' +import { Mail } from 'lib/mail' import { Rewards } from 'lib/utils/rewards' interface SendState { diff --git a/src/modules/commands/sit.ts b/src/modules/commands/sit.ts index d4bffdfa..9162f35e 100644 --- a/src/modules/commands/sit.ts +++ b/src/modules/commands/sit.ts @@ -1,5 +1,5 @@ import { system, world } from '@minecraft/server' -import { LockAction } from 'lib' +import { LockAction } from 'lib/action' import { CustomEntityTypes } from 'lib/assets/custom-entity-types' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { i18n } from 'lib/i18n/text' diff --git a/src/modules/commands/socials.ts b/src/modules/commands/socials.ts index b235dc2e..2ae4f5be 100644 --- a/src/modules/commands/socials.ts +++ b/src/modules/commands/socials.ts @@ -1,6 +1,6 @@ import { system, TicksPerSecond, world } from '@minecraft/server' -import { Settings } from 'lib' import { emoji } from 'lib/assets/emoji' +import { Settings } from 'lib/settings' const socials = [ [`${emoji.custom.socials.discord} §9Discord§7: §b§ldsc.gg/lushway`, 'discord'], diff --git a/src/modules/commands/stats.ts b/src/modules/commands/stats.ts index 85828234..a6a0c79a 100644 --- a/src/modules/commands/stats.ts +++ b/src/modules/commands/stats.ts @@ -1,81 +1,50 @@ -import { Player, ScoreName, ScoreNames } from "@minecraft/server"; -import { - capitalize, - ScoreboardDB, - scoreboardDisplayNames, - scoreboardObjectiveNames, -} from "lib"; -import { form } from "lib/form/new"; -import { i18n, textTable } from "lib/i18n/text"; +import { Player, ScoreName, ScoreNames } from '@minecraft/server' +import { ScoreboardDB, scoreboardDisplayNames, scoreboardObjectiveNames } from 'lib/database/scoreboard' +import { form } from 'lib/form/new' +import { i18n, textTable } from 'lib/i18n/text' +import { capitalize } from 'lib/util' -new Command("stats") - .setDescription(i18n`Показывает статистику по игре`) - .executes((ctx) => statsForm({}).command(ctx)); +new Command('stats').setDescription(i18n`Показывает статистику по игре`).executes(ctx => statsForm({}).command(ctx)) -export const statsForm = form.params<{ targetId?: string }>( - (f, { player, params: { targetId = player.id } }) => { - const scores = ScoreboardDB.getOrCreateProxyFor(targetId); +export const statsForm = form.params<{ targetId?: string }>((f, { player, params: { targetId = player.id } }) => { + const scores = ScoreboardDB.getOrCreateProxyFor(targetId) - f.title(i18n.header`Статистика игрока ${Player.nameOrUnknown(targetId)}`); - f.body( - textTable([ - [ - scoreboardDisplayNames.totalOnlineTime, - formatDate(scores.totalOnlineTime), - ], - [ - scoreboardDisplayNames.anarchyOnlineTime, - formatDate(scores.anarchyOnlineTime), - ], - "", - [ - scoreboardDisplayNames.lastSeenDate, - i18n.time(Date.now() - scores.lastSeenDate * 1000), - ], - [ - scoreboardDisplayNames.anarchyLastSeenDate, - i18n.time(Date.now() - scores.anarchyLastSeenDate * 1000), - ], - "", - ...statsTable( - scores, - (key) => key, - (n) => n.to(player.lang) - ), - "", - ...statsTable( - scores, - (key) => `anarchy${capitalize(key)}`, - (n) => i18n`Анархия ${n}`.to(player.lang) - ), - ]) - ); - } -); + f.title(i18n.header`Статистика игрока ${Player.nameOrUnknown(targetId)}`) + f.body( + textTable([ + [scoreboardDisplayNames.totalOnlineTime, formatDate(scores.totalOnlineTime)], + [scoreboardDisplayNames.anarchyOnlineTime, formatDate(scores.anarchyOnlineTime)], + '', + [scoreboardDisplayNames.lastSeenDate, i18n.time(Date.now() - scores.lastSeenDate * 1000)], + [scoreboardDisplayNames.anarchyLastSeenDate, i18n.time(Date.now() - scores.anarchyLastSeenDate * 1000)], + '', + ...statsTable( + scores, + key => key, + n => n.to(player.lang), + ), + '', + ...statsTable( + scores, + key => `anarchy${capitalize(key)}`, + n => i18n`Анархия ${n}`.to(player.lang), + ), + ]), + ) +}) function formatDate(date: number) { - return i18n.hhmmss(date); + return i18n.hhmmss(date) } -function statsTable( - s: Player["scores"], - getKey: (k: ScoreNames.Stat) => ScoreName, - getN: (n: Text) => string -) { - const table: Text.Table[number][] = []; - for (const key of scoreboardObjectiveNames.stats) { - const k = getKey(key); - table.push([getN(scoreboardDisplayNames[k]), s[k]]); - if (key === "kills") - table.push([ - getN(i18n`Убийств/Смертей`), - s[getKey("kills")] / s[getKey("deaths")], - ]); - if (key === "damageGive") - table.push([ - getN(i18n`Нанесено/Получено`), - s[getKey("damageGive")] / s[getKey("damageRecieve")], - ]); - } - return table satisfies Text.Table; +function statsTable(s: Player['scores'], getKey: (k: ScoreNames.Stat) => ScoreName, getN: (n: Text) => string) { + const table: Text.Table[number][] = [] + for (const key of scoreboardObjectiveNames.stats) { + const k = getKey(key) + table.push([getN(scoreboardDisplayNames[k]), s[k]]) + if (key === 'kills') table.push([getN(i18n`Убийств/Смертей`), s[getKey('kills')] / s[getKey('deaths')]]) + if (key === 'damageGive') + table.push([getN(i18n`Нанесено/Получено`), s[getKey('damageGive')] / s[getKey('damageRecieve')]]) + } + return table satisfies Text.Table } diff --git a/src/modules/commands/tp.ts b/src/modules/commands/tp.ts index d64035e3..2b28daf8 100644 --- a/src/modules/commands/tp.ts +++ b/src/modules/commands/tp.ts @@ -1,11 +1,11 @@ import { world } from '@minecraft/server' -import { Vec } from 'lib' import { form } from 'lib/form/new' import { debounceMenu } from 'lib/form/utils' import { getFullname } from 'lib/get-fullname' import { i18n, i18nPlural, noI18n } from 'lib/i18n/text' import { isNotPlaying } from 'lib/utils/game' import { VectorInDimension } from 'lib/utils/point' +import { Vec } from 'lib/vector' import { SafePlace } from 'modules/places/lib/safe-place' import { Spawn } from 'modules/places/spawn' import { StoneQuarry } from 'modules/places/stone-quarry/stone-quarry' diff --git a/src/modules/commands/version.ts b/src/modules/commands/version.ts index d8a27c93..5e396c57 100644 --- a/src/modules/commands/version.ts +++ b/src/modules/commands/version.ts @@ -1,5 +1,5 @@ -import { is } from 'lib' import { i18n, textTable } from 'lib/i18n/text' +import { is } from 'lib/roles' new Command('version') .setAliases('v') diff --git a/src/modules/commands/wipe.ts b/src/modules/commands/wipe.ts index 35e9c3b8..e0741956 100644 --- a/src/modules/commands/wipe.ts +++ b/src/modules/commands/wipe.ts @@ -1,22 +1,20 @@ import { GameMode, Player, PlayerDatabase, ScoreNames, ShortcutDimensions, system, world } from '@minecraft/server' -import { - Airdrop, - ArrayForm, - BUTTON, - Compass, - InventoryStore, - is, - Join, - ModalForm, - pick, - scoreboardObjectiveNames, - sizeOf, -} from 'lib' + import { table } from 'lib/database/abstract' +import { InventoryStore } from 'lib/database/inventory' +import { scoreboardObjectiveNames } from 'lib/database/scoreboard' +import { ArrayForm } from 'lib/form/array' +import { ModalForm } from 'lib/form/modal' import { form, NewFormCallback, NewFormCreator } from 'lib/form/new' +import { BUTTON } from 'lib/form/utils' import { i18n, noI18n } from 'lib/i18n/text' +import { Join } from 'lib/player-join' import { Quest } from 'lib/quest' +import { is } from 'lib/roles' +import { Airdrop } from 'lib/rpg/airdrop' +import { Compass } from 'lib/rpg/menu' import { enterNewbieMode, isNewbie } from 'lib/rpg/newbie' +import { pick, sizeOf } from 'lib/util' import { Anarchy } from 'modules/places/anarchy/anarchy' import { Spawn } from 'modules/places/spawn' import { updateBuilderStatus } from 'modules/world-edit/builder' @@ -240,7 +238,7 @@ function wipe(player: Player) { for (let i = 0; i <= 26; i++) player.runCommand(`replaceitem entity @s slot.enderchest ${i} air`) - system.runTimeout(() => Join.emitFirstJoin(player), 'clear', 30) + system.runTimeout(() => Join.getInstance().emitFirstJoin(player), 'clear', 30) } function exitFromAllQuests(player: Player) { diff --git a/src/modules/features/break-place-outside-of-region.ts b/src/modules/features/break-place-outside-of-region.ts index 91548c56..5f4e364a 100644 --- a/src/modules/features/break-place-outside-of-region.ts +++ b/src/modules/features/break-place-outside-of-region.ts @@ -1,10 +1,12 @@ import { Player, system } from '@minecraft/server' import { MinecraftItemTypes } from '@minecraft/vanilla-data' -import { Cooldown, ms } from 'lib' +import { Cooldown } from 'lib/cooldown' + import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { i18n } from 'lib/i18n/text' import { actionGuard, ActionGuardOrder, BLOCK_CONTAINERS, DOORS, GATES, SWITCHES, TRAPDOORS } from 'lib/region/index' import { ScheduleBlockPlace } from 'lib/scheduled-block-place' +import { ms } from 'lib/utils/ms' import { BaseRegion } from 'modules/places/base/region' const INTERACTABLE = DOORS.concat(SWITCHES, TRAPDOORS, BLOCK_CONTAINERS, GATES) diff --git a/src/modules/indicator/health.ts b/src/modules/indicator/health.ts index 3b254d99..892ab621 100644 --- a/src/modules/indicator/health.ts +++ b/src/modules/indicator/health.ts @@ -1,12 +1,14 @@ import { Entity, system, world } from '@minecraft/server' import { MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { Boss, ms, Vec } from 'lib' import { CustomEntityTypes } from 'lib/assets/custom-entity-types' import { ClosingChatSet } from 'lib/extensions/player' import { NOT_MOB_ENTITIES } from 'lib/region/config' +import { Boss } from 'lib/rpg/boss' import { isNotPlaying } from 'lib/utils/game' +import { Vec } from 'lib/vector' +import { ms } from 'lib/utils/ms' import { PlayerNameTagModifiers, setNameTag } from 'modules/indicator/player-name-tag' interface BaseHurtEntity { diff --git a/src/modules/indicator/player-name-tag.ts b/src/modules/indicator/player-name-tag.ts index 2fc53739..64495267 100644 --- a/src/modules/indicator/player-name-tag.ts +++ b/src/modules/indicator/player-name-tag.ts @@ -1,6 +1,7 @@ import { Entity, Player, system } from '@minecraft/server' -import { isNotPlaying } from 'lib' + import { getFullname } from 'lib/get-fullname' +import { isNotPlaying } from 'lib/utils/game' export const PlayerNameTagModifiers: ((player: Player) => string | false)[] = [ player => { diff --git a/src/modules/indicator/pvp.ts b/src/modules/indicator/pvp.ts index c841f596..e2a76e12 100644 --- a/src/modules/indicator/pvp.ts +++ b/src/modules/indicator/pvp.ts @@ -1,10 +1,15 @@ import { EntityDamageCause, EntityHurtAfterEvent, Player, system, world } from '@minecraft/server' -import { Boss, BossArenaRegion, LockAction, ms, Settings } from 'lib' +import { LockAction } from 'lib/action' + import { emoji } from 'lib/assets/emoji' import { Core } from 'lib/extensions/core' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { i18n } from 'lib/i18n/text' import { RegionEvents } from 'lib/region/events' +import { BossArenaRegion } from 'lib/region/kinds/boss-arena' +import { Boss } from 'lib/rpg/boss' +import { Settings } from 'lib/settings' +import { ms } from 'lib/utils/ms' import { WeakPlayerMap } from 'lib/weak-player-storage' import { Anarchy } from 'modules/places/anarchy/anarchy' diff --git a/src/modules/indicator/test-damage.ts b/src/modules/indicator/test-damage.ts index 647a198d..80c5477d 100644 --- a/src/modules/indicator/test-damage.ts +++ b/src/modules/indicator/test-damage.ts @@ -3,9 +3,12 @@ import { EnchantmentType, EquipmentSlot, ItemStack, Player } from '@minecraft/server' import { registerAsync } from '@minecraft/server-gametest' import { MinecraftEnchantmentTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { Enchantments, isKeyof, Temporary } from 'lib' +import { Enchantments } from 'lib/enchantments' + import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { noI18n } from 'lib/i18n/text' +import { isKeyof } from 'lib/util' +import { Temporary } from 'lib/temporary' import { TestStructures } from 'test/constants' const players: Player[] = [] diff --git a/src/modules/loader.ts b/src/modules/loader.ts index c2e37567..27d664ee 100644 --- a/src/modules/loader.ts +++ b/src/modules/loader.ts @@ -1,9 +1,21 @@ -import 'lib' +import 'lib/load/enviroment' +import 'lib/load/message1' -import './anticheat/index' -import './survival/import' +// Database provider +import 'lib/database/properties' + +// Database +import 'lib/database/inventory' +import 'lib/database/player' +import 'lib/database/scoreboard' +import 'lib/database/utils' + +// Command +import 'lib/command/index' -import './chat/chat' +import './lushway/loader' + +import './survival/import' import './test/test' import './wiki/wiki' import './world-edit/builder' diff --git a/src/modules/lushway/config/chat.ts b/src/modules/lushway/config/chat.ts new file mode 100644 index 00000000..12580a69 --- /dev/null +++ b/src/modules/lushway/config/chat.ts @@ -0,0 +1,34 @@ +import { Sounds } from 'lib/assets/custom-sounds' +import { sendPacketToStdout } from 'lib/bds/api' +import { Chat } from 'lib/chat/chat' +import { getFullname } from 'lib/get-fullname' + +class LushWayChat extends Chat { + protected onMessage(ctx: Chat.Context): void { + const message = `${getFullname(ctx.sender, { nameColor: '§7', equippment: true })}§r: ${ctx.text}` + const fullrole = getFullname(ctx.sender, { name: false }) + if (__SERVER__) { + // This is handled/parsed by ServerCore + // Dont really want to do request each time here + sendPacketToStdout('chatMessage', { + name: ctx.sender.name, + role: fullrole, + print: message, + message: ctx.text, + }) + } + + for (const near of ctx.nearPlayers) { + near.tell(message) + if (this.playerSettings(near).sound) near.playSound(Sounds.Click) + } + + for (const outranged of ctx.farPlayers) { + outranged.tell(`${getFullname(ctx.sender, { nameColor: '§8' })}§7: ${ctx.text}`) + } + + ctx.sender.tell(message) + } +} + +Command.registerChatListener(new LushWayChat().chatListener) diff --git a/src/modules/lushway/config/core.ts b/src/modules/lushway/config/core.ts new file mode 100644 index 00000000..9dc3c9af --- /dev/null +++ b/src/modules/lushway/config/core.ts @@ -0,0 +1,3 @@ +import { Core } from 'lib/extensions/core' + +Core.name = '§aLush§fWay' diff --git a/src/modules/lushway/config/join.ts b/src/modules/lushway/config/join.ts new file mode 100644 index 00000000..2dc452b4 --- /dev/null +++ b/src/modules/lushway/config/join.ts @@ -0,0 +1,20 @@ +import { Player } from '@minecraft/server' +import { sendPacketToStdout } from 'lib/bds/api' +import { getFullname } from 'lib/get-fullname' +import { noI18n } from 'lib/i18n/text' +import { JoinWithMessage } from 'lib/player-join' + +export class LushWayJoin extends JoinWithMessage { + onJoinMoveMessage(player: Player, where: 'air' | 'ground', message: Text): void { + __SERVER__ && + sendPacketToStdout('joinOrLeave', { + name: player.name, + role: getFullname(player, { name: false }), + status: 'move', + where, + print: noI18n.nocolor`${'§l§f' + player.name} ${getFullname(player, { name: false })}: ${message}`, + }) + } +} + +new LushWayJoin() diff --git a/src/modules/lushway/loader.ts b/src/modules/lushway/loader.ts new file mode 100644 index 00000000..2d1aeacd --- /dev/null +++ b/src/modules/lushway/loader.ts @@ -0,0 +1,13 @@ +import 'lib/anticheat/ban' +import 'lib/anticheat/forbidden-items' +import 'lib/anticheat/freeze' +import 'lib/anticheat/whitelist' + +import 'lib/database/command' + +import 'lib/region/database' + +import './config/core' + +import './config/chat' +import './config/join' diff --git a/src/modules/minigames/BattleRoyal/index.ts b/src/modules/minigames/BattleRoyal/index.ts index 8421e0ff..09fa2f82 100644 --- a/src/modules/minigames/BattleRoyal/index.ts +++ b/src/modules/minigames/BattleRoyal/index.ts @@ -3,7 +3,7 @@ // TODO Update import { Player, system, world } from '@minecraft/server' -import { Command } from 'lib' + import { br } from './game' import { BATTLE_ROYAL_EVENTS, BR_QUENE } from './var' diff --git a/src/modules/minigames/BattleRoyal/var.ts b/src/modules/minigames/BattleRoyal/var.ts index 950143f0..e95ae6df 100644 --- a/src/modules/minigames/BattleRoyal/var.ts +++ b/src/modules/minigames/BattleRoyal/var.ts @@ -1,6 +1,6 @@ -import { Settings } from 'lib' import { table } from 'lib/database/abstract' import { EventSignal } from 'lib/event-signal' +import { Settings } from 'lib/settings' export const BATTLE_ROYAL_EVENTS = { join: new EventSignal(), diff --git a/src/modules/minigames/Builder.ts b/src/modules/minigames/Builder.ts index 28b72de6..7e278bf7 100644 --- a/src/modules/minigames/Builder.ts +++ b/src/modules/minigames/Builder.ts @@ -1,5 +1,6 @@ import { Player } from '@minecraft/server' -import { LockAction, Sidebar } from 'lib' +import { LockAction } from 'lib/action' +import { Sidebar } from 'lib/sidebar' // TODO Add minigame place diff --git a/src/modules/places/anarchy/airdrop.ts b/src/modules/places/anarchy/airdrop.ts index 6d8358ae..c3d2d599 100644 --- a/src/modules/places/anarchy/airdrop.ts +++ b/src/modules/places/anarchy/airdrop.ts @@ -1,7 +1,11 @@ import { LocationInUnloadedChunkError, system, world } from '@minecraft/server' -import { Airdrop, isNotPlaying, Loot, Vec } from 'lib' + import { Items } from 'lib/assets/custom-items' import { i18n } from 'lib/i18n/text' +import { Airdrop } from 'lib/rpg/airdrop' +import { Loot } from 'lib/rpg/loot-table' +import { isNotPlaying } from 'lib/utils/game' +import { Vec } from 'lib/vector' import { Anarchy } from 'modules/places/anarchy/anarchy' import { CannonItem, CannonShellItem } from '../../pvp/cannon' import { randomLocationInAnarchy } from './random-location-in-anarchy' @@ -15,10 +19,10 @@ const base = new Loot('base_airdrop') .amount({ '25...50': '40%', '51...90': '2%' }) .weight('20%') - .itemStack(CannonShellItem.blueprint) + .itemStack(() => CannonShellItem.blueprint) .weight('10%') - .itemStack(CannonItem.blueprint) + .itemStack(() => CannonItem.blueprint) .weight('5%') .item(Items.Money) @@ -35,10 +39,10 @@ const powerfull = new Loot('powerfull_airdrop') .amount({ '30...64': '40%', '65...128': '2%' }) .weight('20%') - .itemStack(CannonShellItem.itemStack) + .itemStack(CannonShellItem) .weight('10%') - .itemStack(CannonItem.itemStack) + .itemStack(CannonItem) .weight('5%') .item(Items.Money) diff --git a/src/modules/places/anarchy/anarchy.ts b/src/modules/places/anarchy/anarchy.ts index de3fb0d2..080a84c0 100644 --- a/src/modules/places/anarchy/anarchy.ts +++ b/src/modules/places/anarchy/anarchy.ts @@ -1,5 +1,5 @@ import { GameMode, Player } from '@minecraft/server' -import { EventSignal, InventoryStore, Portal, ValidLocation, Vec, location } from 'lib' + import { consoleLang } from 'lib/assets/lang' import { i18n, noI18n } from 'lib/i18n/text' import { isNotPlaying } from 'lib/utils/game' @@ -12,6 +12,12 @@ import { Spawn } from 'modules/places/spawn' import { showSurvivalHud } from 'modules/survival/sidebar' import { AreaWithInventory } from '../lib/area-with-inventory' import { RadioactiveZone } from './radioactive-zone' +import { EventSignal } from 'lib/event-signal' +import { Vec } from 'lib/vector' +import { ValidLocation } from 'lib/location' +import { InventoryStore } from 'lib/database/inventory' +import { location } from 'lib/location' +import { Portal } from 'lib/portals' import('./airdrop') class AnarchyBuilder extends AreaWithInventory { diff --git a/src/modules/places/anarchy/quartz.ts b/src/modules/places/anarchy/quartz.ts index 77337cdd..7fa302ae 100644 --- a/src/modules/places/anarchy/quartz.ts +++ b/src/modules/places/anarchy/quartz.ts @@ -1,12 +1,14 @@ import { ItemStack, system } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEffectTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { isKeyof, ms } from 'lib' + import { i18n } from 'lib/i18n/text' import { RegionEvents } from 'lib/region/events' import { actionGuard, ActionGuardOrder, disableAdventureNear, Region, RegionPermissions } from 'lib/region/index' import { ScheduleBlockPlace } from 'lib/scheduled-block-place' import { TechCity } from '../tech-city/tech-city' +import { ms } from 'lib/utils/ms' +import { isKeyof } from 'lib/util' export class QuartzMineRegion extends Region { protected priority = 100 diff --git a/src/modules/places/base/actions/create.ts b/src/modules/places/base/actions/create.ts index dd88d0f6..a59318e7 100644 --- a/src/modules/places/base/actions/create.ts +++ b/src/modules/places/base/actions/create.ts @@ -1,5 +1,5 @@ import { Block, Player, system, world } from '@minecraft/server' -import { actionGuard, ActionGuardOrder, LockAction, Region, Vec } from 'lib' + import { i18n } from 'lib/i18n/text' import { SphereArea } from 'lib/region/areas/sphere' import { askForExitingNewbieMode, isNewbie } from 'lib/rpg/newbie' @@ -7,6 +7,11 @@ import { BaseItem, baseLogger } from '../base' import { baseLevels } from '../base-levels' import { baseCommand } from '../base-menu' import { BaseRegion } from '../region' +import { Vec } from 'lib/vector' +import { Region } from 'lib/region' +import { LockAction } from 'lib/action' +import { ActionGuardOrder } from 'lib/region' +import { actionGuard } from 'lib/region' actionGuard((_, __, ctx) => { if ( diff --git a/src/modules/places/base/actions/rotting.ts b/src/modules/places/base/actions/rotting.ts index c6569861..abdb7057 100644 --- a/src/modules/places/base/actions/rotting.ts +++ b/src/modules/places/base/actions/rotting.ts @@ -1,24 +1,20 @@ import { Block, BlockPermutation, ContainerSlot, Player, system } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { - actionGuard, - ActionGuardOrder, - Cooldown, - getBlockStatus, - isEmpty, - isLocationError, - isNotPlaying, - Mail, - ms, - Vec, -} from 'lib' + +import { Cooldown } from 'lib/cooldown' import { table } from 'lib/database/abstract' import { form } from 'lib/form/new' import { Message } from 'lib/i18n/message' import { i18n } from 'lib/i18n/text' +import { Mail } from 'lib/mail' import { anyPlayerNearRegion } from 'lib/player-move' +import { actionGuard, ActionGuardOrder } from 'lib/region' import { ScheduleBlockPlace } from 'lib/scheduled-block-place' +import { isEmpty } from 'lib/util' +import { getBlockStatus, isLocationError, isNotPlaying, onLoad } from 'lib/utils/game' import { itemNameXCount } from 'lib/utils/item-name-x-count' +import { ms } from 'lib/utils/ms' +import { Vec } from 'lib/vector' import { spawnParticlesInArea } from 'modules/world-edit/config' import { BaseRegion, RottingState } from '../region' @@ -28,9 +24,11 @@ const materialsReviseTime = __DEV__ ? ms.from('min', 1) : ms.from('min', 1) const cooldowns = table>('baseCoooldowns', () => ({})) -const blocksToMaterialsCooldown = new Cooldown(blocksReviseTime, false, cooldowns.get('blocksToMaterials')) -const reviseMaterialsCooldown = new Cooldown(materialsReviseTime, false, cooldowns.get('revise')) -const takeMaterialsCooldown = new Cooldown(takeMaterialsTime, false, cooldowns.get('takeMaterials')) +const blocksToMaterialsCooldown = onLoad( + () => new Cooldown(blocksReviseTime, false, cooldowns.get('blocksToMaterials')), +) +const reviseMaterialsCooldown = onLoad(() => new Cooldown(materialsReviseTime, false, cooldowns.get('revise'))) +const takeMaterialsCooldown = onLoad(() => new Cooldown(takeMaterialsTime, false, cooldowns.get('takeMaterials'))) system.runInterval( () => { @@ -44,9 +42,9 @@ system.runInterval( spawnParticlesInArea(base.area.center, Vec.add(base.area.center, Vec.one)) if (block.typeId === MinecraftBlockTypes.Barrel) { - if (blocksToMaterialsCooldown.isExpired(base.id)) blocksToMaterials(base) - if (reviseMaterialsCooldown.isExpired(base.id)) reviseMaterials(base, block) - if (takeMaterialsCooldown.isExpired(base.id)) takeMaterials(base, block) + if (blocksToMaterialsCooldown.value.isExpired(base.id)) blocksToMaterials(base) + if (reviseMaterialsCooldown.value.isExpired(base.id)) reviseMaterials(base, block) + if (takeMaterialsCooldown.value.isExpired(base.id)) takeMaterials(base, block) } else startRotting(base, RottingState.Destroyed) } }, @@ -85,7 +83,7 @@ const baseRottingMenu = form.params<{ base: BaseRegion }>((f, { params: { base } f.title(i18n`Гниение базы`) f.body( - i18n`Чтобы база не гнила, в бочке ежедневно должны быть следующие ресурсы:\n${materials}\nМатериалы в бочке:\n${barrelMaterials}\n${missingMaterialsText}\nДо следующего сбора ресурсов: ${i18n.hhmmss(takeMaterialsCooldown.getRemainingTime(base.id))}`, + i18n`Чтобы база не гнила, в бочке ежедневно должны быть следующие ресурсы:\n${materials}\nМатериалы в бочке:\n${barrelMaterials}\n${missingMaterialsText}\nДо следующего сбора ресурсов: ${i18n.hhmmss(takeMaterialsCooldown.value.getRemainingTime(base.id))}`, ) }) diff --git a/src/modules/places/base/base-menu.ts b/src/modules/places/base/base-menu.ts index 54a7b3d4..4ded283e 100644 --- a/src/modules/places/base/base-menu.ts +++ b/src/modules/places/base/base-menu.ts @@ -1,7 +1,7 @@ -import { Vec } from 'lib' import { form } from 'lib/form/new' import { i18n } from 'lib/i18n/text' import { editRegionPermissions, manageRegionMembers } from 'lib/region/form' +import { Vec } from 'lib/vector' import { baseRottingButton } from './actions/rotting' import { baseUpgradeButton } from './actions/upgrade' import { BaseRegion } from './region' diff --git a/src/modules/places/base/region.ts b/src/modules/places/base/region.ts index bf8e4c24..afa4d17d 100644 --- a/src/modules/places/base/region.ts +++ b/src/modules/places/base/region.ts @@ -1,5 +1,5 @@ import { Player } from '@minecraft/server' -import { disableAdventureNear } from 'lib' + import { i18n, noI18n } from 'lib/i18n/text' import { SphereArea } from 'lib/region/areas/sphere' import { registerRegionType } from 'lib/region/command' @@ -7,6 +7,7 @@ import { registerSaveableRegion } from 'lib/region/database' import { RegionWithStructure } from 'lib/region/kinds/with-structure' import { getSafeFromRottingTime, materialsToRString } from './actions/rotting' import { baseLevels } from './base-levels' +import { disableAdventureNear } from 'lib/region' interface BaseLDB extends JsonObject { level: number diff --git a/src/modules/places/dungeons/command.ts b/src/modules/places/dungeons/command.ts index 6ef97eb7..bb713c40 100644 --- a/src/modules/places/dungeons/command.ts +++ b/src/modules/places/dungeons/command.ts @@ -1,13 +1,17 @@ /* i18n-ignore */ import { MolangVariableMap, Player, StructureRotation, system, world } from '@minecraft/server' -import { ArrayForm, isKeyof, Vec } from 'lib' + import { Items } from 'lib/assets/custom-items' import { StructureDungeonsId } from 'lib/assets/structures' import { ItemLoreSchema } from 'lib/database/item-stack' import { ActionbarPriority } from 'lib/extensions/on-screen-display' +import { ArrayForm } from 'lib/form/array' import { i18n, noI18n } from 'lib/i18n/text' import { SphereArea } from 'lib/region/areas/sphere' +import { isKeyof } from 'lib/util' +import { onLoad } from 'lib/utils/load-ref' +import { Vec } from 'lib/vector' import { DungeonRegion } from 'modules/places/dungeons/dungeon' import { CustomDungeonRegion } from './custom-dungeon' import { Dungeon } from './loot' @@ -113,7 +117,7 @@ system.runPlayerInterval( for (const l of Vec.forEach(from, to)) { if (!Vec.isEdge(from, to, l)) continue - player.spawnParticle('minecraft:balloon_gas_particle', l, particle) + player.spawnParticle('minecraft:balloon_gas_particle', l, particle.value) } player.onScreenDisplay.setActionBar( @@ -125,10 +129,13 @@ system.runPlayerInterval( 15, ) -const particle = new MolangVariableMap() +const particle = onLoad(() => { + const vars = new MolangVariableMap() -particle.setVector3('direction', { - x: 0, - y: 0, - z: 0, + vars.setVector3('direction', { + x: 0, + y: 0, + z: 0, + }) + return vars }) diff --git a/src/modules/places/dungeons/custom-dungeon.ts b/src/modules/places/dungeons/custom-dungeon.ts index d527d1af..1fab2d0e 100644 --- a/src/modules/places/dungeons/custom-dungeon.ts +++ b/src/modules/places/dungeons/custom-dungeon.ts @@ -1,11 +1,18 @@ import { Block, GameMode, Player, StructureRotation, system, world } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { is, ModalForm, ms, RegionCreationOptions, registerRegionType, registerSaveableRegion, Vec } from 'lib' + import { StructureDungeonsId, StructureFile } from 'lib/assets/structures' import { i18n, noI18n } from 'lib/i18n/text' import { Area } from 'lib/region/areas/area' import { DungeonRegion, DungeonRegionDatabase } from './dungeon' import { Dungeon } from './loot' +import { Vec } from 'lib/vector' +import { ModalForm } from 'lib/form/modal' +import { is } from 'lib/roles' +import { registerRegionType } from 'lib/region' +import { registerSaveableRegion } from 'lib/region' +import { ms } from 'lib/utils/ms' +import { RegionCreationOptions } from 'lib/region' interface CustomDungeonRegionDatabase extends DungeonRegionDatabase { chestLoot: { diff --git a/src/modules/places/dungeons/dungeon.ts b/src/modules/places/dungeons/dungeon.ts index 1b03de3b..f5c8b622 100644 --- a/src/modules/places/dungeons/dungeon.ts +++ b/src/modules/places/dungeons/dungeon.ts @@ -1,15 +1,6 @@ import { EntityTypes, Player, StructureRotation, StructureSaveMode, system, world } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { - adventureModeRegions, - Cooldown, - isKeyof, - LootTable, - ms, - registerRegionType, - registerSaveableRegion, - Vec, -} from 'lib' + import { StructureDungeonsId, StructureFile, structureFiles } from 'lib/assets/structures' import { NewFormCreator } from 'lib/form/new' import { i18n, noI18n } from 'lib/i18n/text' @@ -20,6 +11,12 @@ import { Region, RegionCreationOptions, RegionPermissions } from 'lib/region/kin import { createLogger } from 'lib/utils/logger' import { structureLikeRotate, structureLikeRotateRelative, toAbsolute, toRelative } from 'lib/utils/structure' import { Dungeon } from './loot' +import { Cooldown } from 'lib/cooldown' +import { registerSaveableRegion, registerRegionType, adventureModeRegions } from 'lib/region' +import { LootTable } from 'lib/rpg/loot-table' +import { isKeyof } from 'lib/util' +import { ms } from 'lib/utils/ms' +import { Vec } from 'lib/vector' const logger = createLogger('dungeon') diff --git a/src/modules/places/dungeons/loot.ts b/src/modules/places/dungeons/loot.ts index ddc6d672..0755dda3 100644 --- a/src/modules/places/dungeons/loot.ts +++ b/src/modules/places/dungeons/loot.ts @@ -1,14 +1,14 @@ -import { Loot, LootTable } from 'lib' import { Items } from 'lib/assets/custom-items' import { StructureDungeonsId } from 'lib/assets/structures' import { i18n } from 'lib/i18n/text' +import { Loot, LootTable } from 'lib/rpg/loot-table' import { CannonItem, CannonShellItem } from 'modules/pvp/cannon' import { FireBallItem } from 'modules/pvp/fireball' import { IceBombItem } from 'modules/pvp/ice-bomb' import { BaseItem } from '../base/base' const defaultLoot = new Loot('dungeon_default_loot') - .itemStack(CannonShellItem.blueprint) + .itemStack(() => CannonShellItem.blueprint) .weight('5%') .item('Apple') @@ -177,18 +177,18 @@ const customLoot: Record = { Sharpness: { '1...3': '1%', '4...5': '10%' }, }) - .itemStack(CannonItem.itemStack) + .itemStack(CannonItem) .weight('40%') .amount({ '1...2': '1%' }) - .itemStack(CannonShellItem.itemStack) + .itemStack(CannonShellItem) .weight('60%') .amount({ '1...9': '10%', '10...16': '1%', }) - .itemStack(BaseItem.itemStack) + .itemStack(BaseItem) .weight('5%') .amount({ '0...1': '1%' }) diff --git a/src/modules/places/dungeons/warden.ts b/src/modules/places/dungeons/warden.ts index ac2034a7..df1b850d 100644 --- a/src/modules/places/dungeons/warden.ts +++ b/src/modules/places/dungeons/warden.ts @@ -1,24 +1,24 @@ import { system } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { - actionGuard, - ActionGuardOrder, - disableAdventureNear, - fromMsToTicks, - ms, - PVP_ENTITIES, - Region, - RegionPermissions, - registerRegionType, - registerSaveableRegion, - Vec, -} from 'lib' + import { form } from 'lib/form/new' import { i18n, noI18n } from 'lib/i18n/text' import { anyPlayerNearRegion } from 'lib/player-move' import { rollChance } from 'lib/rpg/random' import { createLogger } from 'lib/utils/logger' import { BaseItem } from '../base/base' +import { + Region, + RegionPermissions, + PVP_ENTITIES, + registerSaveableRegion, + registerRegionType, + disableAdventureNear, + actionGuard, + ActionGuardOrder, +} from 'lib/region' +import { fromMsToTicks, ms } from 'lib/utils/ms' +import { Vec } from 'lib/vector' const logger = createLogger('warden') diff --git a/src/modules/places/lib/city-investigating-quest.ts b/src/modules/places/lib/city-investigating-quest.ts index 0dc363ca..d584fbb3 100644 --- a/src/modules/places/lib/city-investigating-quest.ts +++ b/src/modules/places/lib/city-investigating-quest.ts @@ -1,8 +1,8 @@ -import { isNotPlaying } from 'lib' import { i18n } from 'lib/i18n/text' import { Quest } from 'lib/quest' import { RegionEvents } from 'lib/region/events' import { City } from './city' +import { isNotPlaying } from 'lib/utils/game' export class CityInvestigating { static list: CityInvestigating[] = [] diff --git a/src/modules/places/lib/city.ts b/src/modules/places/lib/city.ts index b9e6fc65..fd08de68 100644 --- a/src/modules/places/lib/city.ts +++ b/src/modules/places/lib/city.ts @@ -1,4 +1,3 @@ -import { location, LootTable } from 'lib' import { Crate } from 'lib/crates/crate' import { Cutscene } from 'lib/cutscene' import { i18n, i18nShared } from 'lib/i18n/text' @@ -8,6 +7,8 @@ import { Npc } from 'lib/rpg/npc' import { Jeweler } from 'modules/places/lib/npc/jeweler' import { Scavenger } from './npc/scavenger' import { SafePlace } from './safe-place' +import { location } from 'lib/location' +import { LootTable } from 'lib/rpg/loot-table' export abstract class City extends SafePlace { protected createKits(normalLoot: LootTable, donutLoot: LootTable) { diff --git a/src/modules/places/lib/npc/guide.ts b/src/modules/places/lib/npc/guide.ts index f6149077..64b2b533 100644 --- a/src/modules/places/lib/npc/guide.ts +++ b/src/modules/places/lib/npc/guide.ts @@ -15,7 +15,7 @@ export class GuideNpc extends NpcForm { for (const quest of Quest.quests.values()) { if (quest.guideIgnore) continue - if (quest.place.group === group) f.quest(quest) + if (quest.place.group === group) ctx.lf.quest(quest) } }) } diff --git a/src/modules/places/lib/safe-place.ts b/src/modules/places/lib/safe-place.ts index b933a270..125cc0b5 100644 --- a/src/modules/places/lib/safe-place.ts +++ b/src/modules/places/lib/safe-place.ts @@ -1,28 +1,30 @@ import { Player, system, TicksPerSecond } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { - actionGuard, - ActionGuardOrder, - ArrayForm, - debounceMenu, - location, - locationWithRadius, - locationWithRotation, - Portal, - SafeAreaRegion, - Vec, - Vector3Radius, -} from 'lib' + import { Sounds } from 'lib/assets/custom-sounds' import { emoji } from 'lib/assets/emoji' +import { ArrayForm } from 'lib/form/array' +import { debounceMenu } from 'lib/form/utils' import { SharedI18nMessage } from 'lib/i18n/message' import { i18n, noI18n } from 'lib/i18n/text' +import { location, locationWithRadius, locationWithRotation, Vector3Radius } from 'lib/location' +import { Portal } from 'lib/portals' +import { actionGuard, ActionGuardOrder, SafeAreaRegion } from 'lib/region' import { SphereArea } from 'lib/region/areas/sphere' import { RegionEvents } from 'lib/region/events' import { Group } from 'lib/rpg/place' import { MultiCost } from 'lib/shop/cost' import { ErrorCost } from 'lib/shop/cost/cost' import { Product } from 'lib/shop/product' +import { Vec } from 'lib/vector' + +declare module '@minecraft/server' { + interface PlayerDatabase { + unlockedPortals?: string[] + } +} + +export {} export class SafePlace { static places: SafePlace[] = [] diff --git a/src/modules/places/mineshaft/algo.ts b/src/modules/places/mineshaft/algo.ts index 31eac897..7fd1eecb 100644 --- a/src/modules/places/mineshaft/algo.ts +++ b/src/modules/places/mineshaft/algo.ts @@ -1,7 +1,7 @@ import { Block, Dimension, Player } from '@minecraft/server' import { MinecraftBlockTypes as b, MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Vec } from 'lib' import { EventSignal } from 'lib/event-signal' +import { Vec } from 'lib/vector' import { getEdgeBlocksOf } from './get-edge-blocks-of' import { MineshaftRegion } from './mineshaft-region' import { Ore, OreCollector, OreEntry } from './ore-collector' diff --git a/src/modules/places/mineshaft/mineshaft-region.ts b/src/modules/places/mineshaft/mineshaft-region.ts index 578326dd..cf1d5e4b 100644 --- a/src/modules/places/mineshaft/mineshaft-region.ts +++ b/src/modules/places/mineshaft/mineshaft-region.ts @@ -1,12 +1,15 @@ import { LocationOutOfWorldBoundariesError, Player, PlayerBreakBlockBeforeEvent, system } from '@minecraft/server' -import { ActionForm, ms, registerRegionType, Vec } from 'lib' + +import { NewFormCreator } from 'lib/form/new' import { i18n, noI18n } from 'lib/i18n/text' import { registerSaveableRegion } from 'lib/region/database' import { ScheduleBlockPlace } from 'lib/scheduled-block-place' import { createLogger } from 'lib/utils/logger' import { MineareaRegion } from '../../../lib/region/kinds/minearea' import { ores, placeOre } from './algo' -import { NewFormCreator } from 'lib/form/new' +import { registerRegionType } from 'lib/region' +import { Vec } from 'lib/vector' +import { ms } from 'lib/utils/ms' const logger = createLogger('Shaft') diff --git a/src/modules/places/mineshaft/ore-collector.ts b/src/modules/places/mineshaft/ore-collector.ts index 24968e12..a32f8a2c 100644 --- a/src/modules/places/mineshaft/ore-collector.ts +++ b/src/modules/places/mineshaft/ore-collector.ts @@ -1,7 +1,8 @@ import { MinecraftBlockTypes as b } from '@minecraft/vanilla-data' -import { stringifyError } from 'lib' + import { i18n } from 'lib/i18n/text' import { selectByChance } from 'lib/rpg/random' +import { stringifyError } from 'lib/util' export class Ore { private types: string[] = [] diff --git a/src/modules/places/spawn.ts b/src/modules/places/spawn.ts index d43275b1..5529d9e5 100644 --- a/src/modules/places/spawn.ts +++ b/src/modules/places/spawn.ts @@ -1,16 +1,20 @@ import { GameMode, Player, system, world } from '@minecraft/server' import { MinecraftEffectTypes } from '@minecraft/vanilla-data' -import { InventoryStore, Portal, Settings, locationWithRotation, util } from 'lib' +import { InventoryStore } from 'lib/database/inventory' import { i18n, i18nShared, noI18n } from 'lib/i18n/text' +import { locationWithRotation } from 'lib/location' import { Join } from 'lib/player-join' +import { Portal } from 'lib/portals' import { SphereArea } from 'lib/region/areas/sphere' import { RegionEvents } from 'lib/region/events' import { SafeAreaRegion } from 'lib/region/kinds/safe-area' import { Menu } from 'lib/rpg/menu' import { Group } from 'lib/rpg/place' -import { isNotPlaying } from 'lib/utils/game' +import { Settings } from 'lib/settings' +import { util } from 'lib/util' +import { isNotPlaying, onLoad } from 'lib/utils/game' import { createLogger } from 'lib/utils/logger' import { showSurvivalHud } from 'modules/survival/sidebar' import { AreaWithInventory } from './lib/area-with-inventory' @@ -45,49 +49,55 @@ class SpawnBuilder extends AreaWithInventory { constructor() { super() this.onRegionInterval() - if (this.location.valid) { - const spawnLocation = this.location - world.setDefaultSpawnLocation(spawnLocation) - - this.portal = new Portal('spawn', null, null, player => { - if (!Portal.canTeleport(player)) return - Portal.fadeScreen(player) - - this.switchInventory(player) - spawnLocation.teleport(player) - - showSurvivalHud(player) - - // Need to happen last because showSurvivalHud will reset title show time - Portal.showHudTitle(player, '§9> §bSpawn §9<') - }) - - this.portal - .createCommand() - .setPermissions('everybody') - .setDescription(i18n.nocolor`§r§bПеремещает на спавн`) - - world.afterEvents.playerSpawn.unsubscribe(Join.eventsDefaultSubscribers.playerSpawn) - world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { - // Skip after death respawns - if (!initialSpawn) return - if (player.isSimulated()) return - if (isNotPlaying(player)) return Join.setPlayerJoinPosition(player) - - // Check settings - if (!this.settings(player).teleportToSpawnOnJoin) - return this.logger.player(player) - .info`Not teleporting to spawn on join because player disabled it via settings` - - util.catch(() => { - this.logger.player(player).info`Teleporting player to spawn on join` - this.portal?.teleport(player) - system.runTimeout(() => Join.setPlayerJoinPosition(player), 'Spawn set player position after join', 10) + onLoad(() => { + if (this.location.valid) { + const spawnLocation = this.location + world.setDefaultSpawnLocation(spawnLocation) + + this.portal = new Portal('spawn', null, null, player => { + if (!Portal.canTeleport(player)) return + Portal.fadeScreen(player) + + this.switchInventory(player) + spawnLocation.teleport(player) + + showSurvivalHud(player) + + // Need to happen last because showSurvivalHud will reset title show time + Portal.showHudTitle(player, '§9> §bSpawn §9<') + }) + + this.portal + .createCommand() + .setPermissions('everybody') + .setDescription(i18n.nocolor`§r§bПеремещает на спавн`) + + world.afterEvents.playerSpawn.unsubscribe(Join.getInstance().playerSpawnEventSubscriber) + world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { + // Skip after death respawns + if (!initialSpawn) return + if (player.isSimulated()) return + if (isNotPlaying(player)) return Join.getInstance().setPlayerJoinPosition(player) + + // Check settings + if (!this.settings(player).teleportToSpawnOnJoin) + return this.logger.player(player) + .info`Not teleporting to spawn on join because player disabled it via settings` + + util.catch(() => { + this.logger.player(player).info`Teleporting player to spawn on join` + this.portal?.teleport(player) + system.runTimeout( + () => Join.getInstance().setPlayerJoinPosition(player), + 'Spawn set player position after join', + 10, + ) + }) }) - }) - this.region = SafeAreaRegion.create(new SphereArea({ center: spawnLocation, radius: 30 }, 'overworld')) - } + this.region = SafeAreaRegion.create(new SphereArea({ center: spawnLocation, radius: 30 }, 'overworld')) + } + }) } loadInventory(player: Player): void { @@ -97,7 +107,7 @@ class SpawnBuilder extends AreaWithInventory { xp: 0, health: 20, equipment: {}, - slots: { 0: Menu.itemStack }, + slots: { 0: Menu.itemStack.value }, }, clearAll: true, }) diff --git a/src/modules/places/stone-quarry/barman.ts b/src/modules/places/stone-quarry/barman.ts index 2401f6a0..f5f9ed0e 100644 --- a/src/modules/places/stone-quarry/barman.ts +++ b/src/modules/places/stone-quarry/barman.ts @@ -1,9 +1,8 @@ -import { ItemStack } from '@minecraft/server' +import { ItemStack, Potions } from '@minecraft/server' import { MinecraftPotionEffectTypes as e, MinecraftItemTypes as i, - MinecraftPotionLiquidTypes as lt, - MinecraftPotionModifierTypes as mt, + MinecraftPotionDeliveryTypes as lt, } from '@minecraft/vanilla-data' import { i18n, i18nShared } from 'lib/i18n/text' import { Group } from 'lib/rpg/place' @@ -19,40 +18,23 @@ export class Barman extends ShopNpc { form.itemStack(new ItemStack(i.MilkBucket), new MoneyCost(10)) form.itemStack(new ItemStack(i.HoneyBottle), new MoneyCost(20)) - form.itemStack( - ItemStack.createPotion({ effect: e.FireResistance, liquid: lt.Lingering }).setInfo(i18n`Квас`, undefined), - new MoneyCost(40), - ) + form.itemStack(Potions.resolve(e.FireResistance, lt.Consume).setInfo(i18n`Квас`, undefined), new MoneyCost(40)) form.itemStack( - ItemStack.createPotion({ effect: e.FireResistance, liquid: lt.Lingering, modifier: mt.Long }).setInfo( - i18n`Пиво`, - undefined, - ), + Potions.resolve(e.LongFireResistance, lt.Consume).setInfo(i18n`Пиво`, undefined), new MoneyCost(50), ) - form.itemStack( - ItemStack.createPotion({ effect: e.Invisibility, liquid: lt.Lingering, modifier: mt.Long }).setInfo( - i18n`Сидр`, - undefined, - ), - new MoneyCost(500), - ) + form.itemStack(Potions.resolve(e.LongInvisibility, lt.Consume).setInfo(i18n`Сидр`, undefined), new MoneyCost(500)) form.itemStack( - ItemStack.createPotion({ effect: e.WaterBreath, liquid: lt.Lingering, modifier: mt.Long }).setInfo( - i18n`Настойка из шпината`, - undefined, - ), + Potions.resolve(e.LongWaterBreathing, lt.Consume).setInfo(i18n`Настойка из шпината`, undefined), new MoneyCost(300), ) + form.potion form.itemStack( - ItemStack.createPotion({ effect: e.TurtleMaster, liquid: lt.Lingering, modifier: mt.Long }).setInfo( - i18n`Вино`, - undefined, - ), + Potions.resolve(e.LongTurtleMaster, lt.Consume).setInfo(i18n`Вино`, undefined), new MoneyCost(1000), ) }) diff --git a/src/modules/places/stone-quarry/furnacer.ts b/src/modules/places/stone-quarry/furnacer.ts index 63082ae0..3b0f2ff4 100644 --- a/src/modules/places/stone-quarry/furnacer.ts +++ b/src/modules/places/stone-quarry/furnacer.ts @@ -1,6 +1,6 @@ import { ContainerSlot, Player, TicksPerSecond, system, world } from '@minecraft/server' import { MinecraftItemTypes } from '@minecraft/vanilla-data' -import { Vec, getAuxOrTexture, ms } from 'lib' + import { Sounds } from 'lib/assets/custom-sounds' import { defaultLang } from 'lib/assets/lang' import { table } from 'lib/database/abstract' @@ -12,6 +12,9 @@ import { FreeCost, MoneyCost } from 'lib/shop/cost' import { ShopNpc } from 'lib/shop/npc' import { lockBlockPriorToNpc } from 'modules/survival/locked-features' import { StoneQuarry } from './stone-quarry' +import { Vec } from 'lib/vector' +import { getAuxOrTexture } from 'lib/form/chest' +import { ms } from 'lib/utils/ms' const furnaceExpireTime = ms.from('hour', 1) const furnaceExpireTimeText = i18n`Ключ теперь привязан к этой печке! В течении часа вы можете открывать ее с помощью этого ключа!` diff --git a/src/modules/places/stone-quarry/gunsmith.ts b/src/modules/places/stone-quarry/gunsmith.ts index 59c96ce1..8aeffb32 100644 --- a/src/modules/places/stone-quarry/gunsmith.ts +++ b/src/modules/places/stone-quarry/gunsmith.ts @@ -1,6 +1,7 @@ import { ContainerSlot, ItemStack, Player } from '@minecraft/server' import { MinecraftItemTypes as i, MinecraftBlockTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { translateTypeId } from 'lib' +import { translateTypeId } from 'lib/i18n/lang' + import { i18n, i18nShared } from 'lib/i18n/text' import { Group } from 'lib/rpg/place' import { rollChance } from 'lib/rpg/random' diff --git a/src/modules/places/stone-quarry/stone-quarry.ts b/src/modules/places/stone-quarry/stone-quarry.ts index 3521ddbc..cc158b49 100644 --- a/src/modules/places/stone-quarry/stone-quarry.ts +++ b/src/modules/places/stone-quarry/stone-quarry.ts @@ -1,5 +1,5 @@ import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Loot } from 'lib' + import { i18n, i18nShared } from 'lib/i18n/text' import { AuntZina } from 'modules/places/stone-quarry/aunt-zina' import { Barman } from 'modules/places/stone-quarry/barman' @@ -12,6 +12,7 @@ import { Woodman } from '../lib/npc/woodman' import { Furnacer } from './furnacer' import { Gunsmith } from './gunsmith' import { createBossWither } from './wither.boss' +import { Loot } from 'lib/rpg/loot-table' class StoneQuarryBuilder extends City { constructor() { diff --git a/src/modules/places/stone-quarry/wither.boss.ts b/src/modules/places/stone-quarry/wither.boss.ts index e156eeeb..15bc9024 100644 --- a/src/modules/places/stone-quarry/wither.boss.ts +++ b/src/modules/places/stone-quarry/wither.boss.ts @@ -1,9 +1,12 @@ import { BlockTypes, EntityComponentTypes } from '@minecraft/server' import { MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { Boss, Loot, ms } from 'lib' + import { i18nShared, noI18n } from 'lib/i18n/text' import { BigRegionStructure } from 'lib/region/big-structure' +import { Loot } from 'lib/rpg/loot-table' +import { Boss } from 'lib/rpg/boss' import { Group } from 'lib/rpg/place' +import { ms } from 'lib/utils/ms' export function createBossWither(group: Group) { const boss = Boss.create() diff --git a/src/modules/places/tech-city/engineer.ts b/src/modules/places/tech-city/engineer.ts index 074c2a3e..8fd54ee7 100644 --- a/src/modules/places/tech-city/engineer.ts +++ b/src/modules/places/tech-city/engineer.ts @@ -2,7 +2,7 @@ import { ItemStack, Player } from '@minecraft/server' import { MinecraftItemTypes as i, MinecraftItemTypes } from '@minecraft/vanilla-data' import { Items } from 'lib/assets/custom-items' import { i18n, i18nShared } from 'lib/i18n/text' -import { customItems, CustomItemWithBlueprint } from 'lib/rpg/custom-item' +import { CustomItem, CustomItemWithBlueprint } from 'lib/rpg/custom-item' import { isNewbie } from 'lib/rpg/newbie' import { Group } from 'lib/rpg/place' import { Cost, ItemCost, MultiCost } from 'lib/shop/cost' @@ -11,16 +11,11 @@ import { CannonItem, CannonShellItem } from 'modules/pvp/cannon' import { BaseItem } from '../base/base' import { MagicSlimeBall } from '../village-of-explorers/items' -export const CircuitBoard = new ItemStack(Items.CircuitBoard).setInfo( - undefined, +export const CircuitBoard = new CustomItem(Items.CircuitBoard).lore( i18n`Используется для создания базы у Инжинера в Технограде\n\nМожно получить из усиленного сундука и робота`, ) -export const Chip = new ItemStack(Items.Chip).setInfo( - undefined, - i18n`Используется для создания платы у Инжинера в Технограде`, -) -customItems.push(CircuitBoard, Chip) +export const Chip = new CustomItem(Items.Chip).lore(i18n`Используется для создания платы у Инжинера в Технограде`) export const NotNewbieCost = new (class NotNewbieCost extends Cost { toString(player: Player, canBuy?: boolean): string { @@ -48,25 +43,25 @@ export class Engineer extends ShopNpc { menu.itemStack( BaseItem.itemStack, new MultiCost(NotNewbieCost) - .item(CircuitBoard) + .item(CircuitBoard.itemStack) .item(MinecraftItemTypes.NetherStar) .item(BaseItem.blueprint) .item(MinecraftItemTypes.EnderPearl, 5) - .item(MagicSlimeBall, 30) + .item(MagicSlimeBall.itemStack, 30) .money(4_000), ) for (const [item, cost] of [ - [CannonItem, new MultiCost().item(Chip).money(200)], + [CannonItem, new MultiCost().item(Chip.itemStack).money(200)], [CannonShellItem, new MultiCost().item(MinecraftItemTypes.Gunpowder, 20).money(100)], ] as [CustomItemWithBlueprint, Cost][]) { menu.itemStack(item.itemStack, new MultiCost(new ItemCost(item.blueprint), cost)) } menu.itemStack( - CircuitBoard, + CircuitBoard.itemStack, new MultiCost() - .item(Chip) + .item(Chip.itemStack) .item(MinecraftItemTypes.IronIngot, 20) .item(MinecraftItemTypes.GoldIngot, 10) .item(MinecraftItemTypes.Quartz, 10) diff --git a/src/modules/places/tech-city/golem.boss.ts b/src/modules/places/tech-city/golem.boss.ts index 856da568..fac6d0e5 100644 --- a/src/modules/places/tech-city/golem.boss.ts +++ b/src/modules/places/tech-city/golem.boss.ts @@ -1,9 +1,13 @@ import { world } from '@minecraft/server' import { MinecraftEffectTypes, MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { Boss, Loot, ms, Vec } from 'lib' -import { i18n, i18nShared } from 'lib/i18n/text' + +import { i18nShared } from 'lib/i18n/text' import { Group } from 'lib/rpg/place' import { Chip } from './engineer' +import { Vec } from 'lib/vector' +import { ms } from 'lib/utils/ms' +import { Loot } from 'lib/rpg/loot-table' +import { Boss } from 'lib/rpg/boss' export function createBossGolem(group: Group) { const boss = Boss.create() diff --git a/src/modules/places/tech-city/tech-city.ts b/src/modules/places/tech-city/tech-city.ts index 0039e20b..293a5282 100644 --- a/src/modules/places/tech-city/tech-city.ts +++ b/src/modules/places/tech-city/tech-city.ts @@ -1,6 +1,7 @@ -import { Loot } from 'lib' import { i18n, i18nShared } from 'lib/i18n/text' import { CutArea } from 'lib/region/areas/cut' +import { Loot } from 'lib/rpg/loot-table' +import { onLoad } from 'lib/utils/load-ref' import { CannonItem, CannonShellItem } from 'modules/pvp/cannon' import { QuartzMineRegion } from '../anarchy/quartz' import { BaseItem } from '../base/base' @@ -15,7 +16,9 @@ import { createBossGolem } from './golem.boss' class TechCityBuilder extends City { constructor() { super('TechCity', i18nShared`Техноград`) - this.create() + onLoad(() => { + this.create() + }) } engineer = new Engineer(this.group) diff --git a/src/modules/places/village-of-explorers/items.ts b/src/modules/places/village-of-explorers/items.ts index d1917d6e..4baf92ec 100644 --- a/src/modules/places/village-of-explorers/items.ts +++ b/src/modules/places/village-of-explorers/items.ts @@ -1,10 +1,8 @@ -import { ItemStack } from '@minecraft/server' import { MinecraftItemTypes } from '@minecraft/vanilla-data' import { i18n } from 'lib/i18n/text' -import { customItems } from 'lib/rpg/custom-item' +import { CustomItem } from 'lib/rpg/custom-item' -export const MagicSlimeBall = new ItemStack(MinecraftItemTypes.SlimeBall).setInfo( - i18n`§aМагическая слизь`, - i18n`Используется у Инженера`, -) -customItems.push(MagicSlimeBall) +export const MagicSlimeBall = new CustomItem('magicSlimeBall') + .typeId(MinecraftItemTypes.SlimeBall) + .nameTag(i18n`§aМагическая слизь`) + .lore(i18n`Используется у Инженера`) diff --git a/src/modules/places/village-of-explorers/mage.ts b/src/modules/places/village-of-explorers/mage.ts index 40a05d04..6393d215 100644 --- a/src/modules/places/village-of-explorers/mage.ts +++ b/src/modules/places/village-of-explorers/mage.ts @@ -6,10 +6,11 @@ import { MinecraftEnchantmentTypes, MinecraftItemTypes, MinecraftPotionEffectTypes, - MinecraftPotionModifierTypes, } from '@minecraft/vanilla-data' -import { addNamespace, doNothing, Enchantments, getAuxOrTexture } from 'lib' + import { Sounds } from 'lib/assets/custom-sounds' +import { Enchantments } from 'lib/enchantments' +import { getAuxOrTexture } from 'lib/form/chest' import { translateEnchantment, translateTypeId } from 'lib/i18n/lang' import { i18n, i18nShared } from 'lib/i18n/text' import { Group } from 'lib/rpg/place' @@ -17,6 +18,7 @@ import { Cost, MoneyCost, MultiCost } from 'lib/shop/cost' import { ErrorCost, FreeCost } from 'lib/shop/cost/cost' import { ShopFormSection } from 'lib/shop/form' import { ShopNpc } from 'lib/shop/npc' +import { addNamespace, doNothing } from 'lib/util' import { copyAllItemPropertiesExceptEnchants } from 'lib/utils/game' import { FireBallItem } from 'modules/pvp/fireball' import { IceBombItem } from 'modules/pvp/ice-bomb' @@ -176,10 +178,10 @@ export class Mage extends ShopNpc { form.potion(new MoneyCost(100), MinecraftPotionEffectTypes.Strength) form.potion(new MoneyCost(100), MinecraftPotionEffectTypes.Healing) form.potion(new MoneyCost(100), MinecraftPotionEffectTypes.Swiftness) - form.potion(new MoneyCost(10), MinecraftPotionEffectTypes.NightVision, MinecraftPotionModifierTypes.Long) + form.potion(new MoneyCost(10), MinecraftPotionEffectTypes.LongNightvision) }) - .itemStack(IceBombItem, new MoneyCost(100)) - .itemStack(FireBallItem, new MoneyCost(100)) + .itemStack(IceBombItem.itemStack, new MoneyCost(100)) + .itemStack(FireBallItem.itemStack, new MoneyCost(100)) .itemStack(new ItemStack(i.TotemOfUndying), new MultiCost().money(6_000).item(i.Emerald, 1)) .itemStack(new ItemStack(i.EnchantedGoldenApple), new MultiCost().item(i.GoldenApple).money(10_000)), ) diff --git a/src/modules/places/village-of-explorers/slime.boss.ts b/src/modules/places/village-of-explorers/slime.boss.ts index 1e30d323..4527e527 100644 --- a/src/modules/places/village-of-explorers/slime.boss.ts +++ b/src/modules/places/village-of-explorers/slime.boss.ts @@ -1,10 +1,12 @@ import { world } from '@minecraft/server' import { MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { Loot, ms } from 'lib' + import { i18nShared } from 'lib/i18n/text' import { Boss } from 'lib/rpg/boss' import { Group } from 'lib/rpg/place' import { MagicSlimeBall } from './items' +import { ms } from 'lib/utils/ms' +import { Loot } from 'lib/rpg/loot-table' export function createBossSlime(group: Group) { const boss = Boss.create() diff --git a/src/modules/places/village-of-explorers/village-of-explorers.ts b/src/modules/places/village-of-explorers/village-of-explorers.ts index 2719c235..7fc05f6b 100644 --- a/src/modules/places/village-of-explorers/village-of-explorers.ts +++ b/src/modules/places/village-of-explorers/village-of-explorers.ts @@ -1,4 +1,3 @@ -import { Loot } from 'lib' import { i18n, i18nShared } from 'lib/i18n/text' import { City } from '../lib/city' import { Butcher } from '../lib/npc/butcher' @@ -9,6 +8,7 @@ import { techCityInvestigating } from '../tech-city/quests/investigating' import { MagicSlimeBall } from './items' import { Mage } from './mage' import { createBossSlime } from './slime.boss' +import { Loot } from 'lib/rpg/loot-table' class VillageOfExporersBuilder extends City { constructor() { @@ -37,7 +37,7 @@ class VillageOfExporersBuilder extends City { i18n`Исследователи тип, не понял что ли, глупик, путешествуй смотри наслаждайся, ИССЛЕДУЙ`, ) - f.quest(techCityInvestigating.goToCityQuest, i18n`А где мне базу сделать-то?`) + lf.quest(techCityInvestigating.goToCityQuest, i18n`А где мне базу сделать-то?`) }) } diff --git a/src/modules/places/village-of-miners/village-of-miners.ts b/src/modules/places/village-of-miners/village-of-miners.ts index 7ae21441..700d1c60 100644 --- a/src/modules/places/village-of-miners/village-of-miners.ts +++ b/src/modules/places/village-of-miners/village-of-miners.ts @@ -1,4 +1,3 @@ -import { Loot, migrateLocationName } from 'lib' import { i18n, i18nShared } from 'lib/i18n/text' import { City } from '../lib/city' import { Butcher } from '../lib/npc/butcher' @@ -7,6 +6,8 @@ import { Stoner } from '../lib/npc/stoner' import { Woodman } from '../lib/npc/woodman' import { stoneQuarryInvestigating } from '../stone-quarry/quests/investigating' import { createMineQuests } from './quests/mine-x-blocks' +import { Loot } from 'lib/rpg/loot-table' +import { migrateLocationName } from 'lib/location' class VillageOfMinersBuilder extends City { constructor() { @@ -75,7 +76,7 @@ class VillageOfMinersBuilder extends City { i18n`Они есть... просто они сидят дома и смотрят стрим @shp1natqp`, ) - f.quest( + lf.quest( stoneQuarryInvestigating.goToCityQuest, i18n`Как мне переплавить руду?`, i18n`Возьми у меня задание и отправляйся в другое поселение следуя компасу.`, diff --git a/src/modules/pvp/cannon.ts b/src/modules/pvp/cannon.ts index a0f58169..2ff2d800 100644 --- a/src/modules/pvp/cannon.ts +++ b/src/modules/pvp/cannon.ts @@ -1,11 +1,16 @@ import { EntityComponentTypes, Player, system, world } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { actionGuard, ActionGuardOrder, Cooldown, ms, Vec } from 'lib' + import { CustomEntityTypes } from 'lib/assets/custom-entity-types' import { i18n } from 'lib/i18n/text' import { CustomItemWithBlueprint } from 'lib/rpg/custom-item' import { explosibleEntities, ExplosibleEntityOptions } from './explosible-entities' import { decreaseMainhandItemCount } from './throwable-tnt' +import { Vec } from 'lib/vector' +import { ActionGuardOrder } from 'lib/region' +import { actionGuard } from 'lib/region' +import { ms } from 'lib/utils/ms' +import { Cooldown } from 'lib/cooldown' export const CannonItem = new CustomItemWithBlueprint('cannon') .typeId('lw:cannon_spawn_egg') diff --git a/src/modules/pvp/explosible-entities.ts b/src/modules/pvp/explosible-entities.ts index e844f5b8..2f951599 100644 --- a/src/modules/pvp/explosible-entities.ts +++ b/src/modules/pvp/explosible-entities.ts @@ -1,6 +1,6 @@ import { Entity, EntityDamageCause, ExplosionOptions, Player, system } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { getEdgeBlocksOf } from 'modules/places/mineshaft/get-edge-blocks-of' import { createBlockExplosionChecker } from './raid' diff --git a/src/modules/pvp/explosible-fireworks.ts b/src/modules/pvp/explosible-fireworks.ts index b7ad2f0d..725888fc 100644 --- a/src/modules/pvp/explosible-fireworks.ts +++ b/src/modules/pvp/explosible-fireworks.ts @@ -1,6 +1,6 @@ import { Entity, Player, system, world } from '@minecraft/server' import { MinecraftEntityTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { explosibleEntities, ExplosibleEntityOptions } from './explosible-entities' const fireworks = new Set<{ date: number; entity: Entity }>() diff --git a/src/modules/pvp/fireball.ts b/src/modules/pvp/fireball.ts index 9902be62..2fe728cf 100644 --- a/src/modules/pvp/fireball.ts +++ b/src/modules/pvp/fireball.ts @@ -1,19 +1,14 @@ -import { ItemStack, system, world } from '@minecraft/server' +import { system, world } from '@minecraft/server' -import { Vec } from 'lib' import { CustomEntityTypes } from 'lib/assets/custom-entity-types' import { Items } from 'lib/assets/custom-items' import { i18n } from 'lib/i18n/text' -import { customItems } from 'lib/rpg/custom-item' +import { CustomItem } from 'lib/rpg/custom-item' +import { Vec } from 'lib/vector' import { explosibleEntities, ExplosibleEntityOptions } from './explosible-entities' import { decreaseMainhandItemCount } from './throwable-tnt' -export const FireBallItem = new ItemStack(Items.Fireball).setInfo( - undefined, - i18n`Используйте, чтобы отправить все в огненный ад`, -) - -customItems.push(FireBallItem) +export const FireBallItem = new CustomItem(Items.Fireball).lore(i18n`Используйте, чтобы отправить все в огненный ад`) const fireballExplosion: ExplosibleEntityOptions = { damage: 3, @@ -23,7 +18,7 @@ const fireballExplosion: ExplosibleEntityOptions = { } world.afterEvents.itemUse.subscribe(event => { - if (!FireBallItem.is(event.itemStack)) return + if (!FireBallItem.isItem(event.itemStack)) return decreaseMainhandItemCount(event.source) diff --git a/src/modules/pvp/ice-bomb.ts b/src/modules/pvp/ice-bomb.ts index bc2b4f7d..47a4f8b9 100644 --- a/src/modules/pvp/ice-bomb.ts +++ b/src/modules/pvp/ice-bomb.ts @@ -1,19 +1,19 @@ -import { Entity, EntityComponentTypes, ItemStack, Player, system, world } from '@minecraft/server' +import { Entity, EntityComponentTypes, Player, system, world } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEntityTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { Vec, ms } from 'lib' + import { i18n } from 'lib/i18n/text' -import { customItems } from 'lib/rpg/custom-item' +import { CustomItem } from 'lib/rpg/custom-item' import { ScheduleBlockPlace } from 'lib/scheduled-block-place' +import { ms } from 'lib/utils/ms' import { toPoint } from 'lib/utils/point' +import { Vec } from 'lib/vector' import { WeakPlayerSet } from 'lib/weak-player-storage' import { BaseRegion } from 'modules/places/base/region' import { getEdgeBlocksOf } from 'modules/places/mineshaft/get-edge-blocks-of' -export const IceBombItem = new ItemStack(MinecraftItemTypes.Snowball).setInfo( - i18n`§3Снежная бомба`, - i18n`Используйте, чтобы отправить все к снежной королеве подо льдину`, -) -customItems.push(IceBombItem) +export const IceBombItem = new CustomItem(MinecraftItemTypes.Snowball) + .nameTag(i18n`§3Снежная бомба`) + .lore(i18n`Используйте, чтобы отправить все к снежной королеве подо льдину`) const ICE_BOMB_TRANSOFORM: Record = { [MinecraftBlockTypes.Water]: MinecraftBlockTypes.FrostedIce, @@ -26,7 +26,7 @@ const iceBombs = new Set() const usedIceBombs = new WeakPlayerSet() world.afterEvents.itemUse.subscribe(event => { - if (!event.itemStack.is(IceBombItem)) return + if (!IceBombItem.isItem(event.itemStack)) return usedIceBombs.add(event.source) }) diff --git a/src/modules/pvp/item-ability.ts b/src/modules/pvp/item-ability.ts index f3d67ce0..24fe2349 100644 --- a/src/modules/pvp/item-ability.ts +++ b/src/modules/pvp/item-ability.ts @@ -1,9 +1,10 @@ import { EntityDamageCause, world } from '@minecraft/server' -import { isKeyof } from 'lib' + import { defaultLang } from 'lib/assets/lang' import { ItemLoreSchema } from 'lib/database/item-stack' import { i18n, i18nShared, noI18n } from 'lib/i18n/text' import { rollChance } from 'lib/rpg/random' +import { isKeyof } from 'lib/util' import { createLogger } from 'lib/utils/logger' const logger = createLogger('ItemAbility') diff --git a/src/modules/pvp/raid.ts b/src/modules/pvp/raid.ts index 88ad87ba..7aa7d64c 100644 --- a/src/modules/pvp/raid.ts +++ b/src/modules/pvp/raid.ts @@ -1,15 +1,19 @@ import { Block, Entity, system, world } from '@minecraft/server' -import { LockAction, ms, Region } from 'lib' +import { LockAction } from 'lib/action' + import { ScoreboardDB } from 'lib/database/scoreboard' import { i18n } from 'lib/i18n/text' +import { Region } from 'lib/region' import { MineareaRegion } from 'lib/region/kinds/minearea' import { ScheduleBlockPlace } from 'lib/scheduled-block-place' +import { onLoad } from 'lib/utils/load-ref' +import { ms } from 'lib/utils/ms' import { BaseRegion } from 'modules/places/base/region' const notify = new Map() const targetLockTime = ms.from('min', 8) const raiderLockTime = ms.from('min', 10) -const objective = ScoreboardDB.objective('raid') +const objective = onLoad(() => ScoreboardDB.objective('raid')) world.beforeEvents.explosion.subscribe(event => { const checker = createBlockExplosionChecker() @@ -78,11 +82,11 @@ system.runInterval( } else notify.set(id, { time: time - 1, reason }) } - for (const { participant, score } of objective.getScores()) { + for (const { participant, score } of objective.value.getScores()) { if (score > 1) { - objective.addScore(participant, -1) + objective.value.addScore(participant, -1) } else { - objective.removeParticipant(participant) + objective.value.removeParticipant(participant) } } }, diff --git a/src/modules/pvp/throwable-tnt.ts b/src/modules/pvp/throwable-tnt.ts index c3c81019..0f1efec8 100644 --- a/src/modules/pvp/throwable-tnt.ts +++ b/src/modules/pvp/throwable-tnt.ts @@ -1,8 +1,8 @@ import { GameMode, Player, system, world } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftEntityTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { Vec } from 'lib' import { Cooldown } from 'lib/cooldown' import { ms } from 'lib/utils/ms' +import { Vec } from 'lib/vector' import { explosibleEntities, ExplosibleEntityOptions } from './explosible-entities' const cooldown = new Cooldown(ms.from('sec', 3)) diff --git a/src/modules/quests/daily/index.ts b/src/modules/quests/daily/index.ts index 0381e3ac..ba35388a 100644 --- a/src/modules/quests/daily/index.ts +++ b/src/modules/quests/daily/index.ts @@ -1,12 +1,14 @@ import { Player } from '@minecraft/server' -import { doNothing, noNullable } from 'lib' + import { table } from 'lib/database/abstract' import { form } from 'lib/form/new' +import { QuestForm } from 'lib/form/quest' import { intlListFormat } from 'lib/i18n/intl' import { i18n, textTable } from 'lib/i18n/text' import { questMenuCustomButtons } from 'lib/quest/menu' import { DailyQuest } from 'lib/quest/quest' import { RecurringEvent } from 'lib/recurring-event' +import { noNullable } from 'lib/util' import later from 'lib/utils/later' import { City } from 'modules/places/lib/city' import { CityInvestigating } from 'modules/places/lib/city-investigating-quest' @@ -67,7 +69,7 @@ new RecurringEvent( currentDailyQuestCity = mostPopular storage.cityId = mostPopular?.group.id ?? '' - for (const value of db.values()) { + for (const [, value] of db.entries()) { if (!value.takenToday) value.streak = 0 value.today = 0 value.takenToday = false @@ -114,11 +116,14 @@ questMenuCustomButtons.subscribe(({ player, form }) => { }) ) { const playerDb = db.get(player.id) - form.button(i18n.accent`Ежедневные задания`.badge(dailyQuests - playerDb.today).to(player.lang), dailyQuestsForm.show) + form.button( + i18n.accent`Ежедневные задания`.badge(dailyQuests - playerDb.today).to(player.lang), + dailyQuestsForm.show, + ) } }) -export const dailyQuestsForm = form((f, { player }) => { +export const dailyQuestsForm = form((f, { player, self }) => { const playerDb = db.get(player.id) f.title(i18n`Ежедневные задания`) f.body( @@ -150,6 +155,6 @@ export const dailyQuestsForm = form((f, { player }) => { } for (const quest of currentDailyQuests) { - f.quest(quest) + new QuestForm(f, player, self).quest(quest) } }) diff --git a/src/modules/quests/learning/airdrop.ts b/src/modules/quests/learning/airdrop.ts index 9e5ed914..3ea7f351 100644 --- a/src/modules/quests/learning/airdrop.ts +++ b/src/modules/quests/learning/airdrop.ts @@ -1,5 +1,5 @@ -import { Loot } from 'lib' import { Items } from 'lib/assets/custom-items' +import { Loot } from 'lib/rpg/loot-table' export default new Loot('starter') .item('WoodenSword') diff --git a/src/modules/quests/learning/learning.ts b/src/modules/quests/learning/learning.ts index c9667ac2..4c317941 100644 --- a/src/modules/quests/learning/learning.ts +++ b/src/modules/quests/learning/learning.ts @@ -1,8 +1,7 @@ import { EquipmentSlot, ItemStack, system } from '@minecraft/server' -import { ActionForm, ActionGuardOrder, location, Temporary, Vec } from 'lib' import { MinecraftBlockTypes as b, MinecraftBlockTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { actionGuard } from 'lib' + import { Sounds } from 'lib/assets/custom-sounds' import { Join } from 'lib/player-join' import { Quest } from 'lib/quest/index' @@ -11,13 +10,19 @@ import { createPublicGiveItemCommand, Menu } from 'lib/rpg/menu' import { Items } from 'lib/assets/custom-items' import { ActionbarPriority } from 'lib/extensions/on-screen-display' +import { ActionForm } from 'lib/form/action' import { i18n, i18nShared, noI18n } from 'lib/i18n/text' +import { location } from 'lib/location' +import { actionGuard, ActionGuardOrder } from 'lib/region' import { RegionEvents } from 'lib/region/events' import { MineareaRegion } from 'lib/region/kinds/minearea' import { enterNewbieMode } from 'lib/rpg/newbie' import { noGroup } from 'lib/rpg/place' +import { Temporary } from 'lib/temporary' +import { onLoad } from 'lib/utils/load-ref' import { createLogger } from 'lib/utils/logger' import { createPointVec } from 'lib/utils/point' +import { Vec } from 'lib/vector' import { WeakPlayerMap, WeakPlayerSet } from 'lib/weak-player-storage' import { Anarchy } from 'modules/places/anarchy/anarchy' import { OrePlace, ores } from 'modules/places/mineshaft/algo' @@ -64,8 +69,8 @@ class Learning { // in spawn inventory that will be replaced with // anarchy system.delay(() => { - this.startAxeGiveCommand.ensure(player) - player.getComponent('equippable')?.setEquipment(EquipmentSlot.Offhand, Menu.itemStack) + this.startAxeGiveCommand.value.ensure(player) + player.getComponent('equippable')?.setEquipment(EquipmentSlot.Offhand, Menu.itemStack.value) }) } @@ -271,11 +276,13 @@ class Learning { craftingTableLocation = location(this.quest.group.place('crafting table').name(noI18n`Верстак`)) - startAxeGiveCommand = createPublicGiveItemCommand( - 'startwand', - new ItemStack(MinecraftItemTypes.WoodenAxe), - s => s.typeId === MinecraftItemTypes.WoodenAxe && s.getDynamicProperty('startwand') === true, - i18n`§r§6Начальный топор`, + startAxeGiveCommand = onLoad(() => + createPublicGiveItemCommand( + 'startwand', + new ItemStack(MinecraftItemTypes.WoodenAxe), + s => s.typeId === MinecraftItemTypes.WoodenAxe && s.getDynamicProperty('startwand') === true, + i18n`§r§6Начальный топор`, + ), ) blockedOre = new WeakPlayerMap() diff --git a/src/modules/survival/cleanup.ts b/src/modules/survival/cleanup.ts index ffec3c72..70a9c674 100644 --- a/src/modules/survival/cleanup.ts +++ b/src/modules/survival/cleanup.ts @@ -9,7 +9,7 @@ // TicksPerSecond, // world, // } from '@minecraft/server' -// import { ms, Settings } from 'lib' +// // import { i18n } from 'lib/i18n/text' // import { createLogger } from 'lib/utils/logger' // import { gravestoneEntityTypeId, gravestoneGetOwner } from './death-quest-and-gravestone' diff --git a/src/modules/survival/death-quest-and-gravestone.ts b/src/modules/survival/death-quest-and-gravestone.ts index 8831b875..2fffdcef 100644 --- a/src/modules/survival/death-quest-and-gravestone.ts +++ b/src/modules/survival/death-quest-and-gravestone.ts @@ -1,11 +1,17 @@ import { Entity, Player, system, world } from '@minecraft/server' -import { actionGuard, Cooldown, EventSignal, inventoryIsEmpty, ms, Settings, Vec } from 'lib' + import { CustomEntityTypes } from 'lib/assets/custom-entity-types' +import { EventSignal } from 'lib/event-signal' +import { Cooldown } from 'lib/cooldown' import { i18n, i18nShared, noI18n } from 'lib/i18n/text' import { Quest } from 'lib/quest/quest' -import { ActionGuardOrder, forceAllowSpawnInRegion, Region } from 'lib/region' +import { actionGuard, ActionGuardOrder, forceAllowSpawnInRegion, Region } from 'lib/region' import { SphereArea } from 'lib/region/areas/sphere' +import { inventoryIsEmpty } from 'lib/rpg/airdrop' import { noGroup } from 'lib/rpg/place' +import { Settings } from 'lib/settings' +import { Vec } from 'lib/vector' +import { ms } from 'lib/utils/ms' import { SafePlace } from 'modules/places/lib/safe-place' import { Spawn } from 'modules/places/spawn' diff --git a/src/modules/survival/locked-features.ts b/src/modules/survival/locked-features.ts index 999690bd..b223a72c 100644 --- a/src/modules/survival/locked-features.ts +++ b/src/modules/survival/locked-features.ts @@ -1,6 +1,7 @@ -import { actionGuard, ActionGuardOrder } from 'lib' import { intlListFormat } from 'lib/i18n/intl' import { i18n } from 'lib/i18n/text' +import { ActionGuardOrder } from 'lib/region' +import { actionGuard } from 'lib/region' const blocked: Record = {} diff --git a/src/modules/survival/menu.ts b/src/modules/survival/menu.ts index 8af7e67e..4d41028f 100644 --- a/src/modules/survival/menu.ts +++ b/src/modules/survival/menu.ts @@ -1,114 +1,81 @@ -import { Player } from "@minecraft/server"; -import { BUTTON, doNothing } from "lib"; -import { - achievementsForm, - achievementsFormName, -} from "lib/achievements/command"; -import { clanMenu } from "lib/clan/menu"; -import { Core } from "lib/extensions/core"; -import { form } from "lib/form/new"; -import { i18n } from "lib/i18n/text"; -import { Mail } from "lib/mail"; -import { Join } from "lib/player-join"; -import { questsMenu } from "lib/quest/menu"; -import { Menu } from "lib/rpg/menu"; -import { playerSettingsMenu } from "lib/settings"; -import { mailMenu } from "modules/commands/mail"; -import { statsForm } from "modules/commands/stats"; -import { baseMenu } from "modules/places/base/base-menu"; -import { wiki } from "modules/wiki/wiki"; -import { Anarchy } from "../places/anarchy/anarchy"; -import { Spawn } from "../places/spawn"; -import { recurForm } from "./recurring-events"; -import { speedrunForm } from "./speedrun/target"; +import { Player } from '@minecraft/server' +import { achievementsForm, achievementsFormName } from 'lib/achievements/command' +import { clanMenu } from 'lib/clan/menu' +import { Core } from 'lib/extensions/core' +import { form } from 'lib/form/new' +import { BUTTON } from 'lib/form/utils' +import { i18n } from 'lib/i18n/text' +import { Mail } from 'lib/mail' +import { mailMenu } from 'lib/mail/command' +import { Join } from 'lib/player-join' +import { questsMenu } from 'lib/quest/menu' +import { Menu } from 'lib/rpg/menu' +import { playerSettingsMenu } from 'lib/settings' +import { doNothing } from 'lib/util' +import { statsForm } from 'modules/commands/stats' +import { baseMenu } from 'modules/places/base/base-menu' +import { wiki } from 'modules/wiki/wiki' +import { Anarchy } from '../places/anarchy/anarchy' +import { Spawn } from '../places/spawn' +import { recurForm } from './recurring-events' +import { speedrunForm } from './speedrun/target' function tp( - player: Player, - place: InventoryTypeName, - inv: InventoryTypeName, - color = "§9", - text = i18n`Спавн`, - extra: Text = "" + player: Player, + place: InventoryTypeName, + inv: InventoryTypeName, + color = '§9', + text = i18n`Спавн`, + extra: Text = '', ) { - const here = inv === place; - if (here) - extra = i18n`${extra ? extra.to(player.lang) + " " : ""}§8Вы тут`.to( - player.lang - ); - if (extra) extra = "\n" + extra.to(player.lang); - const prefix = here ? "§7" : color; - return `${prefix}> ${inv === place ? "§7" : "§r§f"}${text.to( - player.lang - )} ${prefix}<${extra}`; + const here = inv === place + if (here) extra = i18n`${extra ? extra.to(player.lang) + ' ' : ''}§8Вы тут`.to(player.lang) + if (extra) extra = '\n' + extra.to(player.lang) + const prefix = here ? '§7' : color + return `${prefix}> ${inv === place ? '§7' : '§r§f'}${text.to(player.lang)} ${prefix}<${extra}` } Menu.form = form((f, { player, self }) => { - const inv = player.database.inv; - f.title(Core.name, "§c§u§s§r"); - f.button( - tp(player, "spawn", inv, "§9", i18n`Спавн`), - "textures/ui/worldsIcon", - () => { - Spawn.portal?.teleport(player); - } - ) - .button( - tp(player, "anarchy", inv, "§c", i18n`Анархия`), - "textures/blocks/tnt_side", - () => { - Anarchy.portal?.teleport(player); - } - ) - .button( - tp(player, "mg", inv, `§6`, i18n`Миниигры`, i18n`§7СКОРО!`), - "textures/blocks/bedrock", - self - ); + const inv = player.database.inv + f.title(Core.name, '§c§u§s§r') + f.button(tp(player, 'spawn', inv, '§9', i18n`Спавн`), 'textures/ui/worldsIcon', () => { + Spawn.portal?.teleport(player) + }) + .button(tp(player, 'anarchy', inv, '§c', i18n`Анархия`), 'textures/blocks/tnt_side', () => { + Anarchy.portal?.teleport(player) + }) + .button(tp(player, 'mg', inv, `§6`, i18n`Миниигры`, i18n`§7СКОРО!`), 'textures/blocks/bedrock', self) - if (player.database.inv === "anarchy") { - f.button( - i18n`Задания`.badge(player.database.quests?.active.length), - "textures/ui/sidebar_icons/genre", - () => questsMenu(player, self) - ); + if (player.database.inv === 'anarchy') { + f.button(i18n`Задания`.badge(player.database.quests?.active.length), 'textures/ui/sidebar_icons/genre', () => + questsMenu(player, self), + ) - f.button( - achievementsFormName(player), - "textures/blocks/gold_block", - achievementsForm - ); + f.button(achievementsFormName(player), 'textures/blocks/gold_block', achievementsForm) - f.button(i18n`База`, "textures/blocks/barrel_side", baseMenu({})); - const [clanText, clan] = clanMenu(player, self); - f.button(clanText, "textures/ui/FriendsIcon", clan); - } + f.button(i18n`База`, 'textures/blocks/barrel_side', baseMenu({})) + const [clanText, clan] = clanMenu(player, self) + f.button(clanText, 'textures/ui/FriendsIcon', clan) + } - f.button( - i18n.nocolor`§6Донат\n§7СКОРО!`, - "textures/ui/permissions_op_crown", - self - ) - .button( - i18n.nocolor`§fПочта`.badge(Mail.getUnreadMessagesCount(player.id)), - "textures/ui/feedIcon", - () => mailMenu(player, self) - ) - .button(i18n.nocolor`§bВики`, BUTTON.search, wiki.show) + f.button(i18n.nocolor`§6Донат\n§7СКОРО!`, 'textures/ui/permissions_op_crown', self) + .button(i18n.nocolor`§fПочта`.badge(Mail.getUnreadMessagesCount(player.id)), 'textures/ui/feedIcon', () => + mailMenu(player, self), + ) + .button(i18n.nocolor`§bВики`, BUTTON.search, wiki.show) - .button(i18n.nocolor`§7Настройки`, BUTTON.settings, () => - playerSettingsMenu(player, self) - ) - .button(i18n`Еще`, BUTTON[">"], secondPage); -}); + .button(i18n.nocolor`§7Настройки`, BUTTON.settings, () => playerSettingsMenu(player, self)) + .button(i18n`Еще`, BUTTON['>'], secondPage) +}) -const secondPage = form((f) => { - f.title(Core.name, "§c§u§s§r"); - f.button(i18n`Цели`, BUTTON["?"], speedrunForm); - f.button(i18n`Лидеры`, BUTTON["?"], doNothing); - f.button(i18n`События`, BUTTON["?"], recurForm); - f.button(i18n`Статистика`, BUTTON["?"], statsForm({})); -}); +const secondPage = form(f => { + f.title(Core.name, '§c§u§s§r') + f.button(i18n`Цели`, BUTTON['?'], speedrunForm) + f.button(i18n`Лидеры`, BUTTON['?'], doNothing) + f.button(i18n`События`, BUTTON['?'], recurForm) + f.button(i18n`Статистика`, BUTTON['?'], statsForm({})) +}) Join.onMoveAfterJoin.subscribe(({ player, firstJoin }) => { - if (firstJoin) Menu.item.give(player, { mode: "ensure" }); -}); + if (firstJoin) Menu.item.give(player, { mode: 'ensure' }) +}) diff --git a/src/modules/survival/random-teleport.ts b/src/modules/survival/random-teleport.ts index a047d718..310df09d 100644 --- a/src/modules/survival/random-teleport.ts +++ b/src/modules/survival/random-teleport.ts @@ -3,7 +3,6 @@ import { EquipmentSlot, ItemLockMode, - ItemStack, LocationInUnloadedChunkError, LocationOutOfWorldBoundariesError, Player, @@ -12,13 +11,14 @@ import { } from '@minecraft/server' import { MinecraftEffectTypes, MinecraftItemTypes } from '@minecraft/vanilla-data' -import { LockAction, Vec, util } from 'lib' +import { LockAction } from 'lib/action' +import { CustomItem } from 'lib/rpg/custom-item' +import { util } from 'lib/util' +import { Vec } from 'lib/vector' -const RTP_ELYTRA = new ItemStack(MinecraftItemTypes.Elytra, 1).setInfo( - '§6Элитра перемещения', - 'Элитра перелета, пропадает на земле', -) -RTP_ELYTRA.lockMode = ItemLockMode.slot +const RTP_ELYTRA = new CustomItem(MinecraftItemTypes.Elytra) + .nameTag('§6Элитра перемещения') + .lore('Элитра перелета, пропадает на земле') const IN_SKY = new Set() new LockAction(player => IN_SKY.has(player.id), '§cВ начале коснитесь земли!') @@ -156,7 +156,9 @@ function giveElytra(player: Player, c = 5) { } } - slot.setItem(RTP_ELYTRA) + const clone = RTP_ELYTRA.itemStack.clone() + clone.lockMode = ItemLockMode.slot + slot.setItem(clone) player.database.survival.rtpElytra = 1 } @@ -182,6 +184,6 @@ function clearElytra(player: Player) { if (!equippable) return const slot = equippable.getEquipmentSlot(EquipmentSlot.Chest) const item = slot.getItem() - if (item && RTP_ELYTRA.is(item)) slot.setItem(undefined) + if (item && RTP_ELYTRA.isItem(item)) slot.setItem(undefined) delete player.database.survival.rtpElytra } diff --git a/src/modules/survival/realtime.ts b/src/modules/survival/realtime.ts index 708e792d..56442903 100644 --- a/src/modules/survival/realtime.ts +++ b/src/modules/survival/realtime.ts @@ -1,6 +1,7 @@ import { TicksPerDay, TimeOfDay, system, world } from '@minecraft/server' -import { Settings } from 'lib' + import { noI18n } from 'lib/i18n/text' +import { Settings } from 'lib/settings' const MinutesPerDay = 24 * 60 const Offset = 6000 diff --git a/src/modules/survival/recurring-events.ts b/src/modules/survival/recurring-events.ts index a720d683..c6810049 100644 --- a/src/modules/survival/recurring-events.ts +++ b/src/modules/survival/recurring-events.ts @@ -1,14 +1,12 @@ -import { Player, TicksPerSecond, world } from "@minecraft/server"; -import { - MinecraftEffectTypes, - MinecraftEffectTypesUnion, -} from "@minecraft/vanilla-data"; -import { ms, RoadRegion } from "lib"; -import { form } from "lib/form/new"; -import { i18n } from "lib/i18n/text"; -import { DurationalRecurringEvent } from "lib/recurring-event"; -import { RegionEvents } from "lib/region/events"; -import later from "lib/utils/later"; +import { Player, TicksPerSecond, world } from '@minecraft/server' +import { MinecraftEffectTypes, MinecraftEffectTypesUnion } from '@minecraft/vanilla-data' +import { form } from 'lib/form/new' +import { i18n } from 'lib/i18n/text' +import { DurationalRecurringEvent } from 'lib/recurring-event' +import { RoadRegion } from 'lib/region' +import { RegionEvents } from 'lib/region/events' +import later from 'lib/utils/later' +import { ms } from 'lib/utils/ms' // TODO Add settings for players to not apply effects on them // TODO Add command to show menu to view events @@ -17,75 +15,64 @@ import later from "lib/utils/later"; // TODO Add chat notification class RecurringEffect { - static all: RecurringEffect[] = []; + static all: RecurringEffect[] = [] - readonly event: DurationalRecurringEvent; + readonly event: DurationalRecurringEvent - constructor( - readonly effectType: MinecraftEffectTypesUnion, - readonly startingOn: number, - filter?: (p: Player) => boolean, - readonly amplifier = 2 - ) { - RecurringEffect.all.push(this); - this.event = new DurationalRecurringEvent( - `effect${effectType}`, - later.parse.recur().every(5).hour().startingOn(startingOn), - ms.from("min", 10), - () => ({}), - (_, ctx) => { - for (const player of world.getAllPlayers()) { - player.success( - i18n.success`Событие! ${effectType} силой ${amplifier} на ${10} минут` - ); - } - ctx.temp.system.runInterval( - () => { - for (const player of world.getAllPlayers()) { - if (filter && !filter(player)) continue; + constructor( + readonly effectType: MinecraftEffectTypesUnion, + readonly startingOn: number, + filter?: (p: Player) => boolean, + readonly amplifier = 2, + ) { + RecurringEffect.all.push(this) + this.event = new DurationalRecurringEvent( + `effect${effectType}`, + later.parse.recur().every(5).hour().startingOn(startingOn), + ms.from('min', 10), + () => ({}), + (_, ctx) => { + for (const player of world.getAllPlayers()) { + player.success(i18n.success`Событие! ${effectType} силой ${amplifier} на ${10} минут`) + } + ctx.temp.system.runInterval( + () => { + for (const player of world.getAllPlayers()) { + if (filter && !filter(player)) continue - player.addEffect( - MinecraftEffectTypes[effectType], - TicksPerSecond * 3, - { - amplifier, - showParticles: false, - } - ); - } - }, - `effect${effectType}`, - TicksPerSecond * 2 - ); - } - ); - } + player.addEffect(MinecraftEffectTypes[effectType], TicksPerSecond * 3, { + amplifier, + showParticles: false, + }) + } + }, + `effect${effectType}`, + TicksPerSecond * 2, + ) + }, + ) + } } -new RecurringEffect("Haste", 1); -new RecurringEffect("HealthBoost", 2); +new RecurringEffect('Haste', 1) +new RecurringEffect('HealthBoost', 2) new RecurringEffect( - "Speed", - 3, - (p) => - RegionEvents.playerInRegionsCache - .get(p) - ?.some((e) => e instanceof RoadRegion) ?? false, - 4 -); + 'Speed', + 3, + p => RegionEvents.playerInRegionsCache.get(p)?.some(e => e instanceof RoadRegion) ?? false, + 4, +) export const recurForm = form((f, { self }) => { - f.title(i18n`События`); - f.body(i18n`Время: ${new Date().toHHMMSS()}`); + f.title(i18n`События`) + f.body(i18n`Время: ${new Date().toHHMMSS()}`) - const now = Date.now(); - for (const event of RecurringEffect.all) { - const next = event.event.getNextOccurances(1)[0] ?? new Date(); - f.button( - i18n`${event.effectType} ${event.amplifier + 1}\nЧерез ${i18n.time( - next.getTime() - now - )} (${next.toHHMM()})`, - self - ); - } -}); + const now = Date.now() + for (const event of RecurringEffect.all) { + const next = event.event.getNextOccurances(1)[0] ?? new Date() + f.button( + i18n`${event.effectType} ${event.amplifier + 1}\nЧерез ${i18n.time(next.getTime() - now)} (${next.toHHMM()})`, + self, + ) + } +}) diff --git a/src/modules/survival/sidebar.ts b/src/modules/survival/sidebar.ts index 8f84075f..da5917a8 100644 --- a/src/modules/survival/sidebar.ts +++ b/src/modules/survival/sidebar.ts @@ -1,8 +1,14 @@ import { Player, system, TicksPerSecond, world } from '@minecraft/server' -import { Menu, Region, separateNumberWithDots, Settings, Sidebar } from 'lib' + import { emoji } from 'lib/assets/emoji' import { i18n } from 'lib/i18n/text' +import { Join } from 'lib/player-join' import { Quest } from 'lib/quest/quest' +import { Region } from 'lib/region' +import { Menu } from 'lib/rpg/menu' +import { Settings } from 'lib/settings' +import { Sidebar } from 'lib/sidebar' +import { separateNumberWithDots } from 'lib/util' import { Minigame } from 'modules/minigames/Builder' import { BaseRegion } from 'modules/places/base/region' @@ -137,7 +143,7 @@ export function showSurvivalHud(player: Player) { system.runPlayerInterval( player => { - if (player.database.join) return // Do not show sidebar until player actually joins the world + if (Join.getInstance().isJoining(player)) return // Do not show sidebar until player actually joins the world const settings = getSidebarSettings(player) diff --git a/src/modules/survival/speedrun/target.ts b/src/modules/survival/speedrun/target.ts index 39b6d333..3914b987 100644 --- a/src/modules/survival/speedrun/target.ts +++ b/src/modules/survival/speedrun/target.ts @@ -1,6 +1,8 @@ import { Player } from '@minecraft/server' -import { InventoryInterval, ScoreboardDB } from 'lib' +import { InventoryInterval } from 'lib/action' + import { defaultLang } from 'lib/assets/lang' +import { ScoreboardDB } from 'lib/database/scoreboard' import { form } from 'lib/form/new' import { i18n, i18nShared } from 'lib/i18n/text' import { BaseItem } from 'modules/places/base/base' @@ -64,10 +66,9 @@ declare module '@minecraft/server' { } } -const baseTypeId = BaseItem.itemStack.typeId InventoryInterval.slots.subscribe(({ player, slot }) => { if (!isSpeedRunningFor(player, SpeedRunTarget.GetBaseItem)) return - if (slot.isValid && slot.typeId === baseTypeId && BaseItem.isItem(slot.getItem())) { + if (slot.isValid && BaseItem.isItem(slot.getItem())) { finishSpeedRun(player, SpeedRunTarget.GetBaseItem) } }) diff --git a/src/modules/test/edit-structure.ts b/src/modules/test/edit-structure.ts index 3d3657d0..1b9ff3db 100644 --- a/src/modules/test/edit-structure.ts +++ b/src/modules/test/edit-structure.ts @@ -1,9 +1,11 @@ import { BlockVolume, LocationInUnloadedChunkError, world } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Region, Vec } from 'lib' + import { StructureDungeonsId } from 'lib/assets/structures' import { form } from 'lib/form/new' import { noI18n } from 'lib/i18n/text' +import { Region } from 'lib/region' +import { Vec } from 'lib/vector' const f = form((f, { player }) => { for (const [name, id] of Object.entries(StructureDungeonsId)) { diff --git a/src/modules/test/enchant.ts b/src/modules/test/enchant.ts index fc8c4ae6..3f107c6f 100644 --- a/src/modules/test/enchant.ts +++ b/src/modules/test/enchant.ts @@ -1,6 +1,6 @@ /* i18n-ignore */ -import { world } from '@minecraft/server' import { Enchantments } from 'lib/enchantments' +import { stringify } from 'lib/util' new Command('enchant') .setDescription('Зачаровывает предмет') @@ -37,7 +37,7 @@ new Command('enchant') newitem.lockMode = item.lockMode for (const prop of item.getDynamicPropertyIds()) newitem.setDynamicProperty(prop, item.getDynamicProperty(prop)) - if (newitem.enchantable) world.debug('enchants', [...newitem.enchantable.getEnchantments()]) + if (newitem.enchantable) ctx.player.tell('enchants ' + stringify([...newitem.enchantable.getEnchantments()])) mainhand.setItem(newitem) }) diff --git a/src/modules/test/load-chunks.ts b/src/modules/test/load-chunks.ts index daaac399..2e2f8a70 100644 --- a/src/modules/test/load-chunks.ts +++ b/src/modules/test/load-chunks.ts @@ -1,8 +1,8 @@ import { Block, system } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Vec } from 'lib' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { noI18n } from 'lib/i18n/text' +import { Vec } from 'lib/vector' new Command('chunkload') .setPermissions('curator') diff --git a/src/modules/test/minimap.ts b/src/modules/test/minimap.ts index aa15f17b..a7615966 100644 --- a/src/modules/test/minimap.ts +++ b/src/modules/test/minimap.ts @@ -1,6 +1,6 @@ import { RGBA, system, world } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { removeNamespace } from 'lib' +import { removeNamespace } from 'lib/util' system.afterEvents.scriptEventReceive.subscribe( ({ id }) => { diff --git a/src/modules/test/properties.ts b/src/modules/test/properties.ts index 5d452c60..506cb02f 100644 --- a/src/modules/test/properties.ts +++ b/src/modules/test/properties.ts @@ -1,6 +1,7 @@ import { Player } from '@minecraft/server' -import { ArrayForm } from 'lib' + import { playerJson } from 'lib/assets/player-json' +import { ArrayForm } from 'lib/form/array' new Command('props') .setDescription('Player properties menu') diff --git a/src/modules/test/simulatedPlayer.ts b/src/modules/test/simulatedPlayer.ts index dbf150e5..039df256 100644 --- a/src/modules/test/simulatedPlayer.ts +++ b/src/modules/test/simulatedPlayer.ts @@ -4,7 +4,8 @@ import { GameMode, system, world } from '@minecraft/server' import * as GameTest from '@minecraft/server-gametest' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Vec, util } from 'lib' +import { util } from 'lib/util' +import { Vec } from 'lib/vector' import { TestStructures } from 'test/constants' const time = 9999999 diff --git a/src/modules/test/test.ts b/src/modules/test/test.ts index 8aae5981..e1c4838e 100644 --- a/src/modules/test/test.ts +++ b/src/modules/test/test.ts @@ -1,51 +1,45 @@ /* i18n-ignore */ /* eslint-disable */ -import { ItemStack, MolangVariableMap, Player, ScriptEventSource, system, world } from '@minecraft/server' +import { MolangVariableMap, Player, Potions, ScriptEventSource, system, world } from '@minecraft/server' import { MinecraftBlockTypes, MinecraftCameraPresetsTypes, MinecraftEnchantmentTypes, MinecraftEntityTypes, MinecraftItemTypes, + MinecraftPotionDeliveryTypes, MinecraftPotionEffectTypes, } from '@minecraft/vanilla-data' -import { - Airdrop, - BUTTON, - ChestForm, - DatabaseUtils, - FormNpc, - LootTable, - Mail, - Region, - RoadRegion, - SafeAreaRegion, - Settings, - Vec, - getAuxOrTexture, - getAuxTextureOrPotionAux, - inspect, - is, - isKeyof, - restorePlayerCamera, - util, -} from 'lib' + import { CustomEntityTypes } from 'lib/assets/custom-entity-types' import { CommandContext } from 'lib/command/context' import { parseArguments } from 'lib/command/utils' import { Cutscene } from 'lib/cutscene' +import { DatabaseUtils } from 'lib/database/utils' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { ActionForm } from 'lib/form/action' +import { ChestForm, getAuxOrTexture, getAuxTextureOrPotionAux } from 'lib/form/chest' import { MessageForm } from 'lib/form/message' import { ModalForm } from 'lib/form/modal' import { form } from 'lib/form/new' +import { FormNpc } from 'lib/form/npc' +import { BUTTON } from 'lib/form/utils' import { i18n, noI18n } from 'lib/i18n/text' +import { Mail } from 'lib/mail' +import { Region, RoadRegion, SafeAreaRegion } from 'lib/region' import { MineareaRegion } from 'lib/region/kinds/minearea' +import { is } from 'lib/roles' +import { Airdrop } from 'lib/rpg/airdrop' +import { LootTable } from 'lib/rpg/loot-table' import { Compass } from 'lib/rpg/menu' import { setMinimapNpcPosition } from 'lib/rpg/minimap' +import { Settings } from 'lib/settings' +import { inspect, isKeyof, util } from 'lib/util' +import { restorePlayerCamera } from 'lib/utils/game' import { toPoint } from 'lib/utils/point' import { Rewards } from 'lib/utils/rewards' +import { Vec } from 'lib/vector' import { requestAirdrop } from 'modules/places/anarchy/airdrop' import { BaseRegion } from 'modules/places/base/region' import { skipForBlending } from 'modules/world-edit/utils/blending' @@ -202,7 +196,7 @@ const tests: Record< }, potionAux(ctx) { for (const effect of Object.values(MinecraftPotionEffectTypes)) { - const item = ItemStack.createPotion({ effect }) + const item = Potions.resolve(effect, MinecraftPotionDeliveryTypes.ThrownSplash) getAuxTextureOrPotionAux(item) } }, @@ -403,7 +397,7 @@ const tests: Record< }, dbinspect(ctx) { - world.debug( + console.log( 'test41', { DatabaseUtils }, world.overworld.getEntities({ type: DatabaseUtils.entityTypeId }).map(e => { diff --git a/src/modules/wiki/wiki.ts b/src/modules/wiki/wiki.ts index 1b308f8c..fd66f442 100644 --- a/src/modules/wiki/wiki.ts +++ b/src/modules/wiki/wiki.ts @@ -1,6 +1,7 @@ import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { getAuxOrTexture, langToken } from 'lib' +import { getAuxOrTexture } from 'lib/form/chest' import { form } from 'lib/form/new' +import { langToken } from 'lib/i18n/lang' import { i18n, textTable } from 'lib/i18n/text' import { selectByChance } from 'lib/rpg/random' import { ores } from 'modules/places/mineshaft/algo' diff --git a/src/modules/world-edit/commands/general/id.ts b/src/modules/world-edit/commands/general/id.ts index d0666c37..c64e3009 100644 --- a/src/modules/world-edit/commands/general/id.ts +++ b/src/modules/world-edit/commands/general/id.ts @@ -1,6 +1,7 @@ import {} from '@minecraft/server' import { MinecraftEntityTypes } from '@minecraft/vanilla-data' -import { Vec, inspect } from 'lib' +import { Vec } from 'lib/vector' +import { inspect } from 'lib/util' const root = new Command('id').setDescription('Выдает айди').setPermissions('builder').setGroup('we') diff --git a/src/modules/world-edit/commands/region/set/block-is-avaible.ts b/src/modules/world-edit/commands/region/set/block-is-avaible.ts index 77efc405..2dd2276d 100644 --- a/src/modules/world-edit/commands/region/set/block-is-avaible.ts +++ b/src/modules/world-edit/commands/region/set/block-is-avaible.ts @@ -1,13 +1,14 @@ import { BlockTypes, Player } from '@minecraft/server' import { suggest } from 'lib/command/utils' import { noI18n } from 'lib/i18n/text' +import { onLoad } from 'lib/utils/load-ref' const prefix = 'minecraft:' -const blocks = BlockTypes.getAll().map(e => e.id.substring(prefix.length)) +const blocks = onLoad(() => BlockTypes.getAll().map(e => e.id.substring(prefix.length))) export function blockIsAvaible(block: string, player: Pick): boolean { - if (blocks.includes(block)) return true + if (blocks.value.includes(block)) return true player.tell(noI18n.error`Блока ${block} не существует.`) - suggest(player, block, blocks) + suggest(player, block, blocks.value) return false } diff --git a/src/modules/world-edit/commands/region/set/set-selection.ts b/src/modules/world-edit/commands/region/set/set-selection.ts index a031b3a4..cfc3c274 100644 --- a/src/modules/world-edit/commands/region/set/set-selection.ts +++ b/src/modules/world-edit/commands/region/set/set-selection.ts @@ -1,13 +1,14 @@ import { Player } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { BUTTON } from 'lib' + import { ChestForm } from 'lib/form/chest' import { WeakPlayerMap } from 'lib/weak-player-storage' import { ReplaceMode } from 'modules/world-edit/utils/blocks-set' import { WorldEdit } from '../../../lib/world-edit' import { SelectedBlock, useBlockSelection } from './use-block-selection' import { useReplaceMode } from './use-replace-mode' +import { BUTTON } from 'lib/form/utils' const selection = { block: new WeakPlayerMap(), diff --git a/src/modules/world-edit/commands/region/set/use-block-selection.ts b/src/modules/world-edit/commands/region/set/use-block-selection.ts index 064c5152..157746c7 100644 --- a/src/modules/world-edit/commands/region/set/use-block-selection.ts +++ b/src/modules/world-edit/commands/region/set/use-block-selection.ts @@ -1,7 +1,12 @@ import { BlockPermutation, BlockTypes, Player } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { ActionForm, BUTTON, ChestForm, ModalForm, inspect, translateTypeId } from 'lib' +import { ActionForm } from 'lib/form/action' +import { ChestForm } from 'lib/form/chest' +import { ModalForm } from 'lib/form/modal' +import { BUTTON } from 'lib/form/utils' +import { translateTypeId } from 'lib/i18n/lang' import { i18n } from 'lib/i18n/text' +import { inspect } from 'lib/util' import { WeakPlayerMap } from 'lib/weak-player-storage' import { WEeditBlockStatesMenu } from 'modules/world-edit/menu' import { diff --git a/src/modules/world-edit/commands/region/set/use-replace-mode.ts b/src/modules/world-edit/commands/region/set/use-replace-mode.ts index d2349549..0978a2cd 100644 --- a/src/modules/world-edit/commands/region/set/use-replace-mode.ts +++ b/src/modules/world-edit/commands/region/set/use-replace-mode.ts @@ -1,6 +1,7 @@ import { Player } from '@minecraft/server' -import { BUTTON, settingsModal } from 'lib' +import { BUTTON } from 'lib/form/utils' import { noI18n } from 'lib/i18n/text' +import { settingsModal } from 'lib/settings' import { WeakPlayerMap } from 'lib/weak-player-storage' import { getReplaceMode, ReplaceMode } from 'modules/world-edit/utils/blocks-set' import { REPLACE_MODES } from 'modules/world-edit/utils/default-block-sets' diff --git a/src/modules/world-edit/commands/selection/chunk.ts b/src/modules/world-edit/commands/selection/chunk.ts index e3cd7e32..82d50708 100644 --- a/src/modules/world-edit/commands/selection/chunk.ts +++ b/src/modules/world-edit/commands/selection/chunk.ts @@ -1,5 +1,5 @@ import { Entity, Player } from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { WorldEdit } from '../../lib/world-edit' /** diff --git a/src/modules/world-edit/commands/selection/expand.ts b/src/modules/world-edit/commands/selection/expand.ts index 8ae49a02..17133e31 100644 --- a/src/modules/world-edit/commands/selection/expand.ts +++ b/src/modules/world-edit/commands/selection/expand.ts @@ -1,5 +1,5 @@ import {} from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { WorldEdit } from '../../lib/world-edit' export class SelectionManager { diff --git a/src/modules/world-edit/commands/selection/pos1.ts b/src/modules/world-edit/commands/selection/pos1.ts index 5d1d07cd..1ffc01f9 100644 --- a/src/modules/world-edit/commands/selection/pos1.ts +++ b/src/modules/world-edit/commands/selection/pos1.ts @@ -1,5 +1,5 @@ import {} from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { WorldEdit } from '../../lib/world-edit' new Command('pos1') diff --git a/src/modules/world-edit/commands/selection/pos2.ts b/src/modules/world-edit/commands/selection/pos2.ts index 9f4b23d8..d8ca260d 100644 --- a/src/modules/world-edit/commands/selection/pos2.ts +++ b/src/modules/world-edit/commands/selection/pos2.ts @@ -1,5 +1,5 @@ import {} from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { WorldEdit } from '../../lib/world-edit' new Command('pos2') diff --git a/src/modules/world-edit/commands/selection/size.ts b/src/modules/world-edit/commands/selection/size.ts index 24b1c12b..63d4880f 100644 --- a/src/modules/world-edit/commands/selection/size.ts +++ b/src/modules/world-edit/commands/selection/size.ts @@ -1,7 +1,7 @@ import {} from '@minecraft/server' -import { Vec } from 'lib' import { CommandContext } from 'lib/command/context' import { i18n } from 'lib/i18n/text' +import { Vec } from 'lib/vector' import { WorldEdit } from 'modules/world-edit/lib/world-edit' function getSelection(ctx: CommandContext) { diff --git a/src/modules/world-edit/config.ts b/src/modules/world-edit/config.ts index e1d998f7..c05cb428 100644 --- a/src/modules/world-edit/config.ts +++ b/src/modules/world-edit/config.ts @@ -4,6 +4,7 @@ import { MolangVariableMap, world, } from '@minecraft/server' +import { onLoad } from 'lib/utils/load-ref' import { Vec } from 'lib/vector' export const WE_CONFIG = { @@ -17,15 +18,13 @@ export const WE_CONFIG = { DRAW_SELECTION_PARTICLE: 'minecraft:balloon_gas_particle', DRAW_SELECTION_MAX_SIZE: 5000, - DRAW_SELECTION_PARTICLE_OPTIONS: new MolangVariableMap(), + DRAW_SELECTION_PARTICLE_OPTIONS: onLoad(() => { + const map = new MolangVariableMap() + map.setVector3('direction', { x: 0, y: 0, z: 0 }) + return map + }), } -WE_CONFIG.DRAW_SELECTION_PARTICLE_OPTIONS.setVector3('direction', { - x: 0, - y: 0, - z: 0, -}) - export function spawnParticlesInArea( pos1: Vector3, pos2: Vector3, @@ -44,7 +43,7 @@ export function spawnParticlesInArea( world.overworld.spawnParticle( WE_CONFIG.DRAW_SELECTION_PARTICLE, { x, y, z }, - WE_CONFIG.DRAW_SELECTION_PARTICLE_OPTIONS, + WE_CONFIG.DRAW_SELECTION_PARTICLE_OPTIONS.value, ) } catch (e) { if (e instanceof LocationInUnloadedChunkError || e instanceof LocationOutOfWorldBoundariesError) continue diff --git a/src/modules/world-edit/lib/world-edit-multi-tool.ts b/src/modules/world-edit/lib/world-edit-multi-tool.ts index e0714cb4..4ab2058d 100644 --- a/src/modules/world-edit/lib/world-edit-multi-tool.ts +++ b/src/modules/world-edit/lib/world-edit-multi-tool.ts @@ -1,7 +1,12 @@ import { ContainerSlot, ItemStack, Player } from '@minecraft/server' -import { ArrayForm, ask, BUTTON, doNothing, ModalForm } from 'lib' + import { noI18n } from 'lib/i18n/text' import { WorldEditTool } from './world-edit-tool' +import { doNothing } from 'lib/util' +import { ModalForm } from 'lib/form/modal' +import { ask } from 'lib/form/message' +import { BUTTON } from 'lib/form/utils' +import { ArrayForm } from 'lib/form/array' export interface ToolsDataStorage { /** Version */ diff --git a/src/modules/world-edit/lib/world-edit-tool-brush.ts b/src/modules/world-edit/lib/world-edit-tool-brush.ts index 20032c6f..d0defd09 100644 --- a/src/modules/world-edit/lib/world-edit-tool-brush.ts +++ b/src/modules/world-edit/lib/world-edit-tool-brush.ts @@ -1,6 +1,7 @@ import { BlockRaycastHit, ItemStack, Player } from '@minecraft/server' -import { isLocationError } from 'lib' +import { noI18n } from 'lib/i18n/text' import stringifyError from 'lib/utils/error' +import { isLocationError } from 'lib/utils/game' import { worldEditPlayerSettings } from 'modules/world-edit/settings' import { BlocksSetRef } from '../utils/blocks-set' import { WorldEditTool } from './world-edit-tool' @@ -29,7 +30,7 @@ export abstract class WorldEditToolBrush extends Wor if (!this.isOurBrush(storage)) return const hit = player.getBlockFromViewDirection({ maxDistance: storage.maxDistance }) - const fail = (reason: string) => player.fail(`§7Кисть§f: §c${reason}`) + const fail = (reason: string) => player.fail(noI18n.error`Кисть: ${reason}`) if (!hit) return fail('Блок слишком далеко.') try { diff --git a/src/modules/world-edit/lib/world-edit-tool.ts b/src/modules/world-edit/lib/world-edit-tool.ts index 2822227a..5baad0db 100644 --- a/src/modules/world-edit/lib/world-edit-tool.ts +++ b/src/modules/world-edit/lib/world-edit-tool.ts @@ -8,9 +8,11 @@ import { system, world, } from '@minecraft/server' -import { Command, inspect, isKeyof, noBoolean, stringify, util } from 'lib' + +import { Command } from 'lib/command' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { noI18n, textUnitColorize } from 'lib/i18n/text' +import { inspect, isKeyof, noBoolean, stringify, util } from 'lib/util' import { BlocksSetRef, stringifyBlocksSetRef } from 'modules/world-edit/utils/blocks-set' import { worldEditPlayerSettings } from '../settings' diff --git a/src/modules/world-edit/lib/world-edit.ts b/src/modules/world-edit/lib/world-edit.ts index 35d25f20..94fb0bc8 100644 --- a/src/modules/world-edit/lib/world-edit.ts +++ b/src/modules/world-edit/lib/world-edit.ts @@ -1,5 +1,5 @@ import { BlockPermutation, Player, StructureMirrorAxis, StructureRotation, system, world } from '@minecraft/server' -import { Vec, ask, getRole, isLocationError } from 'lib' + import { Sounds } from 'lib/assets/custom-sounds' import { table } from 'lib/database/abstract' import { i18n } from 'lib/i18n/text' @@ -17,6 +17,10 @@ import { toPermutation, toReplaceTarget, } from '../utils/blocks-set' +import { isLocationError } from 'lib/utils/game' +import { ask } from 'lib/form/message' +import { getRole } from 'lib/roles' +import { Vec } from 'lib/vector' // TODO Add WorldEdit.runMultipleAsyncJobs diff --git a/src/modules/world-edit/menu.ts b/src/modules/world-edit/menu.ts index 19cac62d..07fd00ae 100644 --- a/src/modules/world-edit/menu.ts +++ b/src/modules/world-edit/menu.ts @@ -1,13 +1,19 @@ import { BlockStates, BlockTypes, Player, world } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { ActionForm, BUTTON, FormCallback, ModalForm, Vec, inspect, is, noNullable, stringify } from 'lib' import { Sounds } from 'lib/assets/custom-sounds' +import { ActionForm } from 'lib/form/action' import { ArrayForm } from 'lib/form/array' import { ChestButtonOptions, ChestForm } from 'lib/form/chest' import { ask } from 'lib/form/message' +import { ModalForm } from 'lib/form/modal' +import { BUTTON, FormCallback } from 'lib/form/utils' import { translateTypeId } from 'lib/i18n/lang' import { i18n } from 'lib/i18n/text' +import { is } from 'lib/roles' +import { inspect, noNullable, stringify } from 'lib/util' +import { onLoad } from 'lib/utils/load-ref' +import { Vec } from 'lib/vector' import { WorldEdit } from 'modules/world-edit/lib/world-edit' import { weRandomizerTool } from 'modules/world-edit/tools/randomizer' import { @@ -538,7 +544,7 @@ function WEeditBlocksSetMenu(o: { form.show(player) } -const allStates = BlockStates.getAll() +const allStates = onLoad(() => BlockStates.getAll()) export function WEeditBlockStatesMenu( player: Player, @@ -558,7 +564,7 @@ export function WEeditBlockStatesMenu( // eslint-disable-next-line prefer-const for (let [stateName, stateValue] of Object.entries(states)) { - const stateDef = allStates.find(e => e.id === stateName) + const stateDef = allStates.value.find(e => e.id === stateName) if (!stateDef) continue form.button( diff --git a/src/modules/world-edit/settings.ts b/src/modules/world-edit/settings.ts index dfac7b35..aaec9bd6 100644 --- a/src/modules/world-edit/settings.ts +++ b/src/modules/world-edit/settings.ts @@ -1,4 +1,4 @@ -import { Settings } from 'lib' +import { Settings } from 'lib/settings' export const worldEditPlayerSettings = Settings.player('§6World§dEdit\n§7Настройки строителя мира', 'we', { noBrushParticles: { diff --git a/src/modules/world-edit/tools/brush.ts b/src/modules/world-edit/tools/brush.ts index d4ed4fd1..c8ef869c 100644 --- a/src/modules/world-edit/tools/brush.ts +++ b/src/modules/world-edit/tools/brush.ts @@ -1,8 +1,13 @@ import { ContainerSlot, Entity, Player, system, world } from '@minecraft/server' -import { ModalForm, Vec, is, isKeyof, isLocationError } from 'lib' + import { CustomEntityTypes } from 'lib/assets/custom-entity-types' import { Items } from 'lib/assets/custom-items' +import { ModalForm } from 'lib/form/modal' import { i18n } from 'lib/i18n/text' +import { is } from 'lib/roles' +import { isKeyof } from 'lib/util' +import { isLocationError, onLoad } from 'lib/utils/game' +import { Vec } from 'lib/vector' import { WeakPlayerMap } from 'lib/weak-player-storage' import { Cuboid } from '../../../lib/utils/cuboid' import { WE_CONFIG } from '../config' @@ -165,12 +170,14 @@ class BrushTool extends WorldEditToolBrush { ctx.player.success() }) - world.overworld - .getEntities({ - type: CustomEntityTypes.FloatingText, - name: WE_CONFIG.BRUSH_LOCATOR, - }) - .forEach(e => e.remove()) + onLoad(() => { + world.overworld + .getEntities({ + type: CustomEntityTypes.FloatingText, + name: WE_CONFIG.BRUSH_LOCATOR, + }) + .forEach(e => e.remove()) + }) this.onGlobalInterval('global', (player, _, slot) => { if (slot.typeId !== this.typeId && this.brushLocators.has(player.id)) { diff --git a/src/modules/world-edit/tools/create-region.ts b/src/modules/world-edit/tools/create-region.ts index 391536e8..34bb07a8 100644 --- a/src/modules/world-edit/tools/create-region.ts +++ b/src/modules/world-edit/tools/create-region.ts @@ -1,11 +1,15 @@ import { ContainerSlot, ItemStack, Player } from '@minecraft/server' -import { ModalForm, Region, regionTypes, Vec } from 'lib' + import { Items } from 'lib/assets/custom-items' import { ActionbarPriority } from 'lib/extensions/on-screen-display' import { noI18n } from 'lib/i18n/text' import { SphereArea } from 'lib/region/areas/sphere' import { WeBackup, WorldEdit } from '../lib/world-edit' import { WorldEditTool } from '../lib/world-edit-tool' +import { Vec } from 'lib/vector' +import { Region } from 'lib/region' +import { regionTypes } from 'lib/region' +import { ModalForm } from 'lib/form/modal' interface Storage { version: number diff --git a/src/modules/world-edit/tools/dash.ts b/src/modules/world-edit/tools/dash.ts index e0359056..59f9d599 100644 --- a/src/modules/world-edit/tools/dash.ts +++ b/src/modules/world-edit/tools/dash.ts @@ -1,5 +1,5 @@ import { world } from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { Items } from 'lib/assets/custom-items' world.afterEvents.itemUse.subscribe(({ itemStack, source }) => { diff --git a/src/modules/world-edit/tools/debug-stick.ts b/src/modules/world-edit/tools/debug-stick.ts index 574381a2..64a534b9 100644 --- a/src/modules/world-edit/tools/debug-stick.ts +++ b/src/modules/world-edit/tools/debug-stick.ts @@ -1,9 +1,10 @@ import { Block, BlockStates, ContainerSlot, ItemStack, Player } from '@minecraft/server' import { BlockStateSuperset } from '@minecraft/vanilla-data' -import { ModalForm, Vec } from 'lib' import { Items } from 'lib/assets/custom-items' import { ActionbarPriority } from 'lib/extensions/on-screen-display' +import { ModalForm } from 'lib/form/modal' import { i18n, noI18n } from 'lib/i18n/text' +import { Vec } from 'lib/vector' import { WorldEditTool } from '../lib/world-edit-tool' import { WEeditBlockStatesMenu } from '../menu' diff --git a/src/modules/world-edit/tools/multi-brush.ts b/src/modules/world-edit/tools/multi-brush.ts index 0a82c15b..d547db9f 100644 --- a/src/modules/world-edit/tools/multi-brush.ts +++ b/src/modules/world-edit/tools/multi-brush.ts @@ -1,5 +1,5 @@ import { Direction, ItemStack, Player } from '@minecraft/server' -import { Vec } from 'lib' +import { Vec } from 'lib/vector' import { Items } from 'lib/assets/custom-items' import { ToolsDataStorage, WorldEditMultiTool } from '../lib/world-edit-multi-tool' import { WorldEditTool } from '../lib/world-edit-tool' diff --git a/src/modules/world-edit/tools/randomizer.ts b/src/modules/world-edit/tools/randomizer.ts index ba094e95..0bd59421 100644 --- a/src/modules/world-edit/tools/randomizer.ts +++ b/src/modules/world-edit/tools/randomizer.ts @@ -1,7 +1,7 @@ import { ContainerSlot, ItemStack, Player, world } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { ModalForm } from 'lib' +import { ModalForm } from 'lib/form/modal' import { i18n } from 'lib/i18n/text' import { isNotPlaying } from 'lib/utils/game' import { WorldEditTool } from '../lib/world-edit-tool' diff --git a/src/modules/world-edit/tools/shovel.ts b/src/modules/world-edit/tools/shovel.ts index 7a1ac7f2..83b25a98 100644 --- a/src/modules/world-edit/tools/shovel.ts +++ b/src/modules/world-edit/tools/shovel.ts @@ -1,8 +1,9 @@ import { ContainerSlot, ItemStack, Player, world } from '@minecraft/server' -import { ModalForm, Vec } from 'lib' import { Items } from 'lib/assets/custom-items' import { ActionbarPriority } from 'lib/extensions/on-screen-display' +import { ModalForm } from 'lib/form/modal' import { i18n } from 'lib/i18n/text' +import { Vec } from 'lib/vector' import { WorldEdit } from 'modules/world-edit/lib/world-edit' import { WorldEditTool } from '../lib/world-edit-tool' import { skipForBlending } from '../utils/blending' diff --git a/src/modules/world-edit/tools/smooth.ts b/src/modules/world-edit/tools/smooth.ts index 893390e5..3e3c674a 100644 --- a/src/modules/world-edit/tools/smooth.ts +++ b/src/modules/world-edit/tools/smooth.ts @@ -1,8 +1,11 @@ import { Block, BlockPermutation, ContainerSlot, Player, system, world } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { is, ModalForm, util, Vec } from 'lib' import { Items } from 'lib/assets/custom-items' +import { ModalForm } from 'lib/form/modal' +import { is } from 'lib/roles' +import { util } from 'lib/util' +import { Vec } from 'lib/vector' import { WorldEdit } from 'modules/world-edit/lib/world-edit' import { BlocksSetRef, diff --git a/src/modules/world-edit/tools/tool.ts b/src/modules/world-edit/tools/tool.ts index 5fa63539..fd89fed9 100644 --- a/src/modules/world-edit/tools/tool.ts +++ b/src/modules/world-edit/tools/tool.ts @@ -1,8 +1,12 @@ import { ContainerSlot, MolangVariableMap, Player, system, world } from '@minecraft/server' -import { ActionForm, ModalForm, Vec, inspect } from 'lib' import { Items } from 'lib/assets/custom-items' import { ListParticles } from 'lib/assets/particles' import { ListSounds } from 'lib/assets/sounds' +import { ActionForm } from 'lib/form/action' +import { ModalForm } from 'lib/form/modal' +import { inspect } from 'lib/utils/inspect' +import { onLoad } from 'lib/utils/load-ref' +import { Vec } from 'lib/vector' import { WorldEditTool } from '../lib/world-edit-tool' const actions: Record = { @@ -110,7 +114,7 @@ class Tool extends WorldEditTool { constructor() { super() - const variables = new MolangVariableMap() + const variables = onLoad(() => new MolangVariableMap()) system.runInterval( () => { @@ -134,7 +138,7 @@ class Tool extends WorldEditTool { hit.block.dimension.spawnParticle( lore[1], Vec.add(hit.block.location, { x: 0.5, z: 0.5, y: 1.5 }), - variables, + variables.value, ) } diff --git a/src/modules/world-edit/utils/blocks-set.ts b/src/modules/world-edit/utils/blocks-set.ts index 7fe4f884..fce6d99f 100644 --- a/src/modules/world-edit/utils/blocks-set.ts +++ b/src/modules/world-edit/utils/blocks-set.ts @@ -1,10 +1,11 @@ import { Block, BlockPermutation, Player } from '@minecraft/server' import { BlockStateSuperset } from '@minecraft/vanilla-data' -import { noNullable, translateTypeId } from 'lib' import { table } from 'lib/database/abstract' import { DEFAULT_BLOCK_SETS, DEFAULT_REPLACE_TARGET_SETS, REPLACE_MODES } from './default-block-sets' import { Language } from 'lib/assets/lang' +import { noNullable } from 'lib/util' +import { translateTypeId } from 'lib/i18n/lang' export type BlockStateWeight = [...Parameters, number] @@ -92,7 +93,7 @@ export function getBlocksInSet([playerId, blocksSetName]: BlocksSetRef) { } export function getReplaceTargets(ref: BlocksSetRef): ReplaceTarget[] { - const defaultReplaceTarget = DEFAULT_REPLACE_TARGET_SETS[ref[1]] + const defaultReplaceTarget = DEFAULT_REPLACE_TARGET_SETS.value[ref[1]] if (defaultReplaceTarget) return defaultReplaceTarget return getActiveBlocksInSet(ref)?.map(fromBlockStateWeightToReplaceTarget) ?? [] diff --git a/src/modules/world-edit/utils/default-block-sets.ts b/src/modules/world-edit/utils/default-block-sets.ts index faa69353..dc7ae8b6 100644 --- a/src/modules/world-edit/utils/default-block-sets.ts +++ b/src/modules/world-edit/utils/default-block-sets.ts @@ -1,6 +1,7 @@ import { BlockPermutation, BlockTypes, LiquidType } from '@minecraft/server' import { BlockStateSuperset, MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { noNullable } from 'lib' +import { noNullable } from 'lib/util' +import { onLoad } from 'lib/utils/load-ref' import { BlockStateWeight, BlocksSets, @@ -9,10 +10,15 @@ import { fromBlockStateWeightToReplaceTarget, } from './blocks-set' -const trees: BlockStateWeight[] = BlockTypes.getAll() - .filter(e => e.id.endsWith('_log') || e.id.includes('leaves')) - .map(e => [e.id, void 0, 1]) -trees.push([MinecraftBlockTypes.MangroveRoots, void 0, 1]) +const trees = onLoad(() => { + const trees: BlockStateWeight[] = BlockTypes.getAll() + .filter(e => e.id.endsWith('_log') || e.id.includes('leaves')) + .map(e => [e.id, void 0, 1]) + + trees.push([MinecraftBlockTypes.MangroveRoots, void 0, 1]) + + return trees +}) export const DEFAULT_BLOCK_SETS: BlocksSets = { Земля: [[MinecraftBlockTypes.GrassBlock, void 0, 1]], @@ -48,11 +54,14 @@ function isGlassPane(typeId: string) { } const allBlockTypes = [isSlab, isStairs, isWall, isTrapdoor, isGlass, isGlassPane] -const air = BlockPermutation.resolve(MinecraftBlockTypes.Air) +const air = onLoad(() => BlockPermutation.resolve(MinecraftBlockTypes.Air)) -export const DEFAULT_REPLACE_TARGET_SETS: Record = { - 'Любое дерево': trees.map(fromBlockStateWeightToReplaceTarget).filter(noNullable), -} +export const DEFAULT_REPLACE_TARGET_SETS = onLoad( + () => + ({ + 'Любое дерево': trees.value.map(fromBlockStateWeightToReplaceTarget).filter(noNullable), + }) as Record, +) export const REPLACE_MODES: Record = { 'Не воздух': { @@ -77,7 +86,7 @@ export const REPLACE_MODES: Record = { 'Замена соответств. блока': { matches: () => true, select(block, permutations) { - if (block.isAir) return air + if (block.isAir) return air.value let permutation: BlockPermutation | undefined const { typeId } = block @@ -125,7 +134,7 @@ export function shortenBlocksSetName(name: string | undefined | null) { } addPostfix(DEFAULT_BLOCK_SETS) -addPostfix(DEFAULT_REPLACE_TARGET_SETS) +DEFAULT_REPLACE_TARGET_SETS.onLoad(v => addPostfix(v)) function addPostfix(blocksSet: Record) { Object.keys(blocksSet).forEach(e => { diff --git a/src/modules/world-edit/utils/shapes.ts b/src/modules/world-edit/utils/shapes.ts index 21eff4f8..463376d5 100644 --- a/src/modules/world-edit/utils/shapes.ts +++ b/src/modules/world-edit/utils/shapes.ts @@ -22,7 +22,8 @@ export const SHAPES = { 'customMountain': ({ x, y, z }) => y <= 0.5 * Math.sin(x / 10) + 0.5 * Math.cos(z / 10), 'tetrahedron': ({ x, y, z, yMin, zMin }) => ( - Math.abs(-x) + Math.abs(x) + Math.abs(y) + Math.abs(z) - yMin, zMin === 0 + Math.abs(-x) + Math.abs(x) + Math.abs(y) + Math.abs(z) - yMin, + zMin === 0 ), 'triangle_prism': ({ x, y, z, yMin }) => diff --git a/src/test/__mocks__/minecraft_server.ts b/src/test/__mocks__/minecraft_server.ts index 88465075..4c474798 100644 --- a/src/test/__mocks__/minecraft_server.ts +++ b/src/test/__mocks__/minecraft_server.ts @@ -433,6 +433,66 @@ export enum EntityComponentTypes { WantsJockey = 'minecraft:wants_jockey', } +/** The types of paramaters accepted by a custom command. */ +export enum CustomCommandParamType { + /** + * @remarks + * Block type parameter provides a {@link BlockType}. + */ + BlockType = 'BlockType', + /** + * @remarks + * Boolean parameter. + */ + Boolean = 'Boolean', + /** + * @remarks + * Entity selector parameter provides an {@link Entity}. + */ + EntitySelector = 'EntitySelector', + /** + * @remarks + * Entity type parameter provides an {@link EntityType}. + */ + EntityType = 'EntityType', + /** + * @remarks + * Command enum parameter. + */ + Enum = 'Enum', + /** + * @remarks + * Float parameter. + */ + Float = 'Float', + /** + * @remarks + * Integer parameter. + */ + Integer = 'Integer', + /** + * @remarks + * Item type parameter provides an {@link ItemType}. + */ + ItemType = 'ItemType', + /** + * @remarks + * Location parameter provides a {@link + * @minecraft/server.Location}. + */ + Location = 'Location', + /** + * @remarks + * Player selector parameter provides a {@link Player}. + */ + PlayerSelector = 'PlayerSelector', + /** + * @remarks + * String parameter. + */ + String = 'String', +} + export class EntityEquippableComponent extends EntityComponent { static readonly componentId = 'minecraft:equippable' readonly typeId = 'minecraft:equippable' diff --git a/src/test/utils.ts b/src/test/utils.ts index a8c15f11..ca9a52f4 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Player, system } from '@minecraft/server' -import { EventSignal } from 'lib' +import { EventSignal } from 'lib/event-signal' import type { Table } from 'lib/database/abstract' import type { TestFormCallback, TFD } from 'test/__mocks__/minecraft_server-ui' diff --git a/src/test/vitest.d.ts b/src/test/vitest.d.ts index ed4d6a26..e50ef01a 100644 --- a/src/test/vitest.d.ts +++ b/src/test/vitest.d.ts @@ -6,7 +6,12 @@ declare global { const afterAll: (typeof import('@vitest/runner'))['afterAll'] const afterEach: (typeof import('@vitest/runner'))['afterEach'] - const expect: import('@vitest/expect').ExpectStatic + const expect: (( + actual: T, + message?: string, + ) => import('@vitest/expect').Assertion & { not: import('@vitest/expect').Assertion }) & + import('@vitest/expect').ExpectStatic + const expectTypeOf: typeof import('expect-type').expectTypeOf const vi: typeof import('@vitest/spy') & { diff --git a/yarn.lock b/yarn.lock index 63b0f361..46047076 100644 --- a/yarn.lock +++ b/yarn.lock @@ -739,66 +739,51 @@ __metadata: languageName: node linkType: hard -"@minecraft/common@npm:^1.0.0, @minecraft/common@npm:^1.1.0": - version: 1.2.0 - resolution: "@minecraft/common@npm:1.2.0" - checksum: 10c0/597c3ff8ab275ba5d5fb3037e68970e59ac96e8793b7738178c95b17d22a8f48a4412425314ed494f664466dd9a342e2a9eff52af544e57689f103c2ae965e10 - languageName: node - linkType: hard - -"@minecraft/server-admin@npm:^1.0.0-beta.1.21.90-stable": - version: 1.0.0-beta.release.1.19.50 - resolution: "@minecraft/server-admin@npm:1.0.0-beta.release.1.19.50" - checksum: 10c0/81e3b467411c086e21ed29bbc7b73c03db15dd7072aaa344fa4f936a6f1fc7cbce1dcadc68dea8df4e48028367f5d885b3d2ec3904ecab397be404c5ff9ecc51 - languageName: node - linkType: hard - -"@minecraft/server-gametest@npm:1.0.0-beta.1.21.90-stable": - version: 1.0.0-beta.1.21.90-stable - resolution: "@minecraft/server-gametest@npm:1.0.0-beta.1.21.90-stable" - dependencies: - "@minecraft/common": "npm:^1.0.0" - "@minecraft/server": "npm:^1.17.0 || ^2.0.0" - checksum: 10c0/bb72ae9c0f3a90758999c778e83b9fe8af3e87de9cf1a076e273baec11d378ec12672f276cc3b880364578ac344b925fc93fcbcfa361c508295a4a3162cc23dd +"@minecraft/server-gametest@npm:1.0.0-beta.1.21.120-stable": + version: 1.0.0-beta.1.21.120-stable + resolution: "@minecraft/server-gametest@npm:1.0.0-beta.1.21.120-stable" + peerDependencies: + "@minecraft/common": ^1.0.0 + "@minecraft/server": ^1.17.0 || ^2.0.0 || ^2.4.0-beta.1.21.120-stable + checksum: 10c0/00d53fc710025611e772d4d0da98118026d77d07fb9f3752e73a1c517937be909d1e6acefc538ac439508ed896dec0bd4053e502ac29f317154fec7f42a7e975 languageName: node linkType: hard -"@minecraft/server-net@npm:1.0.0-beta.1.21.90-stable": - version: 1.0.0-beta.1.21.90-stable - resolution: "@minecraft/server-net@npm:1.0.0-beta.1.21.90-stable" - dependencies: - "@minecraft/common": "npm:^1.0.0" - "@minecraft/server": "npm:^1.17.0 || ^2.0.0" - "@minecraft/server-admin": "npm:^1.0.0-beta.1.21.90-stable" - checksum: 10c0/9938b03b813623239742da6c6c1ea89590b59be438432692eb09168f4aa8a361b5939cf9ceb178eb76f70d39ca8ed495113ab42aed1797a348e3c3ffc3790e13 +"@minecraft/server-net@npm:1.0.0-beta.1.21.120-stable": + version: 1.0.0-beta.1.21.120-stable + resolution: "@minecraft/server-net@npm:1.0.0-beta.1.21.120-stable" + peerDependencies: + "@minecraft/common": ^1.0.0 + "@minecraft/server": ^1.17.0 || ^2.0.0 + "@minecraft/server-admin": ^1.0.0-beta.1.21.120-stable + checksum: 10c0/a6eb346c2fbb3d4e1ea0814cc08e71d0c922290b74008f73692cd4f980ac482a6bf49037bf4d2704a5bda12b97050117177222341a049e3be8c531595e5a11b2 languageName: node linkType: hard -"@minecraft/server-ui@npm:2.1.0-beta.1.21.90-stable": - version: 2.1.0-beta.1.21.90-stable - resolution: "@minecraft/server-ui@npm:2.1.0-beta.1.21.90-stable" - dependencies: - "@minecraft/common": "npm:^1.0.0" - "@minecraft/server": "npm:^2.0.0" - checksum: 10c0/2e0c761cc596c355757e65e763c89bb851abd6c60e29db688ae7d15cd7fea93af5c5938d1596d48371dcbd0c5391d53d4f2623e71722da13af370c865beb2d6e +"@minecraft/server-ui@npm:2.1.0-beta.1.21.120-stable": + version: 2.1.0-beta.1.21.120-stable + resolution: "@minecraft/server-ui@npm:2.1.0-beta.1.21.120-stable" + peerDependencies: + "@minecraft/common": ^1.0.0 + "@minecraft/server": ^2.0.0 || ^2.4.0-beta.1.21.120-stable + checksum: 10c0/b2ddea1ec213cd31b14c21b0815903ea397616e145808807f644c33fb897230ce31a1275c89a1fcb8d928decda00ca9a00d133185b1422233f83154b5fac4fb3 languageName: node linkType: hard -"@minecraft/server@npm:2.1.0-beta.1.21.90-stable": - version: 2.1.0-beta.1.21.90-stable - resolution: "@minecraft/server@npm:2.1.0-beta.1.21.90-stable" - dependencies: - "@minecraft/common": "npm:^1.1.0" +"@minecraft/server@npm:2.4.0-beta.1.21.120-stable": + version: 2.4.0-beta.1.21.120-stable + resolution: "@minecraft/server@npm:2.4.0-beta.1.21.120-stable" peerDependencies: + "@minecraft/common": ^1.2.0 "@minecraft/vanilla-data": ">=1.20.70" - checksum: 10c0/54cfa429982248721ef087d644264130a137a2233d76ec25796074a6890ea9e52c4e7d4f884425c6d6efb32dcdde03f223ccce16d73c2e0097003c10879e1c00 + checksum: 10c0/ab999a09952c852eefc31467aaee7c129ef207c1e3971e901fed34c4d56d3827a713fa2872083f66a97a137e2e5fea8bbaed3732c0ee771366878a7bfb9836e9 languageName: node linkType: hard -"@minecraft/vanilla-data@npm:1.21.90": - version: 1.21.90 - resolution: "@minecraft/vanilla-data@npm:1.21.90" - checksum: 10c0/5f84fd20917c294c11d00a9de21ae740768ffb39c9dd9bb5e20378e7500760acc6ca896afc03176a1666a3a2d828a9344305e0b3482734d95b8671aaa01123e7 +"@minecraft/vanilla-data@npm:1.21.120": + version: 1.21.120 + resolution: "@minecraft/vanilla-data@npm:1.21.120" + checksum: 10c0/460eb19de9c7ed01fb8b8e3829e0055dbf0c9c2c1050ceb524ae8b2e165bac9c02fecb7f0d75379467256df65006a0601defc49bc6c1efe4312d1898215649cb languageName: node linkType: hard @@ -3703,11 +3688,11 @@ __metadata: "@formatjs/intl-locale": "npm:^4.2.11" "@formatjs/intl-numberformat": "npm:^8.15.4" "@formatjs/intl-pluralrules": "npm:^5.4.4" - "@minecraft/server": "npm:2.1.0-beta.1.21.90-stable" - "@minecraft/server-gametest": "npm:1.0.0-beta.1.21.90-stable" - "@minecraft/server-net": "npm:1.0.0-beta.1.21.90-stable" - "@minecraft/server-ui": "npm:2.1.0-beta.1.21.90-stable" - "@minecraft/vanilla-data": "npm:1.21.90" + "@minecraft/server": "npm:2.4.0-beta.1.21.120-stable" + "@minecraft/server-gametest": "npm:1.0.0-beta.1.21.120-stable" + "@minecraft/server-net": "npm:1.0.0-beta.1.21.120-stable" + "@minecraft/server-ui": "npm:2.1.0-beta.1.21.120-stable" + "@minecraft/vanilla-data": "npm:1.21.120" "@vitest/coverage-istanbul": "npm:3.2.4" "@vitest/ui": "npm:3.2.4" async-mutex: "npm:^0.5.0"