diff options
Diffstat (limited to 'src/main/kotlin')
-rw-r--r-- | src/main/kotlin/Firmament.kt | 173 | ||||
-rw-r--r-- | src/main/kotlin/events/HudRenderEvent.kt | 4 | ||||
-rw-r--r-- | src/main/kotlin/features/debug/PowerUserTools.kt | 7 | ||||
-rw-r--r-- | src/main/kotlin/features/mining/PickaxeAbility.kt | 262 | ||||
-rw-r--r-- | src/main/kotlin/features/texturepack/NumberMatcher.kt | 231 | ||||
-rw-r--r-- | src/main/kotlin/util/ErrorUtil.kt | 16 | ||||
-rw-r--r-- | src/main/kotlin/util/MC.kt | 131 | ||||
-rw-r--r-- | src/main/kotlin/util/TimeMark.kt | 82 | ||||
-rw-r--r-- | src/main/kotlin/util/mc/SNbtFormatter.kt | 138 | ||||
-rw-r--r-- | src/main/kotlin/util/regex.kt | 14 | ||||
-rw-r--r-- | src/main/kotlin/util/skyblock/AbilityUtils.kt | 138 | ||||
-rw-r--r-- | src/main/kotlin/util/textutil.kt | 2 |
12 files changed, 755 insertions, 443 deletions
diff --git a/src/main/kotlin/Firmament.kt b/src/main/kotlin/Firmament.kt index c1801f4..343ec40 100644 --- a/src/main/kotlin/Firmament.kt +++ b/src/main/kotlin/Firmament.kt @@ -1,5 +1,3 @@ - - package moe.nea.firmament import com.mojang.brigadier.CommandDispatcher @@ -33,7 +31,6 @@ import kotlinx.coroutines.plus import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlin.coroutines.EmptyCoroutineContext -import net.minecraft.client.render.chunk.SectionBuilder import net.minecraft.command.CommandRegistryAccess import net.minecraft.util.Identifier import moe.nea.firmament.commands.registerFirmamentCommand @@ -51,98 +48,98 @@ import moe.nea.firmament.util.SBData import moe.nea.firmament.util.data.IDataHolder object Firmament { - const val MOD_ID = "firmament" + const val MOD_ID = "firmament" - val DEBUG = System.getProperty("firmament.debug") == "true" - val DATA_DIR: Path = Path.of(".firmament").also { Files.createDirectories(it) } - val CONFIG_DIR: Path = Path.of("config/firmament").also { Files.createDirectories(it) } - val logger: Logger = LogManager.getLogger("Firmament") - private val metadata: ModMetadata by lazy { - FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow().metadata - } - val version: Version by lazy { metadata.version } + val DEBUG = System.getProperty("firmament.debug") == "true" + val DATA_DIR: Path = Path.of(".firmament").also { Files.createDirectories(it) } + val CONFIG_DIR: Path = Path.of("config/firmament").also { Files.createDirectories(it) } + val logger: Logger = LogManager.getLogger("Firmament") + private val metadata: ModMetadata by lazy { + FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow().metadata + } + val version: Version by lazy { metadata.version } - val json = Json { - prettyPrint = DEBUG - isLenient = true - ignoreUnknownKeys = true - encodeDefaults = true - } + val json = Json { + prettyPrint = DEBUG + isLenient = true + ignoreUnknownKeys = true + encodeDefaults = true + } - val httpClient by lazy { - HttpClient { - install(ContentNegotiation) { - json(json) - } - install(ContentEncoding) { - gzip() - deflate() - } - install(UserAgent) { - agent = "Firmament/$version" - } - if (DEBUG) - install(Logging) { - level = LogLevel.INFO - } - install(HttpCache) - } - } + val httpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json) + } + install(ContentEncoding) { + gzip() + deflate() + } + install(UserAgent) { + agent = "Firmament/$version" + } + if (DEBUG) + install(Logging) { + level = LogLevel.INFO + } + install(HttpCache) + } + } - val globalJob = Job() - val coroutineScope = - CoroutineScope(EmptyCoroutineContext + CoroutineName("Firmament")) + SupervisorJob(globalJob) + val globalJob = Job() + val coroutineScope = + CoroutineScope(EmptyCoroutineContext + CoroutineName("Firmament")) + SupervisorJob(globalJob) - private fun registerCommands( - dispatcher: CommandDispatcher<FabricClientCommandSource>, - @Suppress("UNUSED_PARAMETER") - ctx: CommandRegistryAccess - ) { - registerFirmamentCommand(dispatcher) - CommandEvent.publish(CommandEvent(dispatcher, ctx, MC.networkHandler?.commandDispatcher)) - } + private fun registerCommands( + dispatcher: CommandDispatcher<FabricClientCommandSource>, + @Suppress("UNUSED_PARAMETER") + ctx: CommandRegistryAccess + ) { + registerFirmamentCommand(dispatcher) + CommandEvent.publish(CommandEvent(dispatcher, ctx, MC.networkHandler?.commandDispatcher)) + } - @JvmStatic - fun onInitialize() { - } + @JvmStatic + fun onInitialize() { + } - @JvmStatic - fun onClientInitialize() { - FeatureManager.subscribeEvents() - var tick = 0 - ClientTickEvents.END_CLIENT_TICK.register(ClientTickEvents.EndTick { instance -> - TickEvent.publish(TickEvent(tick++)) - }) - IDataHolder.registerEvents() - RepoManager.initialize() - SBData.init() - FeatureManager.autoload() - HypixelStaticData.spawnDataCollectionLoop() - ClientCommandRegistrationCallback.EVENT.register(this::registerCommands) - ClientLifecycleEvents.CLIENT_STARTED.register(ClientLifecycleEvents.ClientStarted { - ClientStartedEvent.publish(ClientStartedEvent()) - }) - ClientLifecycleEvents.CLIENT_STOPPING.register(ClientLifecycleEvents.ClientStopping { - logger.info("Shutting down Firmament coroutines") - globalJob.cancel() - }) - registerFirmamentEvents() - ItemTooltipCallback.EVENT.register { stack, context, type, lines -> - ItemTooltipEvent.publish(ItemTooltipEvent(stack, context, type, lines)) - } - ScreenEvents.AFTER_INIT.register(ScreenEvents.AfterInit { client, screen, scaledWidth, scaledHeight -> - ScreenEvents.afterRender(screen) - .register(ScreenEvents.AfterRender { screen, drawContext, mouseX, mouseY, tickDelta -> - ScreenRenderPostEvent.publish(ScreenRenderPostEvent(screen, mouseX, mouseY, tickDelta, drawContext)) - }) - }) - } + @JvmStatic + fun onClientInitialize() { + FeatureManager.subscribeEvents() + var tick = 0 + ClientTickEvents.END_CLIENT_TICK.register(ClientTickEvents.EndTick { instance -> + TickEvent.publish(TickEvent(tick++)) + }) + IDataHolder.registerEvents() + RepoManager.initialize() + SBData.init() + FeatureManager.autoload() + HypixelStaticData.spawnDataCollectionLoop() + ClientCommandRegistrationCallback.EVENT.register(this::registerCommands) + ClientLifecycleEvents.CLIENT_STARTED.register(ClientLifecycleEvents.ClientStarted { + ClientStartedEvent.publish(ClientStartedEvent()) + }) + ClientLifecycleEvents.CLIENT_STOPPING.register(ClientLifecycleEvents.ClientStopping { + logger.info("Shutting down Firmament coroutines") + globalJob.cancel() + }) + registerFirmamentEvents() + ItemTooltipCallback.EVENT.register { stack, context, type, lines -> + ItemTooltipEvent.publish(ItemTooltipEvent(stack, context, type, lines)) + } + ScreenEvents.AFTER_INIT.register(ScreenEvents.AfterInit { client, screen, scaledWidth, scaledHeight -> + ScreenEvents.afterRender(screen) + .register(ScreenEvents.AfterRender { screen, drawContext, mouseX, mouseY, tickDelta -> + ScreenRenderPostEvent.publish(ScreenRenderPostEvent(screen, mouseX, mouseY, tickDelta, drawContext)) + }) + }) + } - fun identifier(path: String) = Identifier.of(MOD_ID, path) - inline fun <reified T : Any> tryDecodeJsonFromStream(inputStream: InputStream): Result<T> { - return runCatching { - json.decodeFromStream<T>(inputStream) - } - } + fun identifier(path: String) = Identifier.of(MOD_ID, path) + inline fun <reified T : Any> tryDecodeJsonFromStream(inputStream: InputStream): Result<T> { + return runCatching { + json.decodeFromStream<T>(inputStream) + } + } } diff --git a/src/main/kotlin/events/HudRenderEvent.kt b/src/main/kotlin/events/HudRenderEvent.kt index 555b3c8..a773a93 100644 --- a/src/main/kotlin/events/HudRenderEvent.kt +++ b/src/main/kotlin/events/HudRenderEvent.kt @@ -4,10 +4,14 @@ package moe.nea.firmament.events import net.minecraft.client.gui.DrawContext import net.minecraft.client.render.RenderTickCounter +import net.minecraft.world.GameMode +import moe.nea.firmament.util.MC /** * Called when hud elements should be rendered, before the screen, but after the world. */ data class HudRenderEvent(val context: DrawContext, val tickDelta: RenderTickCounter) : FirmamentEvent() { + val isRenderingHud = !MC.options.hudHidden + val isRenderingCursor = MC.interactionManager?.currentGameMode != GameMode.SPECTATOR && isRenderingHud companion object : FirmamentEventBus<HudRenderEvent>() } diff --git a/src/main/kotlin/features/debug/PowerUserTools.kt b/src/main/kotlin/features/debug/PowerUserTools.kt index 7ce14c0..529f011 100644 --- a/src/main/kotlin/features/debug/PowerUserTools.kt +++ b/src/main/kotlin/features/debug/PowerUserTools.kt @@ -28,6 +28,7 @@ import moe.nea.firmament.mixins.accessor.AccessorHandledScreen import moe.nea.firmament.util.ClipboardUtils import moe.nea.firmament.util.MC import moe.nea.firmament.util.focusedItemStack +import moe.nea.firmament.util.mc.SNbtFormatter.Companion.toPrettyString import moe.nea.firmament.util.mc.displayNameAccordingToNbt import moe.nea.firmament.util.mc.loreAccordingToNbt import moe.nea.firmament.util.skyBlockId @@ -44,6 +45,7 @@ object PowerUserTools : FirmamentFeature { val copyLoreData by keyBindingWithDefaultUnbound("copy-lore") val copySkullTexture by keyBindingWithDefaultUnbound("copy-skull-texture") val copyEntityData by keyBindingWithDefaultUnbound("entity-data") + val copyItemStack by keyBindingWithDefaultUnbound("copy-item-stack") } override val config @@ -125,7 +127,7 @@ object PowerUserTools : FirmamentFeature { Pair(item, Text.stringifiedTranslatable("firmament.tooltip.copied.modelid", model.toString())) } else if (it.matches(TConfig.copyNbtData)) { // TODO: copy full nbt - val nbt = item.get(DataComponentTypes.CUSTOM_DATA)?.nbt?.toString() ?: "<empty>" + val nbt = item.get(DataComponentTypes.CUSTOM_DATA)?.nbt?.toPrettyString() ?: "<empty>" ClipboardUtils.setTextContent(nbt) lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.nbt")) } else if (it.matches(TConfig.copyLoreData)) { @@ -157,6 +159,9 @@ object PowerUserTools : FirmamentFeature { Text.stringifiedTranslatable("firmament.tooltip.copied.skull-id", skullTexture.toString()) ) println("Copied skull id: $skullTexture") + } else if (it.matches(TConfig.copyItemStack)) { + ClipboardUtils.setTextContent(item.encode(MC.currentOrDefaultRegistries).toPrettyString()) + lastCopiedStack = Pair(item, Text.stringifiedTranslatable("firmament.tooltip.copied.stack")) } } diff --git a/src/main/kotlin/features/mining/PickaxeAbility.kt b/src/main/kotlin/features/mining/PickaxeAbility.kt index 192419f..1853d65 100644 --- a/src/main/kotlin/features/mining/PickaxeAbility.kt +++ b/src/main/kotlin/features/mining/PickaxeAbility.kt @@ -1,4 +1,3 @@ - package moe.nea.firmament.features.mining import java.util.regex.Pattern @@ -30,155 +29,146 @@ import moe.nea.firmament.util.parseShortNumber import moe.nea.firmament.util.parseTimePattern import moe.nea.firmament.util.render.RenderCircleProgress import moe.nea.firmament.util.render.lerp +import moe.nea.firmament.util.skyblock.AbilityUtils import moe.nea.firmament.util.toShedaniel import moe.nea.firmament.util.unformattedString import moe.nea.firmament.util.useMatch object PickaxeAbility : FirmamentFeature { - override val identifier: String - get() = "pickaxe-info" - - - object TConfig : ManagedConfig(identifier) { - val cooldownEnabled by toggle("ability-cooldown") { true } - val cooldownScale by integer("ability-scale", 16, 64) { 16 } - val drillFuelBar by toggle("fuel-bar") { true } - } - - var lobbyJoinTime = TimeMark.farPast() - var lastUsage = mutableMapOf<String, TimeMark>() - var abilityOverride: String? = null - var defaultAbilityDurations = mutableMapOf<String, Duration>( - "Mining Speed Boost" to 120.seconds, - "Pickobulus" to 110.seconds, - "Gemstone Infusion" to 140.seconds, - "Hazardous Miner" to 140.seconds, - "Maniac Miner" to 59.seconds, - "Vein Seeker" to 60.seconds - ) - - override val config: ManagedConfig - get() = TConfig - - fun getCooldownPercentage(name: String, cooldown: Duration): Double { - val sinceLastUsage = lastUsage[name]?.passedTime() ?: Duration.INFINITE - val sinceLobbyJoin = lobbyJoinTime.passedTime() + override val identifier: String + get() = "pickaxe-info" + + + object TConfig : ManagedConfig(identifier) { + val cooldownEnabled by toggle("ability-cooldown") { true } + val cooldownScale by integer("ability-scale", 16, 64) { 16 } + val drillFuelBar by toggle("fuel-bar") { true } + } + + var lobbyJoinTime = TimeMark.farPast() + var lastUsage = mutableMapOf<String, TimeMark>() + var abilityOverride: String? = null + var defaultAbilityDurations = mutableMapOf<String, Duration>( + "Mining Speed Boost" to 120.seconds, + "Pickobulus" to 110.seconds, + "Gemstone Infusion" to 140.seconds, + "Hazardous Miner" to 140.seconds, + "Maniac Miner" to 59.seconds, + "Vein Seeker" to 60.seconds + ) + + override val config: ManagedConfig + get() = TConfig + + fun getCooldownPercentage(name: String, cooldown: Duration): Double { + val sinceLastUsage = lastUsage[name]?.passedTime() ?: Duration.INFINITE + val sinceLobbyJoin = lobbyJoinTime.passedTime() if (SBData.skyblockLocation == SkyBlockIsland.MINESHAFT) { if (sinceLobbyJoin < sinceLastUsage) { return 1.0 } } - if (sinceLastUsage < cooldown) - return sinceLastUsage / cooldown - return 1.0 - } - - @Subscribe - fun onSlotClick(it: SlotClickEvent) { - if (MC.screen?.title?.unformattedString == "Heart of the Mountain") { - val name = it.stack.displayNameAccordingToNbt?.unformattedString ?: return - val cooldown = it.stack.loreAccordingToNbt.firstNotNullOfOrNull { - cooldownPattern.useMatch(it.unformattedString) { - parseTimePattern(group("cooldown")) - } - } ?: return - defaultAbilityDurations[name] = cooldown - } - } - - @Subscribe - fun onDurabilityBar(it: DurabilityBarEvent) { - if (!TConfig.drillFuelBar) return - val lore = it.item.loreAccordingToNbt - if (lore.lastOrNull()?.unformattedString?.contains("DRILL") != true) return - val maxFuel = lore.firstNotNullOfOrNull { - fuelPattern.useMatch(it.unformattedString) { - parseShortNumber(group("maxFuel")) - } - } ?: return - val extra = it.item.extraAttributes - if (!extra.contains("drill_fuel")) return - val fuel = extra.getInt("drill_fuel") - val percentage = fuel / maxFuel.toFloat() - it.barOverride = DurabilityBarEvent.DurabilityBar( - lerp( - DyeColor.RED.toShedaniel(), - DyeColor.GREEN.toShedaniel(), - percentage - ), percentage - ) - } - - @Subscribe - fun onChatMessage(it: ProcessChatEvent) { - abilityUsePattern.useMatch(it.unformattedString) { - lastUsage[group("name")] = TimeMark.now() - } - abilitySwitchPattern.useMatch(it.unformattedString) { - abilityOverride = group("ability") - } - } - - @Subscribe - fun onWorldReady(event: WorldReadyEvent) { - lobbyJoinTime = TimeMark.now() - abilityOverride = null - } + if (sinceLastUsage < cooldown) + return sinceLastUsage / cooldown + return 1.0 + } + + @Subscribe + fun onSlotClick(it: SlotClickEvent) { + if (MC.screen?.title?.unformattedString == "Heart of the Mountain") { + val name = it.stack.displayNameAccordingToNbt.unformattedString + val cooldown = it.stack.loreAccordingToNbt.firstNotNullOfOrNull { + cooldownPattern.useMatch(it.unformattedString) { + parseTimePattern(group("cooldown")) + } + } ?: return + defaultAbilityDurations[name] = cooldown + } + } + + @Subscribe + fun onDurabilityBar(it: DurabilityBarEvent) { + if (!TConfig.drillFuelBar) return + val lore = it.item.loreAccordingToNbt + if (lore.lastOrNull()?.unformattedString?.contains("DRILL") != true) return + val maxFuel = lore.firstNotNullOfOrNull { + fuelPattern.useMatch(it.unformattedString) { + parseShortNumber(group("maxFuel")) + } + } ?: return + val extra = it.item.extraAttributes + if (!extra.contains("drill_fuel")) return + val fuel = extra.getInt("drill_fuel") + val percentage = fuel / maxFuel.toFloat() + it.barOverride = DurabilityBarEvent.DurabilityBar( + lerp( + DyeColor.RED.toShedaniel(), + DyeColor.GREEN.toShedaniel(), + percentage + ), percentage + ) + } + + @Subscribe + fun onChatMessage(it: ProcessChatEvent) { + abilityUsePattern.useMatch(it.unformattedString) { + lastUsage[group("name")] = TimeMark.now() + } + abilitySwitchPattern.useMatch(it.unformattedString) { + abilityOverride = group("ability") + } + } + + @Subscribe + fun onWorldReady(event: WorldReadyEvent) { + lobbyJoinTime = TimeMark.now() + abilityOverride = null + } @Subscribe fun onProfileSwitch(event: ProfileSwitchEvent) { lastUsage.clear() } - val abilityUsePattern = Pattern.compile("You used your (?<name>.*) Pickaxe Ability!") - val fuelPattern = Pattern.compile("Fuel: .*/(?<maxFuel>$SHORT_NUMBER_FORMAT)") - - data class PickaxeAbilityData( - val name: String, - val cooldown: Duration, - ) - - fun getCooldownFromLore(itemStack: ItemStack): PickaxeAbilityData? { - val lore = itemStack.loreAccordingToNbt - if (!lore.any { it.unformattedString.contains("Breaking Power") }) - return null - val cooldown = lore.firstNotNullOfOrNull { - cooldownPattern.useMatch(it.unformattedString) { - parseTimePattern(group("cooldown")) - } - } ?: return null - val name = lore.firstNotNullOfOrNull { - abilityPattern.useMatch(it.unformattedString) { - group("name") - } - } ?: return null - return PickaxeAbilityData(name, cooldown) - } - - - val cooldownPattern = Pattern.compile("Cooldown: (?<cooldown>$TIME_PATTERN)") - val abilityPattern = Pattern.compile("(⦾ )?Ability: (?<name>.*) {2}RIGHT CLICK") - val abilitySwitchPattern = - Pattern.compile("You selected (?<ability>.*) as your Pickaxe Ability\\. This ability will apply to all of your pickaxes!") - - - @Subscribe - fun renderHud(event: HudRenderEvent) { - if (!TConfig.cooldownEnabled) return - var ability = getCooldownFromLore(MC.player?.getStackInHand(Hand.MAIN_HAND) ?: return) ?: return - defaultAbilityDurations[ability.name] = ability.cooldown - val ao = abilityOverride - if (ao != ability.name && ao != null) { - ability = PickaxeAbilityData(ao, defaultAbilityDurations[ao] ?: 120.seconds) - } - event.context.matrices.push() - event.context.matrices.translate(MC.window.scaledWidth / 2F, MC.window.scaledHeight / 2F, 0F) - event.context.matrices.scale(TConfig.cooldownScale.toFloat(), TConfig.cooldownScale.toFloat(), 1F) - RenderCircleProgress.renderCircle( - event.context, Identifier.of("firmament", "textures/gui/circle.png"), - getCooldownPercentage(ability.name, ability.cooldown).toFloat(), - 0f, 1f, 0f, 1f - ) - event.context.matrices.pop() - } + val abilityUsePattern = Pattern.compile("You used your (?<name>.*) Pickaxe Ability!") + val fuelPattern = Pattern.compile("Fuel: .*/(?<maxFuel>$SHORT_NUMBER_FORMAT)") + val pickaxeAbilityCooldownPattern = Pattern.compile("Your pickaxe ability is on cooldown for (?<remainingCooldown>$TIME_PATTERN)\\.") + + data class PickaxeAbilityData( + val name: String, + val cooldown: Duration, + ) + + fun getCooldownFromLore(itemStack: ItemStack): PickaxeAbilityData? { + val lore = itemStack.loreAccordingToNbt + if (!lore.any { it.unformattedString.contains("Breaking Power") }) + return null + val ability = AbilityUtils.getAbilities(itemStack).firstOrNull() ?: return null + return PickaxeAbilityData(ability.name, ability.cooldown ?: return null) + } + + val cooldownPattern = Pattern.compile("Cooldown: (?<cooldown>$TIME_PATTERN)") + val abilitySwitchPattern = + Pattern.compile("You selected (?<ability>.*) as your Pickaxe Ability\\. This ability will apply to all of your pickaxes!") + + @Subscribe + fun renderHud(event: HudRenderEvent) { + if (!TConfig.cooldownEnabled) return + if (!event.isRenderingCursor) return + var ability = getCooldownFromLore(MC.player?.getStackInHand(Hand.MAIN_HAND) ?: return) ?: return + defaultAbilityDurations[ability.name] = ability.cooldown + val ao = abilityOverride + if (ao != ability.name && ao != null) { + ability = PickaxeAbilityData(ao, defaultAbilityDurations[ao] ?: 120.seconds) + } + event.context.matrices.push() + event.context.matrices.translate(MC.window.scaledWidth / 2F, MC.window.scaledHeight / 2F, 0F) + event.context.matrices.scale(TConfig.cooldownScale.toFloat(), TConfig.cooldownScale.toFloat(), 1F) + RenderCircleProgress.renderCircle( + event.context, Identifier.of("firmament", "textures/gui/circle.png"), + getCooldownPercentage(ability.name, ability.cooldown).toFloat(), + 0f, 1f, 0f, 1f + ) + event.context.matrices.pop() + } } diff --git a/src/main/kotlin/features/texturepack/NumberMatcher.kt b/src/main/kotlin/features/texturepack/NumberMatcher.kt index 7e6665f..e6f2d01 100644 --- a/src/main/kotlin/features/texturepack/NumberMatcher.kt +++ b/src/main/kotlin/features/texturepack/NumberMatcher.kt @@ -1,4 +1,3 @@ - package moe.nea.firmament.features.texturepack import com.google.gson.JsonElement @@ -6,120 +5,120 @@ import com.google.gson.JsonPrimitive import moe.nea.firmament.util.useMatch abstract class NumberMatcher { - abstract fun test(number: Number): Boolean - - - companion object { - fun parse(jsonElement: JsonElement): NumberMatcher? { - if (jsonElement is JsonPrimitive) { - if (jsonElement.isString) { - val string = jsonElement.asString - return parseRange(string) ?: parseOperator(string) - } - if (jsonElement.isNumber) { - val number = jsonElement.asNumber - val hasDecimals = (number.toString().contains(".")) - return MatchNumberExact(if (hasDecimals) number.toLong() else number.toDouble()) - } - } - return null - } - - private val intervalSpec = - "(?<beginningOpen>[\\[\\(])(?<beginning>[0-9.]+)?,(?<ending>[0-9.]+)?(?<endingOpen>[\\]\\)])" - .toPattern() - - fun parseRange(string: String): RangeMatcher? { - intervalSpec.useMatch<Nothing>(string) { - // Open in the set-theory sense, meaning does not include its end. - val beginningOpen = group("beginningOpen") == "(" - val endingOpen = group("endingOpen") == ")" - val beginning = group("beginning")?.toDouble() - val ending = group("ending")?.toDouble() - return RangeMatcher(beginning, !beginningOpen, ending, !endingOpen) - } - return null - } - - enum class Operator(val operator: String) { - LESS("<") { - override fun matches(comparisonResult: Int): Boolean { - return comparisonResult < 0 - } - }, - LESS_EQUALS("<=") { - override fun matches(comparisonResult: Int): Boolean { - return comparisonResult <= 0 - } - }, - GREATER(">") { - override fun matches(comparisonResult: Int): Boolean { - return comparisonResult > 0 - } - }, - GREATER_EQUALS(">=") { - override fun matches(comparisonResult: Int): Boolean { - return comparisonResult >= 0 - } - }, - ; - - abstract fun matches(comparisonResult: Int): Boolean - } - - private val operatorPattern = "(?<operator>${Operator.entries.joinToString("|") {it.operator}})(?<value>[0-9.]+)".toPattern() - - fun parseOperator(string: String): OperatorMatcher? { - operatorPattern.useMatch<Nothing>(string) { - val operatorName = group("operator") - val operator = Operator.entries.find { it.operator == operatorName }!! - val value = group("value").toDouble() - return OperatorMatcher(operator, value) - } - return null - } - - data class OperatorMatcher(val operator: Operator, val value: Double) : NumberMatcher() { - override fun test(number: Number): Boolean { - return operator.matches(number.toDouble().compareTo(value)) - } - } - - - data class MatchNumberExact(val number: Number) : NumberMatcher() { - override fun test(number: Number): Boolean { - return when (this.number) { - is Double -> number.toDouble() == this.number.toDouble() - else -> number.toLong() == this.number.toLong() - } - } - } - - data class RangeMatcher( - val beginning: Double?, - val beginningInclusive: Boolean, - val ending: Double?, - val endingInclusive: Boolean, - ) : NumberMatcher() { - override fun test(number: Number): Boolean { - val value = number.toDouble() - if (beginning != null) { - if (beginningInclusive) { - if (value < beginning) return false - } else { - if (value <= beginning) return false - } - } - if (ending != null) { - if (endingInclusive) { - if (value > ending) return false - } else { - if (value >= ending) return false - } - } - return true - } - } - } + abstract fun test(number: Number): Boolean + + + companion object { + fun parse(jsonElement: JsonElement): NumberMatcher? { + if (jsonElement is JsonPrimitive) { + if (jsonElement.isString) { + val string = jsonElement.asString + return parseRange(string) ?: parseOperator(string) + } + if (jsonElement.isNumber) { + val number = jsonElement.asNumber + val hasDecimals = (number.toString().contains(".")) + return MatchNumberExact(if (hasDecimals) number.toLong() else number.toDouble()) + } + } + return null + } + + private val intervalSpec = + "(?<beginningOpen>[\\[\\(])(?<beginning>[0-9.]+)?,(?<ending>[0-9.]+)?(?<endingOpen>[\\]\\)])" + .toPattern() + + fun parseRange(string: String): RangeMatcher? { + intervalSpec.useMatch<Nothing>(string) { + // Open in the set-theory sense, meaning does not include its end. + val beginningOpen = group("beginningOpen") == "(" + val endingOpen = group("endingOpen") == ")" + val beginning = group("beginning")?.toDouble() + val ending = group("ending")?.toDouble() + return RangeMatcher(beginning, !beginningOpen, ending, !endingOpen) + } + return null + } + + enum class Operator(val operator: String) { + LESS("<") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult < 0 + } + }, + LESS_EQUALS("<=") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult <= 0 + } + }, + GREATER(">") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult > 0 + } + }, + GREATER_EQUALS(">=") { + override fun matches(comparisonResult: Int): Boolean { + return comparisonResult >= 0 + } + }, + ; + + abstract fun matches(comparisonResult: Int): Boolean + } + + private val operatorPattern = + "(?<operator>${Operator.entries.joinToString("|") { it.operator }})(?<value>[0-9.]+)".toPattern() + + fun parseOperator(string: String): OperatorMatcher? { + return operatorPattern.useMatch(string) { + val operatorName = group("operator") + val operator = Operator.entries.find { it.operator == operatorName }!! + val value = group("value").toDouble() + OperatorMatcher(operator, value) + } + } + + data class OperatorMatcher(val operator: Operator, val value: Double) : NumberMatcher() { + override fun test(number: Number): Boolean { + return operator.matches(number.toDouble().compareTo(value)) + } + } + + + data class MatchNumberExact(val number: Number) : NumberMatcher() { + override fun test(number: Number): Boolean { + return when (this.number) { + is Double -> number.toDouble() == this.number.toDouble() + else -> number.toLong() == this.number.toLong() + } + } + } + + data class RangeMatcher( + val beginning: Double?, + val beginningInclusive: Boolean, + val ending: Double?, + val endingInclusive: Boolean, + ) : NumberMatcher() { + override fun test(number: Number): Boolean { + val value = number.toDouble() + if (beginning != null) { + if (beginningInclusive) { + if (value < beginning) return false + } else { + if (value <= beginning) return false + } + } + if (ending != null) { + if (endingInclusive) { + if (value > ending) return false + } else { + if (value >= ending) return false + } + } + return true + } + } + } } diff --git a/src/main/kotlin/util/ErrorUtil.kt b/src/main/kotlin/util/ErrorUtil.kt new file mode 100644 index 0000000..4f229af --- /dev/null +++ b/src/main/kotlin/util/ErrorUtil.kt @@ -0,0 +1,16 @@ +package moe.nea.firmament.util + +import moe.nea.firmament.Firmament + +object ErrorUtil { + var aggressiveErrors = run { + Thread.currentThread().stackTrace.any { it.className.startsWith("org.junit.") } || Firmament.DEBUG + } + + @Suppress("NOTHING_TO_INLINE") // Suppressed since i want the logger to not pick up the ErrorUtil stack-frame + inline fun softError(message: String) { + if (aggressiveErrors) error(message) + else Firmament.logger.error(message) + } + +} diff --git a/src/main/kotlin/util/MC.kt b/src/main/kotlin/util/MC.kt index db8eccb..09aa7aa 100644 --- a/src/main/kotlin/util/MC.kt +++ b/src/main/kotlin/util/MC.kt @@ -4,7 +4,9 @@ import io.github.moulberry.repo.data.Coordinate import java.util.concurrent.ConcurrentLinkedQueue import net.minecraft.client.MinecraftClient import net.minecraft.client.gui.screen.ingame.HandledScreen +import net.minecraft.client.option.GameOptions import net.minecraft.client.render.WorldRenderer +import net.minecraft.item.Item import net.minecraft.network.packet.c2s.play.CommandExecutionC2SPacket import net.minecraft.registry.BuiltinRegistries import net.minecraft.registry.RegistryKeys @@ -16,79 +18,82 @@ import moe.nea.firmament.events.TickEvent object MC { - private val messageQueue = ConcurrentLinkedQueue<Text>() + private val messageQueue = ConcurrentLinkedQueue<Text>() - init { - TickEvent.subscribe("MC:push") { - while (true) { - inGameHud.chatHud.addMessage(messageQueue.poll() ?: break) - } - while (true) { - (nextTickTodos.poll() ?: break).invoke() - } - } - } + init { + TickEvent.subscribe("MC:push") { + while (true) { + inGameHud.chatHud.addMessage(messageQueue.poll() ?: break) + } + while (true) { + (nextTickTodos.poll() ?: break).invoke() + } + } + } - fun sendChat(text: Text) { - if (instance.isOnThread) - inGameHud.chatHud.addMessage(text) - else - messageQueue.add(text) - } + fun sendChat(text: Text) { + if (instance.isOnThread) + inGameHud.chatHud.addMessage(text) + else + messageQueue.add(text) + } - fun sendServerCommand(command: String) { - val nh = player?.networkHandler ?: return - nh.sendPacket( - CommandExecutionC2SPacket( - command, - ) - ) - } + fun sendServerCommand(command: String) { + val nh = player?.networkHandler ?: return + nh.sendPacket( + CommandExecutionC2SPacket( + command, + ) + ) + } - fun sendServerChat(text: String) { - player?.networkHandler?.sendChatMessage(text) - } + fun sendServerChat(text: String) { + player?.networkHandler?.sendChatMessage(text) + } - fun sendCommand(command: String) { - player?.networkHandler?.sendCommand(command) - } + fun sendCommand(command: String) { + player?.networkHandler?.sendCommand(command) + } - fun onMainThread(block: () -> Unit) { - if (instance.isOnThread) - block() - else - instance.send(block) - } + fun onMainThread(block: () -> Unit) { + if (instance.isOnThread) + block() + else + instance.send(block) + } - private val nextTickTodos = ConcurrentLinkedQueue<() -> Unit>() - fun nextTick(function: () -> Unit) { - nextTickTodos.add(function) - } + private val nextTickTodos = ConcurrentLinkedQueue<() -> Unit>() + fun nextTick(function: () -> Unit) { + nextTickTodos.add(function) + } - inline val resourceManager get() = (instance.resourceManager as ReloadableResourceManagerImpl) - inline val worldRenderer: WorldRenderer get() = instance.worldRenderer - inline val networkHandler get() = player?.networkHandler - inline val instance get() = MinecraftClient.getInstance() - inline val keyboard get() = instance.keyboard - inline val textureManager get() = instance.textureManager - inline val inGameHud get() = instance.inGameHud - inline val font get() = instance.textRenderer - inline val soundManager get() = instance.soundManager - inline val player get() = instance.player - inline val camera get() = instance.cameraEntity - inline val guiAtlasManager get() = instance.guiAtlasManager - inline val world get() = instance.world - inline var screen - get() = instance.currentScreen - set(value) = instance.setScreen(value) - inline val handledScreen: HandledScreen<*>? get() = instance.currentScreen as? HandledScreen<*> - inline val window get() = instance.window - inline val currentRegistries: RegistryWrapper.WrapperLookup? get() = world?.registryManager - val defaultRegistries: RegistryWrapper.WrapperLookup = BuiltinRegistries.createWrapperLookup() - val defaultItems = defaultRegistries.getWrapperOrThrow(RegistryKeys.ITEM) + inline val resourceManager get() = (instance.resourceManager as ReloadableResourceManagerImpl) + inline val worldRenderer: WorldRenderer get() = instance.worldRenderer + inline val networkHandler get() = player?.networkHandler + inline val instance get() = MinecraftClient.getInstance() + inline val keyboard get() = instance.keyboard + inline val interactionManager get() = instance.interactionManager + inline val textureManager get() = instance.textureManager + inline val options get() = instance.options + inline val inGameHud get() = instance.inGameHud + inline val font get() = instance.textRenderer + inline val soundManager get() = instance.soundManager + inline val player get() = instance.player + inline val camera get() = instance.cameraEntity + inline val guiAtlasManager get() = instance.guiAtlasManager + inline val world get() = instance.world + inline var screen + get() = instance.currentScreen + set(value) = instance.setScreen(value) + inline val handledScreen: HandledScreen<*>? get() = instance.currentScreen as? HandledScreen<*> + inline val window get() = instance.window + inline val currentRegistries: RegistryWrapper.WrapperLookup? get() = world?.registryManager + val defaultRegistries: RegistryWrapper.WrapperLookup = BuiltinRegistries.createWrapperLookup() + inline val currentOrDefaultRegistries get() = currentRegistries ?: defaultRegistries + val defaultItems: RegistryWrapper.Impl<Item> = defaultRegistries.getWrapperOrThrow(RegistryKeys.ITEM) } val Coordinate.blockPos: BlockPos - get() = BlockPos(x, y, z) + get() = BlockPos(x, y, z) diff --git a/src/main/kotlin/util/TimeMark.kt b/src/main/kotlin/util/TimeMark.kt index 1264212..4a076ac 100644 --- a/src/main/kotlin/util/TimeMark.kt +++ b/src/main/kotlin/util/TimeMark.kt @@ -1,44 +1,52 @@ - - package moe.nea.firmament.util import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds class TimeMark private constructor(private val timeMark: Long) : Comparable<TimeMark> { - fun passedTime() = if (timeMark == 0L) Duration.INFINITE else (System.currentTimeMillis() - timeMark).milliseconds - - operator fun minus(other: TimeMark): Duration { - if (other.timeMark == timeMark) - return 0.milliseconds - if (other.timeMark == 0L) - return Duration.INFINITE - if (timeMark == 0L) - return -Duration.INFINITE - return (timeMark - other.timeMark).milliseconds - } - - companion object { - fun now() = TimeMark(System.currentTimeMillis()) - fun farPast() = TimeMark(0L) - fun ago(timeDelta: Duration): TimeMark { - if (timeDelta.isFinite()) { - return TimeMark(System.currentTimeMillis() - timeDelta.inWholeMilliseconds) - } - require(timeDelta.isPositive()) - return farPast() - } - } - - override fun hashCode(): Int { - return timeMark.hashCode() - } - - override fun equals(other: Any?): Boolean { - return other is TimeMark && other.timeMark == timeMark - } - - override fun compareTo(other: TimeMark): Int { - return this.timeMark.compareTo(other.timeMark) - } + fun passedTime() = + if (timeMark == 0L) Duration.INFINITE + else (System.currentTimeMillis() - timeMark).milliseconds + + fun passedAt(fakeNow: TimeMark) = + if (timeMark == 0L) Duration.INFINITE + else (fakeNow.timeMark - timeMark).milliseconds + + operator fun minus(other: TimeMark): Duration { + if (other.timeMark == timeMark) + return 0.milliseconds + if (other.timeMark == 0L) + return Duration.INFINITE + if (timeMark == 0L) + return -Duration.INFINITE + return (timeMark - other.timeMark).milliseconds + } + + companion object { + fun now() = TimeMark(System.currentTimeMillis()) + fun farPast() = TimeMark(0L) + fun ago(timeDelta: Duration): TimeMark { + if (timeDelta.isFinite()) { + return TimeMark(System.currentTimeMillis() - timeDelta.inWholeMilliseconds) + } + require(timeDelta.isPositive()) + return farPast() + } + } + + override fun hashCode(): Int { + return timeMark.hashCode() + } + + override fun equals(other: Any?): Boolean { + return other is TimeMark && other.timeMark == timeMark + } + + override fun toString(): String { + return "https://time.is/$timeMark" + } + + override fun compareTo(other: TimeMark): Int { + return this.timeMark.compareTo(other.timeMark) + } } diff --git a/src/main/kotlin/util/mc/SNbtFormatter.kt b/src/main/kotlin/util/mc/SNbtFormatter.kt new file mode 100644 index 0000000..e773927 --- /dev/null +++ b/src/main/kotlin/util/mc/SNbtFormatter.kt @@ -0,0 +1,138 @@ +package moe.nea.firmament.util.mc + +import net.minecraft.nbt.NbtByte +import net.minecraft.nbt.NbtByteArray +import net.minecraft.nbt.NbtCompound +import net.minecraft.nbt.NbtDouble +import net.minecraft.nbt.NbtElement +import net.minecraft.nbt.NbtEnd +import net.minecraft.nbt.NbtFloat +import net.minecraft.nbt.NbtInt +import net.minecraft.nbt.NbtIntArray +import net.minecraft.nbt.NbtList +import net.minecraft.nbt.NbtLong +import net.minecraft.nbt.NbtLongArray +import net.minecraft.nbt.NbtShort +import net.minecraft.nbt.NbtString +import net.minecraft.nbt.visitor.NbtElementVisitor + +class SNbtFormatter private constructor() : NbtElementVisitor { + private val result = StringBuilder() + private var indent = 0 + private fun writeIndent() { + result.append("\t".repeat(indent)) + } + + private fun pushIndent() { + indent++ + } + + private fun popIndent() { + indent-- + } + + fun apply(element: NbtElement): StringBuilder { + element.accept(this) + return result + } + + + override fun visitString(element: NbtString) { + result.append(NbtString.escape(element.asString())) + } + + override fun visitByte(element: NbtByte) { + result.append(element.numberValue()).append("b") + } + + override fun visitShort(element: NbtShort) { + result.append(element.shortValue()).append("s") + } + + override fun visitInt(element: NbtInt) { + result.append(element.intValue()) + } + + override fun visitLong(element: NbtLong) { + result.append(element.longValue()).append("L") + } + + override fun visitFloat(element: NbtFloat) { + result.append(element.floatValue()).append("f") + } + + override fun visitDouble(element: NbtDouble) { + result.append(element.doubleValue()).append("d") + } + + private fun visitArrayContents(array: List<NbtElement>) { + array.forEachIndexed { index, element -> + writeIndent() + element.accept(this) + if (array.size != index + 1) { + result.append(",") + } + result.append("\n") + } + } + + private fun writeArray(arrayTypeTag: String, array: List<NbtElement>) { + result.append("[").append(arrayTypeTag).append("\n") + pushIndent() + visitArrayContents(array) + popIndent() + writeIndent() + result.append("]") + + } + + override fun visitByteArray(element: NbtByteArray) { + writeArray("B;", element) + } + + override fun visitIntArray(element: NbtIntArray) { + writeArray("I;", element) + } + + override fun visitLongArray(element: NbtLongArray) { + writeArray("L;", element) + } + + override fun visitList(element: NbtList) { + writeArray("", element) + } + + override fun visitCompound(compound: NbtCompound) { + result.append("{\n") + pushIndent() + val keys = compound.keys.sorted() + keys.forEachIndexed { index, key -> + writeIndent() + val element = compound[key] ?: error("Key '$key' found but not present in compound: $compound") + val escapedName = if (key.matches(SIMPLE_NAME)) key else NbtString.escape(key) + result.append(escapedName).append(": ") + element.accept(this) + if (keys.size != index + 1) { + result.append(",") + } + result.append("\n") + } + popIndent() + writeIndent() + result.append("}") + } + + override fun visitEnd(element: NbtEnd) { + result.append("END") + } + + companion object { + fun prettify(nbt: NbtElement): String { + return SNbtFormatter().apply(nbt).toString() + } + + fun NbtElement.toPrettyString() = prettify(this) + + private val SIMPLE_NAME = "[A-Za-z0-9._+-]+".toRegex() + } +} diff --git a/src/main/kotlin/util/regex.kt b/src/main/kotlin/util/regex.kt index 78c90e8..a44435c 100644 --- a/src/main/kotlin/util/regex.kt +++ b/src/main/kotlin/util/regex.kt @@ -1,8 +1,14 @@ +@file:OptIn(ExperimentalTypeInference::class, ExperimentalContracts::class) + package moe.nea.firmament.util import java.util.regex.Matcher import java.util.regex.Pattern import org.intellij.lang.annotations.Language +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.experimental.ExperimentalTypeInference import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -10,10 +16,14 @@ import kotlin.time.Duration.Companion.seconds inline fun <T> String.ifMatches(regex: Regex, block: (MatchResult) -> T): T? = regex.matchEntire(this)?.let(block) -inline fun <T> Pattern.useMatch(string: String, block: Matcher.() -> T): T? = - matcher(string) +inline fun <T> Pattern.useMatch(string: String, block: Matcher.() -> T): T? { + contract { + callsInPlace(block, InvocationKind.AT_MOST_ONCE) + } + return matcher(string) .takeIf(Matcher::matches) ?.let(block) +} @Language("RegExp") val TIME_PATTERN = "[0-9]+[ms]" diff --git a/src/main/kotlin/util/skyblock/AbilityUtils.kt b/src/main/kotlin/util/skyblock/AbilityUtils.kt new file mode 100644 index 0000000..0f0adbe --- /dev/null +++ b/src/main/kotlin/util/skyblock/AbilityUtils.kt @@ -0,0 +1,138 @@ +package moe.nea.firmament.util.skyblock + +import kotlin.time.Duration +import net.minecraft.item.ItemStack +import net.minecraft.text.Text +import moe.nea.firmament.util.ErrorUtil +import moe.nea.firmament.util.directLiteralStringContent +import moe.nea.firmament.util.mc.loreAccordingToNbt +import moe.nea.firmament.util.parseShortNumber +import moe.nea.firmament.util.parseTimePattern +import moe.nea.firmament.util.unformattedString +import moe.nea.firmament.util.useMatch + +object AbilityUtils { + data class ItemAbility( + val name: String, + val hasPowerScroll: Boolean, + val activation: AbilityActivation, + val manaCost: Int?, + val descriptionLines: List<Text>, + val cooldown: Duration?, + ) + + @JvmInline + value class AbilityActivation( + val label: String + ) { + companion object { + val RIGHT_CLICK = AbilityActivation("RIGHT CLICK") + val SNEAK_RIGHT_CLICK = AbilityActivation("SNEAK RIGHT CLICK") + val SNEAK = AbilityActivation("SNEAK") + val EMPTY = AbilityActivation("") + fun of(text: String?): AbilityActivation { + val trimmed = text?.trim() + if (trimmed.isNullOrBlank()) + return EMPTY + return AbilityActivation(trimmed) + } + } + } + + private val abilityNameRegex = "Ability: (?<name>.*?) *".toPattern() + private fun findAbility(iterator: ListIterator<Text>): ItemAbility? { + if (!iterator.hasNext()) { + return null + } + val line = iterator.next() + // The actual information about abilities is stored in the siblings + if (line.directLiteralStringContent != "") return null + var powerScroll: Boolean = false // This should instead determine the power scroll based on text colour + var abilityName: String? = null + var activation: String? = null + var hasProcessedActivation = false + for (sibling in line.siblings) { + val directContent = sibling.directLiteralStringContent ?: continue + if (directContent == "⦾ ") { + powerScroll = true + continue + } + if (!hasProcessedActivation && abilityName != null) { + hasProcessedActivation = true + activation = directContent + continue + } + abilityNameRegex.useMatch<Nothing>(directContent) { + abilityName = group("name") + continue + } + if (abilityName != null) { + ErrorUtil.softError("Found abilityName $abilityName without finding but encountered unprocessable element in: $line") + } + return null + } + if (abilityName == null) return null + val descriptionLines = mutableListOf<Text>() + var manaCost: Int? = null + var cooldown: Duration? = null + while (iterator.hasNext()) { + val descriptionLine = iterator.next() + if (descriptionLine.unformattedString == "") break + var nextIsManaCost = false + var isSpecialLine = false + var nextIsDuration = false + for (sibling in descriptionLine.siblings) { + val directContent = sibling.directLiteralStringContent ?: continue + if ("Mana Cost: " == directContent) { // TODO: 'Soulflow Cost: ' support (or maybe a generic 'XXX Cost: ') + nextIsManaCost = true + isSpecialLine = true + continue + } + if ("Cooldown: " == directContent) { + nextIsDuration = true + isSpecialLine = true + continue + } + if (nextIsDuration) { + nextIsDuration = false + cooldown = parseTimePattern(directContent) + continue + } + if (nextIsManaCost) { + nextIsManaCost = false + manaCost = parseShortNumber(directContent).toInt() + continue + } + if (isSpecialLine) { + ErrorUtil.softError("Unknown special line segment: '$sibling' in '$descriptionLine'") + } + } + if (!isSpecialLine) { + descriptionLines.add(descriptionLine) + } + } + return ItemAbility( + abilityName, + powerScroll, + AbilityActivation.of(activation), + manaCost, + descriptionLines, + cooldown + ) + } + + fun getAbilities(lore: List<Text>): List<ItemAbility> { + val iterator = lore.listIterator() + val abilities = mutableListOf<ItemAbility>() + while (iterator.hasNext()) { + findAbility(iterator)?.let(abilities::add) + } + + return abilities + } + + fun getAbilities(itemStack: ItemStack): List<ItemAbility> { + return getAbilities(itemStack.loreAccordingToNbt) + } + +} diff --git a/src/main/kotlin/util/textutil.kt b/src/main/kotlin/util/textutil.kt index 36924a6..1cef5d4 100644 --- a/src/main/kotlin/util/textutil.kt +++ b/src/main/kotlin/util/textutil.kt @@ -90,6 +90,8 @@ fun CharSequence.removeColorCodes(keepNonColorCodes: Boolean = false): String { val Text.unformattedString: String get() = string.removeColorCodes() +val Text.directLiteralStringContent: String? get() = (this.content as? PlainTextContent)?.string() + fun Text.allSiblings(): List<Text> = listOf(this) + siblings.flatMap { it.allSiblings() } fun MutableText.withColor(formatting: Formatting) = this.styled { it.withColor(formatting).withItalic(false) } |