diff --git a/src/lib/cooldown.ts b/src/lib/cooldown.ts index 494641f5..7042087a 100644 --- a/src/lib/cooldown.ts +++ b/src/lib/cooldown.ts @@ -31,6 +31,11 @@ export class Cooldown { return 0 } + getRemainingTime(player: Player | string) { + const id = player instanceof Player ? player.id : player + return this.time - this.getElapsed(id) + } + /** * Checks if cooldown for player is expired and returns true, otherwise tells player about it if {@link this.tell} is * true and returns false diff --git a/src/lib/extensions/system.ts b/src/lib/extensions/system.ts index 4b24dc54..7a3a8c92 100644 --- a/src/lib/extensions/system.ts +++ b/src/lib/extensions/system.ts @@ -41,7 +41,7 @@ declare module '@minecraft/server' { * @param callback Code to run * @param tickInterval Time in ticks between each run. Its not guaranted that it will be consistent */ - runJobInterval(callback: () => void | Generator, tickInterval: number): () => void + runJobInterval(callback: () => Generator, tickInterval: number): () => void } } @@ -88,10 +88,10 @@ expand(System.prototype, { function jobInterval() { system.runJob( (function* job() { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - for (const _ of callback() ?? []) yield + for (const _ of callback()) yield if (stopped) return - system.runTimeout(jobInterval, 'jobInterval', tickInterval) + if (tickInterval === 0) system.delay(jobInterval) + else system.runTimeout(jobInterval, 'jobInterval', tickInterval) })(), ) } diff --git a/src/lib/game-utils.ts b/src/lib/game-utils.ts index b3d5fb8b..d08ec6f5 100644 --- a/src/lib/game-utils.ts +++ b/src/lib/game-utils.ts @@ -116,31 +116,29 @@ export function nmspc(text: string) { * for preventing overload */ export const loadChunk = dedupe(async function loadChunk(location: Vector3) { - // console.debug('Load chunk entering', Vector.string(location, true)) await world.overworld.runCommandAsync(`tickingarea remove ldchnk`) - await world.overworld.runCommandAsync(`tickingarea add ${Vector.string(location)} ${Vector.string(location)} ldchnk `) + await world.overworld.runCommandAsync(`tickingarea add ${Vector.string(location)} ${Vector.string(location)} ldchnk`) let i = 100 return new Promise(resolve => { function done(result: false | Block) { - stopInterval() + system.clearRun(interval) world.overworld.runCommand(`tickingarea remove ldchnk`) - // console.debug('Load chunk exit.', !!result) resolve(result) } - const stopInterval = system.runJobInterval(() => { - if (i < 0) return done(false) + const interval = system.runInterval( + () => { + if (i < 0) return done(false) - const status = getBlockStatus({ location, dimensionId: 'overworld' }) - if (status === 'unloaded') { - i-- - // console.debug('Chunk is unloaded') - return - } + const status = getBlockStatus({ location, dimensionId: 'overworld' }) + if (status === 'unloaded') return i-- - return done(status) - }, 5) + return done(status) + }, + 'loadChunk', + 5, + ) }) }) diff --git a/src/lib/region/kinds/region.ts b/src/lib/region/kinds/region.ts index 2987b74e..a567a339 100644 --- a/src/lib/region/kinds/region.ts +++ b/src/lib/region/kinds/region.ts @@ -70,7 +70,7 @@ export class Region { region.permissions = ProxyDatabase.setDefaults(options.permissions ?? {}, region.defaultPermissions) region.kind = this.kind region.creator = this - if (options.ldb) region.linkedDatabase = options.ldb + if (options.ldb) region.ldb = options.ldb if (region.structure) region.structure.validateArea() if (!key) { @@ -133,7 +133,7 @@ export class Region { protected readonly defaultPermissions: RegionPermissions = defaultRegionPermissions() /** Database linked to the region */ - linkedDatabase!: JsonObject | undefined + ldb!: JsonObject | undefined /** Region kind */ private kind!: string @@ -230,7 +230,7 @@ export class Region { k: this.kind, permissions: ProxyDatabase.removeDefaults(this.permissions, this.defaultPermissions), dimensionId: this.dimensionType, - ldb: this.linkedDatabase, + ldb: this.ldb, } } diff --git a/src/lib/region/structure.ts b/src/lib/region/structure.ts index b9e3ce2c..1a3a43f8 100644 --- a/src/lib/region/structure.ts +++ b/src/lib/region/structure.ts @@ -7,6 +7,9 @@ export class RegionStructure { protected id = `region:${this.region.id.replaceAll(':', '|')}` + /** Used when structure was saved with bigger area radius, for example in BaseRegion */ + offset = 0 + save(): void | Promise { this.validateArea() world.structureManager.createFromWorld(this.id, this.region.dimension, ...this.region.area.edges, { @@ -45,16 +48,20 @@ export class RegionStructure { } forEachBlock( - callback: (vector: Vector3, structureSavedBlock: BlockPermutation | undefined, dimension: Dimension) => void, + callback: (location: Vector3, structureSavedBlock: BlockPermutation | undefined, dimension: Dimension) => void, ) { const structure = world.structureManager.get(this.id) if (!structure) throw new ReferenceError('No structure found!') const [, edge] = this.region.area.edges + const offset = this.offset ? { x: this.offset, y: this.offset, z: this.offset } : undefined return this.region.area.forEachVector((vector, isIn, dimension) => { if (isIn) { - const structureSavedBlock = structure.getBlockPermutation(Vector.multiply(Vector.subtract(edge, vector), -1)) + const structureLocation = Vector.multiply(Vector.subtract(edge, vector), -1) + const structureSavedBlock = structure.getBlockPermutation( + offset ? Vector.add(structureLocation, offset) : structureLocation, + ) callback(vector, structureSavedBlock, dimension) } }) diff --git a/src/lib/text.ts b/src/lib/text.ts index 282bc041..2d9232dc 100644 --- a/src/lib/text.ts +++ b/src/lib/text.ts @@ -105,7 +105,7 @@ function createSingle( function createBadge(options: ColorizingOptions): (text: TSA, n: number) => Text { return createSingle(options, (text, unit) => { if (typeof unit !== 'number') return text + textUnitColorize(unit, options) - if (unit > 0) return `${text}§8(§c${unit}§8)` + if (unit > 0) return `${text}§7(${options.num ?? '§c'}${unit}§7)` return text.trimEnd() }) } diff --git a/src/lib/util.ts b/src/lib/util.ts index 58b6db19..08012340 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -121,7 +121,7 @@ export function wrap(string: string, maxLength: number) { return lines } -export function wrapLore(lore: string,) { +export function wrapLore(lore: string) { let color = '§7' return wrap(lore, 30).map(e => { // Get latest color from the string @@ -210,6 +210,17 @@ export function hexToRgb(hex: `#${string}`): RGB { return { red, green, blue } } +/** Returns the size of an object or array by checking its length or number of keys. */ +export function sizeOf(target: object | unknown[]): number { + if (Array.isArray(target)) return target.length + return Object.keys(target).length +} + +/** Checks whenether provided object or array has 0 keys */ +export function isEmpty(target: object | unknown[]): boolean { + return sizeOf(target) === 0 +} + /** Empty function that does nothing. */ // eslint-disable-next-line @typescript-eslint/no-empty-function export const doNothing = () => {} diff --git a/src/modules/places/base/actions/create.ts b/src/modules/places/base/actions/create.ts index 5f9b3585..9b64d454 100644 --- a/src/modules/places/base/actions/create.ts +++ b/src/modules/places/base/actions/create.ts @@ -15,6 +15,7 @@ actionGuard((_, __, ctx) => { ) return true }, ActionGuardOrder.Feature) + world.beforeEvents.playerPlaceBlock.subscribe(event => { const { player, block } = event const mainhand = player.mainhand() diff --git a/src/modules/places/base/actions/rotting.ts b/src/modules/places/base/actions/rotting.ts index fb254463..2ecd09cd 100644 --- a/src/modules/places/base/actions/rotting.ts +++ b/src/modules/places/base/actions/rotting.ts @@ -1,43 +1,79 @@ -import { Block, BlockPermutation, Player, system } from '@minecraft/server' +import { Block, BlockPermutation, ContainerSlot, Player, RawText, system } from '@minecraft/server' import { MinecraftBlockTypes } from '@minecraft/vanilla-data' -import { Cooldown, getBlockStatus, isLocationError, Mail, ms, Vector } from 'lib' +import { ActionForm, Cooldown, getBlockStatus, isEmpty, isLocationError, Mail, ms, Vector } from 'lib' import { table } from 'lib/database/abstract' import { playerPositionCache } from 'lib/player-move' +import { itemDescription } from 'lib/shop/rewards' import { t } from 'lib/text' +import { onFullRegionTypeRestore } from 'modules/places/minearea/minearea-region' +import { scheduleBlockPlace, SCHEDULED_DB, unscheduleBlockPlace } from 'modules/survival/scheduled-block-place' import { spawnParticlesInArea } from 'modules/world-edit/config' import { BaseRegion } from '../region' +const takeMaterialsTime = __DEV__ ? ms.from('min', 5) : ms.from('day', 1) +const blocksReviseTime = __DEV__ ? ms.from('sec', 3) : ms.from('min', 2) +const materialsReviseTime = __DEV__ ? ms.from('sec', 1) : ms.from('min', 1) + const cooldowns = table>('baseCoooldowns', () => ({})) -const reviseMaterialsCooldown = new Cooldown(ms.from('min', 2), false, cooldowns.revise) -const takeMaterialsCooldown = new Cooldown(ms.from('day', 1), false, cooldowns.takeMaterials) -const rotCooldown = new Cooldown(ms.from('min', 30), false, cooldowns.rot) +const blocksToMaterialsCooldown = new Cooldown(blocksReviseTime, false, cooldowns.blocksToMaterials) +const reviseMaterialsCooldown = new Cooldown(materialsReviseTime, false, cooldowns.revise) +const takeMaterialsCooldown = new Cooldown(takeMaterialsTime, false, cooldowns.takeMaterials) system.runInterval( () => { for (const base of BaseRegion.instances()) { const block = getBlockStatus({ location: base.area.center, dimensionId: base.dimensionType }) - const isLoaded = isNearPlayers(base) + const isLoaded = anyPlayerNear(base) if (block === 'unloaded' || !isLoaded) continue if (block.typeId === MinecraftBlockTypes.Barrel) { spawnParticlesInArea(base.area.center, Vector.add(base.area.center, Vector.one)) - if (reviseMaterialsCooldown.isExpired(base.id)) reviseMaterials(base) + if (blocksToMaterialsCooldown.isExpired(base.id)) blocksToMaterials(base) + if (reviseMaterialsCooldown.isExpired(base.id)) reviseMaterials(base, block) if (takeMaterialsCooldown.isExpired(base.id)) takeMaterials(base, block) } else startRotting(base) - - if (base.linkedDatabase.isRotting && rotCooldown.isExpired(base.id)) rot(base) } }, 'baseInterval', 10, ) -function startRotting(base: BaseRegion) { - if (base.linkedDatabase.isRotting) return +function baseRottingMenu(base: BaseRegion, player: Player, back?: VoidFunction) { + const selfback = () => baseRottingMenu(base, player, back) + const barrel = base.dimension.getBlock(base.area.center) + const materials: RawText = { + rawtext: Object.entries(base.ldb.materials).map(([typeId, amount]) => ({ + rawtext: [itemDescription({ typeId, amount }, '§f§l'), { text: '\n' }], + })), + } + + const form = new ActionForm( + 'Гниение базы', + t.raw`Чтобы база не гнила, в бочке ежедневно должны быть следующие ресурсы:\n\n${materials}\n\nДо следующей проверки: ${t.time(takeMaterialsCooldown.getRemainingTime(base.id))}`, + ).addButtonBack(back) + + if (barrel) form.addButton('Проверить блоки в бочке', () => (reviseMaterials(base, barrel), selfback())) + + form.show(player) +} + +export function baseRottingButton(base: BaseRegion, player: Player, back?: VoidFunction) { + let text = '' + if (base.ldb.isRotting) { + text = '§cБаза гниет!\n§4Срочно пополните материалы!' + } else { + text = `Поддержание базы` + } + + return [text, baseRottingMenu.bind(null, base, player, back)] as const +} + +async function startRotting(base: BaseRegion) { + if (base.ldb.isRotting) return - base.linkedDatabase.isRotting = true + base.ldb.isRotting = true base.save() const message = t.error`База с владельцем ${base.ownerName} разрушена.` @@ -52,84 +88,187 @@ function startRotting(base: BaseRegion) { ) } }) + + const { radius, center } = base.area + await forEachChangedBlock(base, (_, savedPermutation, location) => { + if (!savedPermutation || Vector.equals(base.area.center, location)) return + + // radius = 10 + // distance | restore time + // 1 (chest) 9 hours + // 6 (stone) 4 hours + // This means that the far block is from + // center the faster it will rot + const distance = Vector.distance(location, center) + const restoreTime = ms.from(__DEV__ ? 'min' : 'hour', radius - distance) + + scheduleBlockPlace({ + dimension: base.dimensionType, + location: location, + restoreTime: restoreTime, + states: savedPermutation.getAllStates(), + typeId: savedPermutation.type.id, + }) + }) +} + +function stopRotting(base: BaseRegion) { + if (!base.ldb.isRotting) return + + base.ldb.isRotting = false + base.save() + + const { dimensionType } = base + + base.area.forEachVector(vector => { + const schedule = SCHEDULED_DB[dimensionType].find(e => Vector.equals(e.location, vector)) + if (schedule) unscheduleBlockPlace(schedule) + }) } let revising = false -async function reviseMaterials(base: BaseRegion) { - if (revising) return +async function blocksToMaterials(base: BaseRegion) { + if (revising) return false try { revising = true - const materials = countMaterials() + const materials = createMaterialsCounter() await forEachChangedBlock(base, block => { if (!block || block.isAir) return - materials.add(block.typeId) + const typeId = block.getItemStack(1, true)?.typeId + if (typeId) materials.add(typeId) }) - console.log('Base materials:', materials.result()) + base.ldb.materials = materials.result() + return true } catch (e) { - if (isLocationError(e)) return - console.error('Unable to base revise materials:', e) + if (!isLocationError(e)) console.error('Unable to revise base materials:', e) + return false } finally { revising = false } } -function countMaterials() { - const materials = new Map() - return { - add(typeId: string) { - const material = materials.get(typeId) ?? 0 - materials.set(typeId, material + 1) - }, - result() { - return Object.fromEntries(materials) - }, - } -} - -function getMaterials(base: BaseRegion, barrel: Block) { +function countMaterialsInBarrel(base: BaseRegion, barrel: Block) { const container = barrel.getComponent('inventory')?.container if (!container) return - const materialsCount = countMaterials() - const slots = new Map() + const materialsCount = createMaterialsCounter() + const typeIdsToSlots = new Map() for (const [, slot] of container.slotEntries()) { const item = slot.getItem() if (!item) continue const { typeId } = item - const { materials, materialsMissing } = base.linkedDatabase + const { materials, materialsMissing } = base.ldb if (!(typeId in materials || typeId in materialsMissing)) continue - materialsCount.add(typeId) + const slots = typeIdsToSlots.get(typeId) ?? [] + typeIdsToSlots.set(typeId, slots.concat(slot)) + materialsCount.add(typeId, slot.amount) } - return { container, result: materialsCount.result() } + + // Save to cache in case block will be unloaded + base.ldb.barrel = materialsCount.result() + + return typeIdsToSlots } -function takeMaterials(base: BaseRegion, barrel: Block) { - if (Object.keys(base.linkedDatabase.materialsMissing).length) return startRotting(base) +function reviseMaterials(base: BaseRegion, barrel: Block) { + const barrelSlots = countMaterialsInBarrel(base, barrel) + if (!barrelSlots) return + + const missing = createMaterialsCounter(base.ldb.materials) + for (const [typeId, amount] of Object.entries(base.ldb.barrel)) { + missing.remove(typeId, amount) + } + + base.ldb.materialsMissing = missing.result() + console.log('reviseMaterials: Missing', base.ldb.materialsMissing) - const materials = getMaterials(base, barrel) - if (!materials) return + return barrelSlots } -export function rot(base: BaseRegion) { - // +function takeMaterials(base: BaseRegion, barrel: Block) { + const barrelSlots = reviseMaterials(base, barrel) + const toTakeFromBarrel = createMaterialsCounter(base.ldb.toTakeFromBarrel) + const barrelMaterials = createMaterialsCounter(base.ldb.barrel) + + if (!isEmpty(base.ldb.materialsMissing)) { + startRotting(base) + } else { + stopRotting(base) + + // The problem with taking materials from barrel is that + // barrel can be in unloaded chunk where we can't do + // anything with block. Because of the we must instead + // save blocks we need to take in the base.ldb.toTakeFromBarrel + // and later when barrel is loaded again we merge them + // with usual materials to take both of them + const materials = createMaterialsCounter(base.ldb.materials) + if (barrelSlots && !isEmpty(base.ldb.toTakeFromBarrel)) { + for (const [typeId, amount] of Object.entries(base.ldb.toTakeFromBarrel)) { + materials.add(typeId, amount) + } + } + + for (const material of Object.entries(materials.result())) { + const [typeId] = material + let [, amount] = material + // Update barrel inventory cache + barrelMaterials.remove(typeId, amount) + + if (!barrelSlots) { + // Barrel unloaded, save for later removing + toTakeFromBarrel.add(typeId, amount) + } else { + for (const slot of barrelSlots.get(typeId) ?? []) { + if (amount <= 0) break + + amount -= slot.amount + if (amount < 0) { + // in this slot there is more items then we need + slot.amount -= amount + slot.amount + } else { + // take all the items from this slot + slot.setItem(undefined) + } + } + } + } + } + + base.ldb.toTakeFromBarrel = toTakeFromBarrel.result() + base.ldb.barrel = barrelMaterials.result() + base.save() } -function forEachChangedBlock(base: BaseRegion, callback: (block?: Block) => void) { - return base.structure.forEachBlock((vector, savedPermutation) => { - const block = base.dimension.getBlock(vector) +onFullRegionTypeRestore(BaseRegion, async base => { + // Rotting complete + await base.structure.place() + base.dimension.setBlockType(base.area.center, MinecraftBlockTypes.Air) + base.delete() +}) + +function forEachChangedBlock( + base: BaseRegion, + callback: (block: Block | undefined, savedPermutation: BlockPermutation | undefined, location: Vector3) => void, +) { + return base.structure.forEachBlock((location, savedPermutation) => { + if (Vector.equals(location, base.area.center)) return + + const block = base.dimension.getBlock(location) if (savedPermutation && block && permutationEquals(block.permutation, savedPermutation)) return - callback(block) + callback(block, savedPermutation, location) }) } function permutationEquals(a: BlockPermutation, b: BlockPermutation) { + if (a.type.id !== b.type.id) return false + const bStates = b.getAllStates() for (const [state, value] of Object.entries(a.getAllStates())) { if (value !== bStates[state]) return false @@ -137,10 +276,28 @@ function permutationEquals(a: BlockPermutation, b: BlockPermutation) { return true } -function isNearPlayers(base: BaseRegion) { +function createMaterialsCounter(from?: Record) { + const materials = new Map(from && Object.entries(from)) + return { + add(typeId: string, amount = 1) { + const material = materials.get(typeId) ?? 0 + materials.set(typeId, material + amount) + }, + remove(typeId: string, amount: number) { + if (!materials.has(typeId)) return + + const material = materials.get(typeId) ?? 0 + const newAmount = material - amount + if (newAmount > 0) materials.set(typeId, newAmount) + else materials.delete(typeId) + }, + result: () => Object.fromEntries(materials), + } +} + +function anyPlayerNear(base: BaseRegion) { for (const [, point] of playerPositionCache) { if (base.area.isNear(point, 20)) return true } - return false } diff --git a/src/modules/places/base/actions/upgrade.ts b/src/modules/places/base/actions/upgrade.ts index a6e705ae..d6b6cbca 100644 --- a/src/modules/places/base/actions/upgrade.ts +++ b/src/modules/places/base/actions/upgrade.ts @@ -1,21 +1,25 @@ import { Player } from '@minecraft/server' +import { SphereArea } from 'lib/region/areas/sphere' import { Product } from 'lib/shop/product' import { MaybeRawText, t } from 'lib/text' import { baseLevels } from '../base-levels' import { BaseRegion } from '../region' export function baseUpgradeButton(base: BaseRegion, player: Player, back: (message?: MaybeRawText) => void) { - const levelText = `${base.linkedDatabase.level}/${baseLevels.length - 1}` - const upgradeTo = base.linkedDatabase.level + 1 - if (!(upgradeTo in baseLevels)) return [`§7Максимальный уровень\n${levelText}`, undefined, back] as Product['button'] + const levelText = t`${base.ldb.level}/${baseLevels.length - 1}` + const upgradeLevel = base.ldb.level + 1 + if (!(upgradeLevel in baseLevels)) + return [`§7Максимальный уровень\n${levelText}`, undefined, back] as Product['button'] - const upgradeLevel = baseLevels[upgradeTo] + const upgrade = baseLevels[upgradeLevel] return Product.create() .form(back) .player(player) - .name(t`Улучшить базу до\n${upgradeTo} уровня, радиус ${upgradeLevel.radius} (${levelText})`) - .cost(upgradeLevel.cost) + .name(t`Улучшить базу: ${levelText}`) + .cost(upgrade.cost) .onBuy(() => { - // + base.ldb.level = upgradeLevel + if (base.area instanceof SphereArea) base.area.radius = upgrade.radius + player.success() }).button } diff --git a/src/modules/places/base/base-menu.ts b/src/modules/places/base/base-menu.ts index e003a0db..be49b215 100644 --- a/src/modules/places/base/base-menu.ts +++ b/src/modules/places/base/base-menu.ts @@ -1,8 +1,9 @@ import { Player } from '@minecraft/server' import { ActionForm, LockAction, Vector, editRegionPermissions, manageRegionMembers } from 'lib' import { MaybeRawText, t } from 'lib/text' -import { BaseRegion } from './region' +import { baseRottingButton } from './actions/rotting' import { baseUpgradeButton } from './actions/upgrade' +import { BaseRegion } from './region' export const baseCommand = new Command('base').setDescription('Меню базы').executes(ctx => openBaseMenu(ctx.player)) @@ -24,7 +25,7 @@ function baseMenu(player: Player, base: BaseRegion, back?: VoidFunction, message const baseBack = (message?: MaybeRawText) => baseMenu(player, base, back, message) const form = new ActionForm( 'Меню базы', - t`${message ? t`${message}\n\n` : ''}${isOwner ? t`Это ваша база.` : t`База игрока ${base.ownerName}`}\n\nКоординаты: ${base.area.center}\nРадиус: ${base.area.radius}`, + t.raw`${message ? t.raw`${message}\n\n` : ''}${isOwner ? t`Это ваша база.` : t`База игрока ${base.ownerName}`}${t`\n\nКоординаты: ${base.area.center}\nРадиус: ${base.area.radius}`}`, ) form @@ -36,6 +37,7 @@ function baseMenu(player: Player, base: BaseRegion, back?: VoidFunction, message pluralForms: basePluralForms, }), ) + .addButton(...baseRottingButton(base, player, baseBack)) .addButton(...baseUpgradeButton(base, player, baseBack)) if (isOwner) diff --git a/src/modules/places/base/base.ts b/src/modules/places/base/base.ts index 694a831f..34938244 100644 --- a/src/modules/places/base/base.ts +++ b/src/modules/places/base/base.ts @@ -1,6 +1,9 @@ import { MinecraftItemTypes } from '@minecraft/vanilla-data' import { CustomItemWithBlueprint } from 'lib/rpg/custom-item' import { createLogger } from 'lib/utils/logger' +import './actions/create' +import './actions/rotting' +import './actions/upgrade' export const BaseItem = new CustomItemWithBlueprint('base') .typeId(MinecraftItemTypes.Barrel) diff --git a/src/modules/places/base/region.ts b/src/modules/places/base/region.ts index 85416fbc..1b67e659 100644 --- a/src/modules/places/base/region.ts +++ b/src/modules/places/base/region.ts @@ -1,60 +1,65 @@ -import { Player } from '@minecraft/server' -import { Mail } from 'lib' +import { actionGuard, ActionGuardOrder, isBuilding, Vector } from 'lib' import { SphereArea } from 'lib/region/areas/sphere' import { registerCreateableRegion } from 'lib/region/command' import { registerSaveableRegion } from 'lib/region/database' import { RegionWithStructure } from 'lib/region/kinds/with-structure' -import { t } from 'lib/text' interface BaseLDB extends JsonObject { level: number - materials: Record - materialsMissing: Record + materials: Readonly> + materialsMissing: Readonly> + barrel: Readonly> + toTakeFromBarrel: Readonly> isRotting: boolean } +const MAX_RADIUS = 30 + export class BaseRegion extends RegionWithStructure { protected onCreate(): void { // Save structure with bigger radius for future upgrading const radius = this.area.radius try { - if (this.area instanceof SphereArea) this.area.radius = 30 + if (this.area instanceof SphereArea) this.area.radius = MAX_RADIUS this.structure.save() } catch (e) { } finally { if (this.area instanceof SphereArea) this.area.radius = radius + this.onRestore() } } - linkedDatabase: BaseLDB = { + protected onRestore(): void { + this.structure.offset = MAX_RADIUS - this.area.radius + } + + ldb: BaseLDB = { level: 1, materials: {}, materialsMissing: {}, + barrel: {}, + toTakeFromBarrel: {}, isRotting: false, } +} - startRotting() { - this.linkedDatabase.isRotting = true - this.save() - - const message = t.error`База с владельцем ${this.ownerName} разрушена.` - this.forEachOwner(player => { - if (player instanceof Player) { - player.fail(message) - } else { - Mail.send( - player, - message, - 'База была зарейжена. Сожалеем. Вы все еще можете восстановить ее, если она не сгнила полностью', - ) - } - }) - } +actionGuard((player, base, ctx) => { + if (!(base instanceof BaseRegion) || isBuilding(player) || !base.isMember(player.id)) return + + if (base.ldb.isRotting) { + if ( + (ctx.type === 'interactWithBlock' || ctx.type === 'place') && + Vector.equals(ctx.event.block.location, base.area.center) + ) { + // Allow interacting with base block or placing + return true + } - onRottingInterval() { - // + // TODO Say how to fix rotting + player.fail('База гниет!') + return false } -} +}, ActionGuardOrder.BlockAction) registerSaveableRegion('base', BaseRegion) registerCreateableRegion('Базы', BaseRegion) diff --git a/src/modules/places/dungeons/dungeon.ts b/src/modules/places/dungeons/dungeon.ts index f58fc975..b67739b8 100644 --- a/src/modules/places/dungeons/dungeon.ts +++ b/src/modules/places/dungeons/dungeon.ts @@ -25,7 +25,7 @@ export class DungeonRegion extends Region { () => { for (const dungeon of this.dungeons) { for (const chest of dungeon.chests) { - const placed = dungeon.linkedDatabase.chests[chest.id] + const placed = dungeon.ldb.chests[chest.id] if (!placed || Cooldown.isExpired(placed, chest.restoreTime)) { dungeon.updateChest(chest) } @@ -39,10 +39,10 @@ export class DungeonRegion extends Region { constructor(area: Area, options: DungeonRegionOptions, key: string) { super(area, options, key) - this.linkedDatabase.structureId = options.structureId + this.ldb.structureId = options.structureId } - linkedDatabase: DungeonRegionDatabase = { + ldb: DungeonRegionDatabase = { chests: {}, structureId: '', } @@ -50,7 +50,7 @@ export class DungeonRegion extends Region { protected structureFile: StructureFile | undefined get structureId() { - return this.linkedDatabase.structureId + return this.ldb.structureId } protected structureSize: Vector3 = Vector.one @@ -136,7 +136,7 @@ export class DungeonRegion extends Region { const block = this.dimension.getBlock(Vector.add(this.structurePosition, chest.location)) if (!block?.isValid()) return - this.linkedDatabase.chests[chest.id] = Date.now() + this.ldb.chests[chest.id] = Date.now() if (block.typeId !== MinecraftBlockTypes.Chest) block.setType(MinecraftBlockTypes.Chest) diff --git a/src/modules/places/minearea/minearea-region.ts b/src/modules/places/minearea/minearea-region.ts index 86a81988..9b7114d0 100644 --- a/src/modules/places/minearea/minearea-region.ts +++ b/src/modules/places/minearea/minearea-region.ts @@ -103,19 +103,23 @@ actionGuard((player, region, ctx) => { } }, ActionGuardOrder.Lowest) -onScheduledBlockPlace.subscribe(({ block, schedules, schedule }) => { - const regions = MineareaRegion.getManyAt(block) - for (const region of regions) { - if (!(region instanceof MineareaRegion)) continue - - const dimensionType = block.dimension.type - const toRestore = schedules.filter(e => region.area.isIn({ vector: e.location, dimensionType }) && e !== schedule) - if (toRestore.length) { - // logger.debug`Still blocks to restore: ${toRestore.length}` - continue +export function onFullRegionTypeRestore( + regionType: T, + callback: (region: InstanceType) => void, +) { + onScheduledBlockPlace.subscribe(({ block, schedules, schedule }) => { + const regions = regionType.getManyAt>(block) + for (const region of regions) { + const dimensionType = block.dimension.type + const toRestore = schedules.filter(e => region.area.isIn({ vector: e.location, dimensionType }) && e !== schedule) + if (toRestore.length) continue + + callback(region) } + }) +} - logger.info`All blocks in region ${region.name} kind ${region.creator.kind} are restored.` - region.structure.place() - } +onFullRegionTypeRestore(MineareaRegion, region => { + logger.info`All blocks in region ${region.name} kind ${region.creator.kind} are restored.` + region.structure.place() }) diff --git a/src/modules/places/stone-quarry/wither.boss.ts b/src/modules/places/stone-quarry/wither.boss.ts index b7c2e9f7..760a73c6 100644 --- a/src/modules/places/stone-quarry/wither.boss.ts +++ b/src/modules/places/stone-quarry/wither.boss.ts @@ -1,4 +1,3 @@ -import { world } from '@minecraft/server' import { MinecraftEntityTypes } from '@minecraft/vanilla-data' import { Boss, Loot, ms } from 'lib' import { RegionStructure } from 'lib/region/structure' @@ -22,12 +21,16 @@ export function createBossWither(group: Group) { .spawnEvent(true) .radius(30) - boss.onRegionCreate.subscribe(region => { + boss.onRegionCreate.subscribe(async region => { region.structure = new RegionStructure(region) if (!region.structure.exists) { - region.structure.save() - world.say(t`Saved structure for ${region.displayName}`) + try { + await region.structure.save() + console.info(t`Saved structure for ${region.displayName}`) + } catch (e) { + console.warn(t.warn`Unable to save structure for ${region.displayName}`) + } } }) diff --git a/src/modules/survival/scheduled-block-place.ts b/src/modules/survival/scheduled-block-place.ts index 8d5d6e27..2789f91b 100644 --- a/src/modules/survival/scheduled-block-place.ts +++ b/src/modules/survival/scheduled-block-place.ts @@ -30,6 +30,7 @@ export function scheduleBlockPlace({ SCHEDULED_DB[dimension].push({ date: Date.now() + restoreTime, ...options }) } +/** Checks whenether provided location in dimension has scheduled blocks */ export function isScheduledToPlace(location: Vector3, dimension: DimensionType) { const dimblocks = IMMUTABLE_DB[dimension] if (typeof dimblocks === 'undefined') return false @@ -37,12 +38,25 @@ export function isScheduledToPlace(location: Vector3, dimension: DimensionType) return dimblocks.find(e => Vector.equals(e.location, location)) } +/** Event that triggers when scheduled block is being placed */ export const onScheduledBlockPlace = new EventSignal<{ schedule: Readonly block: Block + dimensionType: DimensionType schedules: readonly ScheduledBlockPlace[] }>() +const UNSCHEDULED = -1 + +/** + * Cancels scheduled block place by setting its date to -1 + * + * @param schedule - Schedule to be canceled + */ +export function unscheduleBlockPlace(schedule: ScheduledBlockPlace) { + schedule.date = UNSCHEDULED +} + const logger = createLogger('SheduledPlace') // If we will not use immutable unproxied value, @@ -74,9 +88,11 @@ function* scheduledBlockPlaceJob() { } block.setPermutation(BlockPermutation.resolve(schedule.typeId, schedule.states)) + if (__DEV__ || (schedules.length - 1) % 100 === 0) logger.info`${schedule.typeId.replace('minecraft:', '')} to ${schedule.location}, remains ${schedules.length - 1}` - EventSignal.emit(onScheduledBlockPlace, { schedule, block, schedules }) + + EventSignal.emit(onScheduledBlockPlace, { schedule, block, schedules, dimensionType: dimension }) } catch (e) { if (e instanceof LocationInUnloadedChunkError) { yield @@ -85,7 +101,7 @@ function* scheduledBlockPlaceJob() { } // Remove successfully placed block from the schedule array - SCHEDULED_DB[dimension].splice(i, 1) + removeScheduleAt(dimension, i) yield } @@ -99,16 +115,17 @@ function timeout() { } timeout() +function removeScheduleAt(dimension: DimensionType, i: number) { + SCHEDULED_DB[dimension].splice(i, 1) +} + function getScheduleBlock( schedule: Readonly, i: number, dimension: DimensionType, schedules: readonly ScheduledBlockPlace[], ) { - if (typeof schedule === 'undefined') { - SCHEDULED_DB[dimension].splice(i, 1) - return - } + if (typeof schedule === 'undefined' || schedule.date === UNSCHEDULED) return removeScheduleAt(dimension, i) let date = schedule.date if (date !== 0) { diff --git a/src/modules/world-edit/lib/world-edit-tool.ts b/src/modules/world-edit/lib/world-edit-tool.ts index 5ad2e378..f27bdd2f 100644 --- a/src/modules/world-edit/lib/world-edit-tool.ts +++ b/src/modules/world-edit/lib/world-edit-tool.ts @@ -24,7 +24,7 @@ type StorageKey = | 'radius' | 'blending' | 'factor' - | 'useInterval' + | 'activator' const LORE_SEPARATOR = '\u00a0' @@ -134,7 +134,7 @@ export abstract class WorldEditTool { type: 'Тип', blending: 'Смешивание', factor: 'Фактор смешивания', - useInterval: 'Использовать интервал', + activator: 'Активатор', } getStorage(slot: ContainerSlot | ItemStack, returnUndefined?: false): Storage @@ -195,7 +195,7 @@ export abstract class WorldEditTool { } if ('version' in raw) Reflect.deleteProperty(raw, 'version') - return raw + return Object.setPrototypeOf(raw, this.storageSchema) as typeof raw } /** @deprecated */ diff --git a/src/modules/world-edit/lib/world-edit.ts b/src/modules/world-edit/lib/world-edit.ts index 288e109a..5962c46c 100644 --- a/src/modules/world-edit/lib/world-edit.ts +++ b/src/modules/world-edit/lib/world-edit.ts @@ -4,6 +4,7 @@ import { Sounds } from 'lib/assets/custom-sounds' import { table } from 'lib/database/abstract' import { t } from 'lib/text' import { stringify } from 'lib/utils/inspect' +import { createLogger } from 'lib/utils/logger' import { ngettext } from 'lib/utils/ngettext' import { WeakPlayerMap } from 'lib/weak-player-storage' import { WE_CONFIG, spawnParticlesInArea } from '../config' @@ -25,6 +26,8 @@ interface WeDB { pos2: Vector3 } +const logger = createLogger('WorldEdit') + export class WorldEdit { static db = table('worldEdit', () => ({ pos1: { x: 0, y: 0, z: 0 }, pos2: { x: 0, y: 0, z: 0 } })) @@ -120,7 +123,7 @@ export class WorldEdit { const text = stringify(error) if (!text) return - console.error(text) + logger.player(this.player).error`Failed to ${action}: ${error}` this.player.fail(`Не удалось ${action}§f: ${error}`) } @@ -173,7 +176,9 @@ export class WorldEdit { this.loadBackup(history, backup) } - this.player.info(`§3Успешно отменено §f${amount} §3${ngettext(amount, ['действие', 'действия', 'действий'])}!`) + this.player.info( + `§3Успешно ${history === this.history ? 'отменить' : 'восстановить'} §f${amount} §3${ngettext(amount, ['действие', 'действия', 'действий'])}!`, + ) } catch (error) { this.failedTo('отменить', error) } @@ -185,7 +190,7 @@ export class WorldEdit { history === this.history ? 'Отмена (undo) ' + backup.name : 'Восстановление (redo) ' + backup.name, backup.pos1, backup.pos2, - this.undos, + history === this.history ? this.undos : this.history, ) backup.load() diff --git a/src/modules/world-edit/menu.ts b/src/modules/world-edit/menu.ts index e9d2e828..50742e9c 100644 --- a/src/modules/world-edit/menu.ts +++ b/src/modules/world-edit/menu.ts @@ -34,7 +34,9 @@ export function WEmenu(player: Player, body = '') { const we = WorldEdit.forPlayer(player) const form = new ActionForm('§dWorld§6Edit', body) - form.addButton(t.badge`§3Наборы блоков ${getOwnBlocksSetsCount(player.id)}`, () => WEblocksSetsMenu(player)) + form.addButton(t.options({ num: '§f' }).badge`§3Наборы блоков ${getOwnBlocksSetsCount(player.id)}`, () => + WEblocksSetsMenu(player), + ) const toolButtons = WorldEditTool.tools.map(tool => ({ tool, buttonText: tool.getMenuButtonName(player) })) const inactiveTools = toolButtons.filter(e => e.buttonText.startsWith('§8')) @@ -42,7 +44,7 @@ export function WEmenu(player: Player, body = '') { addToForm(activeTools) - form.addButton(t.badge`§3Отмена действий ${we.history.length}`, () => WEundoRedoMenu(player)) + form.addButton(t.options({ num: '§f' }).badge`§3Отмена действий ${we.history.length}`, () => WEundoRedoMenu(player)) form.addButton('§3Создать сундук блоков из набора', () => WEChestFromBlocksSet(player)) addToForm(inactiveTools) diff --git a/src/modules/world-edit/tools/shovel.ts b/src/modules/world-edit/tools/shovel.ts index 3a64bf76..e7fa2839 100644 --- a/src/modules/world-edit/tools/shovel.ts +++ b/src/modules/world-edit/tools/shovel.ts @@ -19,6 +19,20 @@ import { toReplaceTarget, } from '../utils/blocks-set' +enum Activator { + OnUse = 'use', + Interval0 = 'i0', + Interval10 = 'i10', + Interval20 = 'i20', +} + +const activator: Record = { + [Activator.Interval0]: 'Раз в тик', + [Activator.Interval10]: 'Раз в пол секунды', + [Activator.Interval20]: 'Раз в секунду', + [Activator.OnUse]: 'При использовании', +} + interface Storage { version: number blocksSet: BlocksSetRef @@ -29,7 +43,7 @@ interface Storage { offset: number blending: number factor: number - useInterval: boolean + activator: Activator } class ShovelTool extends WorldEditTool { @@ -47,7 +61,7 @@ class ShovelTool extends WorldEditTool { offset: -1, blending: -1, factor: 20, - useInterval: true, + activator: Activator.Interval10, } getMenuButtonName(player: Player) { @@ -72,10 +86,10 @@ class ShovelTool extends WorldEditTool { storage.blending, ) .addSlider('Сила смешивания', 0, 100, 1, storage.factor) - .addToggle('Использовать интервал для активации', storage.useInterval) + .addDropdownFromObject('Метод активации', activator, { defaultValue: storage.activator }) .show( player, - (_, radius, height, offset, blocksSet, replaceBlocksSet, replaceMode, blending, factor, useInterval) => { + (_, radius, height, offset, blocksSet, replaceBlocksSet, replaceMode, blending, factor, activator) => { slot.nameTag = `§r§3Лопата §f${radius} §6${blocksSet}` storage.radius = radius storage.height = height @@ -83,7 +97,7 @@ class ShovelTool extends WorldEditTool { storage.replaceMode = replaceMode ?? '' storage.blending = Math.min(radius, blending) storage.factor = factor - storage.useInterval = useInterval + storage.activator = activator storage.blocksSet = [player.id, blocksSet] @@ -115,14 +129,22 @@ class ShovelTool extends WorldEditTool { } }) + this.onInterval(0, (player, storage) => { + if (storage.activator !== Activator.Interval0) return + this.run(player, storage) + }) this.onInterval(10, (player, storage) => { - if (!storage.useInterval) return + if (storage.activator !== Activator.Interval10) return + this.run(player, storage) + }) + this.onInterval(20, (player, storage) => { + if (storage.activator !== Activator.Interval20) return this.run(player, storage) }) } onUse(player: Player, _: ItemStack, storage: Storage) { - if (storage.useInterval) return + if (storage.activator !== Activator.OnUse) return this.run(player, storage) } diff --git a/src/modules/world-edit/tools/smooth.ts b/src/modules/world-edit/tools/smooth.ts index c3d80035..ec9e5791 100644 --- a/src/modules/world-edit/tools/smooth.ts +++ b/src/modules/world-edit/tools/smooth.ts @@ -83,7 +83,7 @@ export async function smoothVoxelData( function* smootherJob() { try { - const prefix = '§7Сглаживание: §f ' + const prefix = '§7Сглаживание: §f' if (radius > 5) player.info(prefix + 'Вычисление...') // Create a copy of the voxel data diff --git a/src/modules/world-edit/utils/default-block-sets.ts b/src/modules/world-edit/utils/default-block-sets.ts index 346012f5..f02f9a3a 100644 --- a/src/modules/world-edit/utils/default-block-sets.ts +++ b/src/modules/world-edit/utils/default-block-sets.ts @@ -55,11 +55,14 @@ export const DEFAULT_REPLACE_TARGET_SETS: Record = { } export const REPLACE_MODES: Record = { + 'Не воздух': { + matches: block => !block.isAir, + }, 'Любой цельный блок': { - matches: block => block.isSolid, + matches: block => block.isSolid && !block.isAir, }, 'Любой водонепроницаемый блок': { - matches: block => !block.type.canBeWaterlogged, + matches: block => !block.type.canBeWaterlogged && !block.isAir && !block.isLiquid, }, 'Любой полублок': { matches: block => isSlab(block.typeId),