mirror of
https://gitlab.com/cable-mc/cobblemon.git
synced 2026-10-22 15:16:25 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ff2752d1 | ||
|
|
40ece7d051 | ||
|
|
9bf26a1bbf | ||
|
|
fca8500d47 | ||
|
|
b3be1358ea | ||
|
|
7efe86e574 | ||
|
|
0a3eee798d |
@@ -66,6 +66,8 @@ dependencies {
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
// Static GL mocks must not load a graphics driver on headless CI.
|
||||
systemProperty("org.lwjgl.opengl.explicitInit", "true")
|
||||
testLogging {
|
||||
setEvents(listOf("failed"))
|
||||
setExceptionFormat("full")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.mixin.client;
|
||||
|
||||
import com.cobblemon.mod.common.client.render.RenderAlphaScope;
|
||||
import net.minecraft.client.renderer.RenderStateShard;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(RenderStateShard.class)
|
||||
public abstract class RenderStateShardMixin {
|
||||
@Inject(method = "setupRenderState", at = @At("RETURN"))
|
||||
private void cobblemon$applyScopedAlpha(CallbackInfo ci) {
|
||||
if ((Object) this instanceof RenderType) RenderAlphaScope.afterMaterialSetup();
|
||||
}
|
||||
|
||||
@Inject(method = "clearRenderState", at = @At("RETURN"))
|
||||
private void cobblemon$restoreScopedAlpha(CallbackInfo ci) {
|
||||
if ((Object) this instanceof RenderType) RenderAlphaScope.afterMaterialClear();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import com.cobblemon.mod.common.api.habitats.HabitatPools
|
||||
import com.cobblemon.mod.common.api.interaction.RequestManager
|
||||
import com.cobblemon.mod.common.api.molang.MoLangLoadedFilesCache
|
||||
import com.cobblemon.mod.common.api.molang.ObjectValue
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiActionRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.api.permission.PermissionValidator
|
||||
import com.cobblemon.mod.common.api.pokeball.catching.calculators.CaptureCalculator
|
||||
import com.cobblemon.mod.common.api.pokeball.catching.calculators.CaptureCalculators
|
||||
@@ -69,6 +71,7 @@ import com.cobblemon.mod.common.api.storage.player.adapter.TMMoveNbtBackend
|
||||
import com.cobblemon.mod.common.api.storage.player.factory.CachedPlayerDataStoreFactory
|
||||
import com.cobblemon.mod.common.api.tags.CobblemonEntityTypeTags
|
||||
import com.cobblemon.mod.common.api.tags.CobblemonItemTags
|
||||
import com.cobblemon.mod.common.api.vending.VendingMachinePageHandler
|
||||
import com.cobblemon.mod.common.battles.BagItems
|
||||
import com.cobblemon.mod.common.battles.BattleFormat
|
||||
import com.cobblemon.mod.common.battles.BattleRegistry
|
||||
@@ -259,6 +262,7 @@ object Cobblemon {
|
||||
playerDataManager.onPlayerDisconnect(it.player)
|
||||
RequestManager.onLogoff(it.player)
|
||||
serverPlayerStructs.remove(it.player.uuid)
|
||||
MolangUiService.closeAllForPlayer(it.player.uuid)
|
||||
}
|
||||
PlatformEvents.PLAYER_DEATH.subscribe {
|
||||
PCLinkManager.removeLink(it.player.uuid)
|
||||
@@ -321,6 +325,11 @@ object Cobblemon {
|
||||
// Start up the data provider.
|
||||
CobblemonDataProvider.registerDefaults()
|
||||
|
||||
MolangUiActionRegistry.register(
|
||||
VendingMachinePageHandler.ACTION,
|
||||
VendingMachinePageHandler
|
||||
)
|
||||
|
||||
SHINY_ASPECT.register()
|
||||
GENDER_ASPECT.register()
|
||||
COSMETIC_SLOT_ASPECT.register()
|
||||
@@ -450,6 +459,7 @@ object Cobblemon {
|
||||
}
|
||||
|
||||
PlatformEvents.SERVER_STOPPED.subscribe {
|
||||
MolangUiService.closeAll()
|
||||
storage.unregisterAll(it.server.registryAccess())
|
||||
playerDataManager.saveAllStores()
|
||||
playerDataManager.saveExecutor.shutdown()
|
||||
|
||||
@@ -808,6 +808,9 @@ object CobblemonBlocks : PlatformRegistry<Registry<Block>, ResourceKey<Registry<
|
||||
@JvmField
|
||||
val DISPLAY_CASE = create("display_case", DisplayCaseBlock(BlockBehaviour.Properties.of().sound(CobblemonSounds.DISPLAY_CASE_SOUNDS).noOcclusion().pushReaction(PushReaction.BLOCK).mapColor(MapColor.STONE).strength(0.3F).requiresCorrectToolForDrops()))
|
||||
|
||||
@JvmField
|
||||
val VENDING_MACHINE = create("vending_machine", VendingMachineBlock(BlockBehaviour.Properties.of().mapColor(MapColor.METAL).sound(SoundType.METAL).pushReaction(PushReaction.BLOCK).strength(2F).requiresCorrectToolForDrops()))
|
||||
|
||||
// TODO after 1.7
|
||||
// @JvmField
|
||||
// val INCENSE_SWEET = create("incense_sweet", SweetIncenseBlock(BlockBehaviour.Properties.of().sound(CobblemonSounds.DISPLAY_CASE_SOUNDS).noOcclusion().pushReaction(PushReaction.BLOCK).mapColor(MapColor.STONE).strength(0.3F)))
|
||||
|
||||
@@ -77,5 +77,6 @@ object CobblemonCommands {
|
||||
// Possibly lock down registration if and only if under dev environment or running in an environment
|
||||
// with a certain system environment variable set
|
||||
CobblemonInfoCommand.register(dispatcher)
|
||||
MolangUiCommand.register(dispatcher)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +465,8 @@ object CobblemonItems : PlatformRegistry<Registry<Item>, ResourceKey<Registry<It
|
||||
val PASTURE = blockItem("pasture", CobblemonBlocks.PASTURE)
|
||||
@JvmField
|
||||
val DISPLAY_CASE = blockItem("display_case", CobblemonBlocks.DISPLAY_CASE)
|
||||
@JvmField
|
||||
val VENDING_MACHINE = blockItem("vending_machine", CobblemonBlocks.VENDING_MACHINE)
|
||||
// @JvmField
|
||||
// val INCENSE_SWEET = blockItem("incense_sweet", CobblemonBlocks.INCENSE_SWEET) // TODO after 1.7
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ package com.cobblemon.mod.common
|
||||
|
||||
|
||||
import com.cobblemon.mod.common.block.campfirepot.CookingPotMenu
|
||||
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
|
||||
import com.cobblemon.mod.common.block.tmmachine.TMMachineMenu
|
||||
import com.cobblemon.mod.common.platform.PlatformRegistry
|
||||
import net.minecraft.core.Registry
|
||||
@@ -22,9 +23,10 @@ object CobblemonMenuType : PlatformRegistry<Registry<MenuType<*>>, ResourceKey<R
|
||||
|
||||
val COOKING_POT = MenuType.register("cooking_pot_menu", ::CookingPotMenu)
|
||||
val TM_MACHINE = MenuType.register("tm_machine_menu", ::TMMachineMenu)
|
||||
val MOLANG_UI = MenuType.register("molang_ui", ::MolangContainerMenu)
|
||||
|
||||
override val registry: Registry<MenuType<*>>
|
||||
get() = BuiltInRegistries.MENU
|
||||
override val resourceKey: ResourceKey<Registry<MenuType<*>>>
|
||||
get() = Registries.MENU
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ import com.cobblemon.mod.common.client.net.effect.SpawnSnowstormParticleHandler
|
||||
import com.cobblemon.mod.common.client.net.gui.ExpGainedDataPacketHandler
|
||||
import com.cobblemon.mod.common.client.net.gui.InteractPokemonUIPacketHandler
|
||||
import com.cobblemon.mod.common.client.net.gui.PokedexUIPacketHandler
|
||||
import com.cobblemon.mod.common.client.net.molangui.MolangUiCloseHandler
|
||||
import com.cobblemon.mod.common.client.net.molangui.MolangUiCompatibilityRequestHandler
|
||||
import com.cobblemon.mod.common.client.net.molangui.MolangUiOpenHandler
|
||||
import com.cobblemon.mod.common.client.net.molangui.MolangUiUpdateHandler
|
||||
import com.cobblemon.mod.common.client.net.gui.SetActiveTMPacketHandler
|
||||
import com.cobblemon.mod.common.client.net.gui.SetTMMachineContainerDataPacketHandler
|
||||
import com.cobblemon.mod.common.client.net.gui.SummaryUIPacketHandler
|
||||
@@ -83,6 +87,10 @@ import com.cobblemon.mod.common.net.messages.client.callback.OpenPartyMoveCallba
|
||||
import com.cobblemon.mod.common.net.messages.client.cooking.SeasoningRegistrySyncPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.cooking.ToggleCookingPotLidPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.data.*
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiClosePacket
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiCompatibilityRequestPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiOpenPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiUpdatePacket
|
||||
import com.cobblemon.mod.common.net.messages.client.debug.RequestOpenRidingStatsDebugGUIPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.dialogue.DialogueClosedPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.dialogue.DialogueOpenedPacket
|
||||
@@ -180,6 +188,9 @@ import com.cobblemon.mod.common.net.messages.server.dialogue.EscapeDialoguePacke
|
||||
import com.cobblemon.mod.common.net.messages.server.dialogue.InputToDialoguePacket
|
||||
import com.cobblemon.mod.common.net.messages.server.habitat.SaveHabitatBlockSettingsPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.npc.SaveNPCPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiClosedPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiCompatibilityResponsePacket
|
||||
import com.cobblemon.mod.common.net.messages.server.orientation.ServerboundUpdateOrientationPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.pasture.PasturePokemonPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.pasture.SetPastureConflictPacket
|
||||
@@ -239,6 +250,9 @@ import com.cobblemon.mod.common.net.serverhandling.dialogue.InputToDialogueHandl
|
||||
import com.cobblemon.mod.common.net.serverhandling.evolution.AcceptEvolutionHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.habitat.SaveHabitatBlockSettingsHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.npc.SaveNPCHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.molangui.MolangUiActionHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.molangui.MolangUiClosedHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.molangui.MolangUiCompatibilityResponseHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.orientation.ServerboundUpdateOrientationHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.pasture.PasturePokemonHandler
|
||||
import com.cobblemon.mod.common.net.serverhandling.pasture.SetPastureConflictHandler
|
||||
@@ -445,6 +459,11 @@ object CobblemonNetwork {
|
||||
list.add(PacketRegisterInfo(RideSettingsSyncPacket.ID, RideSettingsSyncPacket::decode, RideSettingsSyncHandler))
|
||||
list.add(PacketRegisterInfo(TechnicalMachineRegistrySyncPacket.ID, TechnicalMachineRegistrySyncPacket::decode, DataRegistrySyncPacketHandler()))
|
||||
|
||||
list.add(PacketRegisterInfo(MolangUiOpenPacket.ID, MolangUiOpenPacket::decode, MolangUiOpenHandler))
|
||||
list.add(PacketRegisterInfo(MolangUiClosePacket.ID, MolangUiClosePacket::decode, MolangUiCloseHandler))
|
||||
list.add(PacketRegisterInfo(MolangUiUpdatePacket.ID, MolangUiUpdatePacket::decode, MolangUiUpdateHandler))
|
||||
list.add(PacketRegisterInfo(MolangUiCompatibilityRequestPacket.ID, MolangUiCompatibilityRequestPacket::decode, MolangUiCompatibilityRequestHandler))
|
||||
|
||||
// Effects
|
||||
list.add(PacketRegisterInfo(SpawnSnowstormParticlePacket.ID, SpawnSnowstormParticlePacket::decode, SpawnSnowstormParticleHandler))
|
||||
list.add(PacketRegisterInfo(SpawnSnowstormEntityParticlePacket.ID, SpawnSnowstormEntityParticlePacket::decode, SpawnSnowstormEntityParticleHandler))
|
||||
@@ -643,6 +662,10 @@ object CobblemonNetwork {
|
||||
list.add(PacketRegisterInfo(SetEntityBehaviourPacket.ID, SetEntityBehaviourPacket::decode, SetEntityBehaviourHandler))
|
||||
list.add(PacketRegisterInfo(DamageOnCollisionPacket.ID, DamageOnCollisionPacket::decode, DamageOnCollisionPacketHandler))
|
||||
|
||||
list.add(PacketRegisterInfo(MolangUiActionPacket.ID, MolangUiActionPacket::decode, MolangUiActionHandler))
|
||||
list.add(PacketRegisterInfo(MolangUiClosedPacket.ID, MolangUiClosedPacket::decode, MolangUiClosedHandler))
|
||||
list.add(PacketRegisterInfo(MolangUiCompatibilityResponsePacket.ID, MolangUiCompatibilityResponsePacket::decode, MolangUiCompatibilityResponseHandler))
|
||||
|
||||
// Habitat blocks
|
||||
list.add(PacketRegisterInfo(SaveHabitatBlockSettingsPacket.ID, SaveHabitatBlockSettingsPacket::decode, SaveHabitatBlockSettingsHandler))
|
||||
|
||||
@@ -656,4 +679,4 @@ object CobblemonNetwork {
|
||||
fun sendPacketToPlayer(player: ServerPlayer, packet: NetworkPacket<*>) {
|
||||
Cobblemon.implementation.networkManager.sendPacketToPlayer(player, packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -45,6 +45,11 @@ object GeneralMoLangFunctions {
|
||||
val functions: MutableMap<String, (MoParams) -> Any> = mutableMapOf()
|
||||
|
||||
init {
|
||||
functions["format"] = { params ->
|
||||
val value = params.get<MoValue>(0).asDouble()
|
||||
val decimals = params.get<MoValue>(1).asDouble().toInt().coerceIn(0, 10)
|
||||
StringValue(String.format("%.${decimals}f", value))
|
||||
}
|
||||
functions["print"] = { params ->
|
||||
val message = params.get<MoValue>(0).asString()
|
||||
LOGGER.info(message)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
enum class GuiMode {
|
||||
OVERLAY,
|
||||
SCREEN;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): GuiMode? = entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.scripting.CobblemonScripts
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object MolangUiActionRegistry {
|
||||
private val handlers = ConcurrentHashMap<ActionKey, MolangUiActionHandler>()
|
||||
|
||||
fun register(key: ActionKey, handler: MolangUiActionHandler) {
|
||||
check(handlers.putIfAbsent(key, handler) == null) { "Molang UI action '$key' is already registered" }
|
||||
}
|
||||
|
||||
fun unregister(key: ActionKey): Boolean = handlers.remove(key) != null
|
||||
|
||||
internal fun dispatch(
|
||||
session: MolangUiSession,
|
||||
action: ActionKey,
|
||||
parameters: Map<String, ParameterValue>
|
||||
) {
|
||||
val definition = session.contract.actions[action]
|
||||
?: throw IllegalArgumentException("undeclared action '${action.value}'")
|
||||
val registered = handlers[action]
|
||||
if (registered != null) {
|
||||
registered.handle(MolangUiActionContext(session, session.player, parameters))
|
||||
return
|
||||
}
|
||||
val scriptId = definition.handler
|
||||
?: throw IllegalStateException("action '${action.value}' has no registered handler")
|
||||
val script = CobblemonScripts.scripts[scriptId]
|
||||
?: throw IllegalStateException("Molang UI action script '$scriptId' was not loaded")
|
||||
val runtime = MolangUiServerRuntimeFactory.create(session)
|
||||
parameters.forEach { (name, value) -> runtime.environment.setSimpleVariable(name, value.asMoLangValue()) }
|
||||
script.resolve(runtime, emptyMap())
|
||||
}
|
||||
|
||||
internal fun executeOnOpen(session: MolangUiSession) {
|
||||
val scriptId = session.contract.onOpen ?: return
|
||||
val script = CobblemonScripts.scripts[scriptId]
|
||||
?: throw IllegalStateException("Molang UI on_open script '$scriptId' was not loaded")
|
||||
script.resolve(MolangUiServerRuntimeFactory.create(session), emptyMap())
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.cobblemon.mod.common.api.data.DataRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.container.CollectionConfig
|
||||
import com.cobblemon.mod.common.api.molangui.container.CollectionSource
|
||||
import com.cobblemon.mod.common.api.molangui.container.CloseItemsPolicy
|
||||
import com.cobblemon.mod.common.api.molangui.container.ItemFilterEntry
|
||||
import com.cobblemon.mod.common.api.molangui.container.ShiftClickRule
|
||||
import com.cobblemon.mod.common.api.molangui.container.SlotLayout
|
||||
import com.cobblemon.mod.common.api.reactive.SimpleObservable
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiCompatibilityRequestPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.server.packs.PackType
|
||||
import net.minecraft.server.packs.resources.ResourceManager
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object MolangUiContractRegistry : DataRegistry {
|
||||
override val id = cobblemonResource("molang_ui_contracts")
|
||||
override val type = PackType.SERVER_DATA
|
||||
override val observable = SimpleObservable<MolangUiContractRegistry>()
|
||||
private val contracts = linkedMapOf<ResourceLocation, MolangUiContract>()
|
||||
private val parseFailures = AtomicLong()
|
||||
private const val DIRECTORY = "molang_ui"
|
||||
|
||||
override fun reload(manager: ResourceManager) {
|
||||
val next = linkedMapOf<ResourceLocation, MolangUiContract>()
|
||||
manager.listResources(DIRECTORY) { it.path.endsWith(".json") }.forEach { (location, resource) ->
|
||||
val id = ResourceLocation.fromNamespaceAndPath(
|
||||
location.namespace,
|
||||
location.path.removePrefix("$DIRECTORY/").removeSuffix(".json")
|
||||
)
|
||||
try {
|
||||
val json = resource.openAsReader().use { JsonParser.parseReader(it).asJsonObject }
|
||||
next[id] = parse(id, json)
|
||||
} catch (error: Exception) {
|
||||
parseFailures.incrementAndGet()
|
||||
Cobblemon.LOGGER.error("Rejected Molang UI contract '$location': ${error.message}")
|
||||
}
|
||||
}
|
||||
require(next.size <= MolangUiPacketLimits.MAX_CONTRACTS) {
|
||||
"Molang UI contract count exceeds ${MolangUiPacketLimits.MAX_CONTRACTS}"
|
||||
}
|
||||
contracts.clear()
|
||||
contracts.putAll(next)
|
||||
Cobblemon.LOGGER.info("Loaded ${contracts.size} validated Molang UI contracts")
|
||||
observable.emit(this)
|
||||
}
|
||||
|
||||
override fun sync(player: ServerPlayer) {
|
||||
MolangUiCompatibilityRequestPacket(versions()).sendToPlayer(player)
|
||||
}
|
||||
|
||||
internal fun parse(
|
||||
id: ResourceLocation,
|
||||
json: JsonObject,
|
||||
itemExists: (ResourceLocation) -> Boolean = BuiltInRegistries.ITEM::containsKey
|
||||
): MolangUiContract {
|
||||
require(!json.has("blocks_input")) { "blocks_input is unsupported; input handling is determined by mode" }
|
||||
val formatVersion = json.get("format_version").requireUiInt("format_version")
|
||||
require(formatVersion == MOLANG_UI_FORMAT_VERSION) { "unsupported format_version $formatVersion" }
|
||||
val contractVersion = json.get("contract_version").requireUiString("contract_version")
|
||||
MolangUiPacketLimits.requireIdentifier(contractVersion, "contract_version")
|
||||
val mode = GuiMode.fromString(json.get("mode").requireUiString("mode"))
|
||||
?: throw IllegalArgumentException("unknown mode '${json.get("mode").asString}'")
|
||||
val parameters = parseParameters(json.getAsJsonObject("parameters"))
|
||||
val actions = parseActions(json.getAsJsonObject("actions"))
|
||||
val collections = parseCollections(json.getAsJsonObject("collections"), itemExists)
|
||||
val shiftRules = parseShiftRules(json.getAsJsonArray("shift_click_rules"), collections.keys)
|
||||
val closePolicy = json.get("on_close_items")?.requireUiString("on_close_items")?.let { raw ->
|
||||
CloseItemsPolicy.fromId(raw) ?: throw IllegalArgumentException("unknown on_close_items policy '$raw'")
|
||||
} ?: CloseItemsPolicy.RETURN
|
||||
return MolangUiContract(
|
||||
id, formatVersion, contractVersion, mode, parameters, actions,
|
||||
json.get("on_open")?.requireUiString("on_open")?.let { ResourceLocation.tryParse(it) ?: error("invalid on_open script id") },
|
||||
collections, shiftRules, closePolicy,
|
||||
json.get("exclusive")?.requireUiBoolean("exclusive") ?: false
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseParameters(json: JsonObject?): Map<String, ParameterDefinition> {
|
||||
require((json?.size() ?: 0) <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many parameters" }
|
||||
return json?.entrySet()?.associate { (name, element) ->
|
||||
MolangUiParameters.requireName(name)
|
||||
val obj = element.asJsonObject
|
||||
val type = ParameterType.fromId(obj.get("type").requireUiString("parameters.$name.type"))
|
||||
?: throw IllegalArgumentException("unknown type for parameter '$name'")
|
||||
val default = obj.get("default")?.let { parseDefault(type, it, "parameters.$name.default") }
|
||||
name to ParameterDefinition(type, obj.get("required")?.requireUiBoolean("parameters.$name.required") ?: (default == null), default)
|
||||
}?.toMap() ?: emptyMap()
|
||||
}
|
||||
|
||||
private fun parseDefault(type: ParameterType, element: JsonElement, path: String): ParameterDefault {
|
||||
if (element.isJsonObject && element.asJsonObject.has("molang")) {
|
||||
require(element.asJsonObject.size() == 1) { "expression '$path' contains unknown fields" }
|
||||
return ParameterDefault.Molang(element.asJsonObject.get("molang").requireUiString("$path.molang"))
|
||||
}
|
||||
val value = when (type) {
|
||||
ParameterType.STRING -> ParameterValue.StringValue(element.requireUiString(path))
|
||||
ParameterType.NUMBER -> ParameterValue.NumberValue(element.requireUiNumber(path))
|
||||
ParameterType.BOOLEAN -> ParameterValue.BooleanValue(element.requireUiBoolean(path))
|
||||
ParameterType.VEC3 -> {
|
||||
require(element.isJsonArray && element.asJsonArray.size() == 3) { "'$path' must be a three-number array" }
|
||||
ParameterValue.Vec3Value(
|
||||
element.asJsonArray[0].requireUiNumber("$path[0]"),
|
||||
element.asJsonArray[1].requireUiNumber("$path[1]"),
|
||||
element.asJsonArray[2].requireUiNumber("$path[2]")
|
||||
)
|
||||
}
|
||||
}
|
||||
MolangUiPacketLimits.requireValue(value.asWireValue(), path)
|
||||
return ParameterDefault.Literal(value)
|
||||
}
|
||||
|
||||
private fun parseActions(json: JsonObject?): Map<ActionKey, ActionDefinition> {
|
||||
val result = json?.entrySet()?.associate { (name, element) ->
|
||||
val key = ActionKey(name)
|
||||
val obj = element.asJsonObject
|
||||
val handler = obj.get("handler")?.requireUiString("actions.$name.handler")?.let { ResourceLocation.tryParse(it) ?: error("invalid handler for action '$name'") }
|
||||
val params = obj.getAsJsonObject("parameters")?.entrySet()?.associate { (paramName, typeElement) ->
|
||||
MolangUiPacketLimits.requireIdentifier(paramName, "action parameter name")
|
||||
paramName to (ParameterType.fromId(typeElement.requireUiString("actions.$name.parameters.$paramName"))
|
||||
?: throw IllegalArgumentException("unknown type for action parameter '$name.$paramName'"))
|
||||
}?.toMap() ?: emptyMap()
|
||||
require(params.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "action '$name' has too many parameters" }
|
||||
key to ActionDefinition(handler, params)
|
||||
}?.toMap() ?: emptyMap()
|
||||
require(result.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "contract has too many actions" }
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseCollections(
|
||||
json: JsonObject?,
|
||||
itemExists: (ResourceLocation) -> Boolean
|
||||
): Map<String, CollectionConfig> {
|
||||
require((json?.size() ?: 0) <= MolangUiPacketLimits.MAX_CONTRACTS) { "too many container collections" }
|
||||
return json?.entrySet()?.associate { (name, element) ->
|
||||
MolangUiPacketLimits.requireIdentifier(name, "collection name")
|
||||
val obj = element.asJsonObject
|
||||
val source = CollectionSource.fromId(obj.get("source")?.requireUiString("collections.$name.source") ?: "session")
|
||||
?: throw IllegalArgumentException("unknown source for collection '$name'")
|
||||
val filters = obj.getAsJsonArray("allowed_items")?.map { raw ->
|
||||
val filter = ItemFilterEntry.parse(raw.requireUiString("collections.$name.allowed_items"))
|
||||
?: throw IllegalArgumentException("invalid item filter '${raw.asString}' in '$name'")
|
||||
if (filter is ItemFilterEntry.Item) require(itemExists(filter.id)) {
|
||||
"unknown item '${filter.id}' in collection '$name'"
|
||||
}
|
||||
filter
|
||||
}?.toList() ?: emptyList()
|
||||
val layoutObj = obj.getAsJsonObject("layout") ?: throw IllegalArgumentException("collection '$name' is missing layout")
|
||||
val offset = layoutObj.pair("offset", 0, 0)
|
||||
val grid = layoutObj.pair("grid_dimensions", 1, 1)
|
||||
val slotSize = layoutObj.pair("slot_size", 18, 18)
|
||||
val spacing = layoutObj.pair("slot_spacing", 0, 0)
|
||||
val layout = SlotLayout(offset.first, offset.second, grid.first, grid.second, slotSize.first, slotSize.second, spacing.first, spacing.second)
|
||||
name to CollectionConfig(
|
||||
obj.get("size").requireUiInt("collections.$name.size"), filters,
|
||||
obj.get("take_only")?.requireUiBoolean("collections.$name.take_only") ?: false, source,
|
||||
obj.getAsJsonArray("slots")?.map { it.requireUiInt("collections.$name.slots") }?.toList(), layout
|
||||
)
|
||||
}?.toMap() ?: emptyMap()
|
||||
}
|
||||
|
||||
private fun parseShiftRules(array: JsonArray?, collections: Set<String>): List<ShiftClickRule> =
|
||||
array?.map { element ->
|
||||
val obj = element.asJsonObject
|
||||
val from = obj.stringList("from")
|
||||
val to = obj.stringList("to")
|
||||
val valid = collections + ShiftClickRule.PLAYER_SIDE_NAMES
|
||||
require(from.isNotEmpty() && to.isNotEmpty() && (from + to).all { it in valid }) { "shift-click rule references an unknown collection" }
|
||||
ShiftClickRule(from, to)
|
||||
}?.toList() ?: emptyList()
|
||||
|
||||
fun getContract(id: ResourceLocation): MolangUiContract? = contracts[id]
|
||||
fun getAllContracts(): Map<ResourceLocation, MolangUiContract> = contracts.toMap()
|
||||
fun versions(): Map<ResourceLocation, String> = contracts.mapValues { it.value.contractVersion }
|
||||
fun parseFailureCount(): Long = parseFailures.get()
|
||||
}
|
||||
|
||||
private fun JsonObject.pair(name: String, first: Int, second: Int): Pair<Int, Int> {
|
||||
val value = getAsJsonArray(name) ?: return first to second
|
||||
require(value.size() == 2) { "'$name' must have two entries" }
|
||||
return value[0].requireUiInt(name) to value[1].requireUiInt(name)
|
||||
}
|
||||
private fun JsonObject.stringList(name: String): List<String> = getAsJsonArray(name)?.map { it.requireUiString(name) }?.toList()
|
||||
?: throw IllegalArgumentException("missing array '$name'")
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
|
||||
data class MolangUiDefinition(
|
||||
val guiId: ResourceLocation,
|
||||
val formatVersion: Int,
|
||||
val contractVersion: String,
|
||||
val mode: GuiMode,
|
||||
val parameters: Map<String, ParameterType>,
|
||||
val actions: Set<ActionKey>,
|
||||
val exclusive: Boolean,
|
||||
val root: WidgetDefinition,
|
||||
val variables: Map<String, Double>
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object MolangUiDiagnostics {
|
||||
private val activeSessions = AtomicLong()
|
||||
private val rejectedActions = AtomicLong()
|
||||
private val compatibilityFailures = AtomicLong()
|
||||
|
||||
internal fun sessionOpened() { activeSessions.incrementAndGet() }
|
||||
internal fun sessionClosed() { activeSessions.updateAndGet { value -> (value - 1).coerceAtLeast(0) } }
|
||||
internal fun actionRejected() { rejectedActions.incrementAndGet() }
|
||||
internal fun compatibilityFailures(count: Long) { compatibilityFailures.addAndGet(count) }
|
||||
|
||||
fun snapshot() = Snapshot(activeSessions.get(), rejectedActions.get(), compatibilityFailures.get(), MolangUiContractRegistry.parseFailureCount())
|
||||
|
||||
data class Snapshot(val activeSessions: Long, val rejectedActions: Long, val compatibilityFailures: Long, val parseFailures: Long)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
|
||||
internal fun JsonElement?.requireUiString(path: String): String {
|
||||
require(this != null && isJsonPrimitive && asJsonPrimitive.isString) { "'$path' must be a string" }
|
||||
return asString
|
||||
}
|
||||
|
||||
internal fun JsonElement?.requireUiBoolean(path: String): Boolean {
|
||||
require(this != null && isJsonPrimitive && asJsonPrimitive.isBoolean) { "'$path' must be a boolean" }
|
||||
return asBoolean
|
||||
}
|
||||
|
||||
internal fun JsonElement?.requireUiNumber(path: String): Double {
|
||||
require(this != null && isJsonPrimitive && asJsonPrimitive.isNumber) { "'$path' must be a number" }
|
||||
return asDouble.also { require(it.isFinite()) { "'$path' must be finite" } }
|
||||
}
|
||||
|
||||
internal fun JsonElement?.requireUiInt(path: String): Int {
|
||||
require(this != null && isJsonPrimitive && asJsonPrimitive.isNumber) { "'$path' must be an integer" }
|
||||
return try {
|
||||
asBigDecimal.intValueExact()
|
||||
} catch (error: ArithmeticException) {
|
||||
throw IllegalArgumentException("'$path' must be an integer in the 32-bit range", error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.container.SlotLayout
|
||||
import com.cobblemon.mod.common.api.molangui.container.CollectionConfig
|
||||
import com.cobblemon.mod.common.api.molangui.container.CloseItemsPolicy
|
||||
import com.cobblemon.mod.common.api.molangui.container.ShiftClickRule
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import java.util.UUID
|
||||
|
||||
const val MOLANG_UI_FORMAT_VERSION = 1
|
||||
|
||||
enum class ParameterType(val id: String) {
|
||||
STRING("string"),
|
||||
NUMBER("number"),
|
||||
BOOLEAN("boolean"),
|
||||
VEC3("vec3");
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): ParameterType? = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ParameterValue {
|
||||
val type: ParameterType
|
||||
fun asWireValue(): String
|
||||
|
||||
data class StringValue(val value: String) : ParameterValue {
|
||||
override val type = ParameterType.STRING
|
||||
override fun asWireValue() = value
|
||||
}
|
||||
|
||||
data class NumberValue(val value: Double) : ParameterValue {
|
||||
init { require(value.isFinite()) { "number parameters must be finite" } }
|
||||
override val type = ParameterType.NUMBER
|
||||
override fun asWireValue() = value.toString()
|
||||
}
|
||||
|
||||
data class BooleanValue(val value: Boolean) : ParameterValue {
|
||||
override val type = ParameterType.BOOLEAN
|
||||
override fun asWireValue() = value.toString()
|
||||
}
|
||||
|
||||
data class Vec3Value(val x: Double, val y: Double, val z: Double) : ParameterValue {
|
||||
init { require(x.isFinite() && y.isFinite() && z.isFinite()) { "vec3 parameters must be finite" } }
|
||||
override val type = ParameterType.VEC3
|
||||
override fun asWireValue() = "$x,$y,$z"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(type: ParameterType, value: String): ParameterValue? = when (type) {
|
||||
ParameterType.STRING -> StringValue(value)
|
||||
ParameterType.NUMBER -> value.toDoubleOrNull()?.takeIf(Double::isFinite)?.let(::NumberValue)
|
||||
ParameterType.BOOLEAN -> value.toBooleanStrictOrNull()?.let(::BooleanValue)
|
||||
ParameterType.VEC3 -> value.split(',').takeIf { it.size == 3 }
|
||||
?.map { it.trim().toDoubleOrNull() }
|
||||
?.takeIf { it.all { part -> part != null && part.isFinite() } }
|
||||
?.let { Vec3Value(it[0]!!, it[1]!!, it[2]!!) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ParameterDefinition(
|
||||
val type: ParameterType,
|
||||
val required: Boolean = true,
|
||||
val default: ParameterDefault? = null
|
||||
)
|
||||
|
||||
sealed interface ParameterDefault {
|
||||
data class Literal(val value: ParameterValue) : ParameterDefault
|
||||
data class Molang(val expression: String) : ParameterDefault
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class ActionKey(val value: String) {
|
||||
init { require(value.isNotBlank() && value.length <= MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH) }
|
||||
}
|
||||
|
||||
data class ActionDefinition(
|
||||
val handler: ResourceLocation? = null,
|
||||
val parameters: Map<String, ParameterType> = emptyMap()
|
||||
)
|
||||
|
||||
data class MolangUiContract(
|
||||
val id: ResourceLocation,
|
||||
val formatVersion: Int,
|
||||
val contractVersion: String,
|
||||
val mode: GuiMode,
|
||||
val parameters: Map<String, ParameterDefinition>,
|
||||
val actions: Map<ActionKey, ActionDefinition>,
|
||||
val onOpen: ResourceLocation?,
|
||||
val collections: Map<String, CollectionConfig>,
|
||||
val shiftClickRules: List<ShiftClickRule>,
|
||||
val closeItemsPolicy: CloseItemsPolicy,
|
||||
val exclusive: Boolean
|
||||
) {
|
||||
init {
|
||||
require(mode == GuiMode.SCREEN || collections.isEmpty()) { "overlay views cannot have container collections" }
|
||||
SlotLayout.validateCollections(collections.mapValues { it.value.layout })
|
||||
}
|
||||
}
|
||||
|
||||
data class MolangUiOpenRequest(
|
||||
val uiId: ResourceLocation,
|
||||
val parameters: Map<String, ParameterValue> = emptyMap(),
|
||||
val blockPos: BlockPos? = null
|
||||
)
|
||||
|
||||
sealed interface MolangUiOpenResult {
|
||||
data class Opened(val session: MolangUiSessionHandle) : MolangUiOpenResult
|
||||
data class Rejected(val reason: RejectionReason, val detail: String) : MolangUiOpenResult
|
||||
}
|
||||
|
||||
enum class RejectionReason {
|
||||
UNKNOWN_UI,
|
||||
INCOMPATIBLE_CLIENT,
|
||||
INVALID_PARAMETERS,
|
||||
SESSION_LIMIT,
|
||||
INVALID_CONTAINER,
|
||||
INTERNAL_ERROR
|
||||
}
|
||||
|
||||
enum class SessionState { OPENING, ACTIVE, CLOSING, CLOSED }
|
||||
|
||||
interface MolangUiSessionHandle : AutoCloseable {
|
||||
val sessionId: UUID
|
||||
val uiId: ResourceLocation
|
||||
val state: SessionState
|
||||
fun updateParameters(parameters: Map<String, ParameterValue>): Boolean
|
||||
fun updateVariables(variables: Map<String, Double>): Boolean
|
||||
override fun close()
|
||||
}
|
||||
|
||||
data class MolangUiActionContext(
|
||||
val session: MolangUiSessionHandle,
|
||||
val player: ServerPlayer,
|
||||
val parameters: Map<String, ParameterValue>
|
||||
)
|
||||
|
||||
fun interface MolangUiActionHandler {
|
||||
fun handle(context: MolangUiActionContext)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
object MolangUiPacketLimits {
|
||||
const val MAX_CONTRACTS = 256
|
||||
const val MAX_ACTION_FIELDS = 32
|
||||
const val MAX_IDENTIFIER_LENGTH = 128
|
||||
const val MAX_VALUE_LENGTH = 4 * 1024
|
||||
const val MAX_PACKET_BYTES = 256 * 1024
|
||||
|
||||
fun requireIdentifier(value: String, label: String): String {
|
||||
require(value.isNotBlank() && value.length <= MAX_IDENTIFIER_LENGTH) {
|
||||
"$label must contain 1..$MAX_IDENTIFIER_LENGTH characters"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fun requireValue(value: String, label: String): String {
|
||||
require(value.length <= MAX_VALUE_LENGTH) { "$label exceeds $MAX_VALUE_LENGTH characters" }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.bedrockk.molang.runtime.value.MoValue
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
|
||||
internal object MolangUiParameters {
|
||||
val GENERAL_FUNCTIONS = setOf("format", "to_string", "to_number", "to_int", "is_number", "is_int", "is_blank")
|
||||
private val reserved = GENERAL_FUNCTIONS + setOf(
|
||||
"gui_id", "gui_time", "delta", "screen_width", "screen_height", "mouse_x", "mouse_y", "item", "translate", "event"
|
||||
)
|
||||
private val namePattern = Regex("[a-z_][a-z0-9_]*")
|
||||
|
||||
fun requireName(name: String) {
|
||||
MolangUiPacketLimits.requireIdentifier(name, "parameter name")
|
||||
require(namePattern.matches(name) && name !in reserved) { "invalid or reserved parameter name '$name'" }
|
||||
}
|
||||
|
||||
fun decode(types: Map<String, ParameterType>, values: Map<String, String>): Map<String, ParameterValue> {
|
||||
require(values.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many parameters" }
|
||||
return values.mapValues { (name, raw) ->
|
||||
requireName(name)
|
||||
val type = types[name] ?: throw IllegalArgumentException("undeclared parameter '$name'")
|
||||
MolangUiPacketLimits.requireValue(raw, name)
|
||||
ParameterValue.parse(type, raw) ?: throw IllegalArgumentException("invalid ${type.id} parameter '$name'")
|
||||
}
|
||||
}
|
||||
|
||||
fun validate(values: Map<String, ParameterValue>) {
|
||||
require(values.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many parameters" }
|
||||
values.forEach { (name, value) ->
|
||||
requireName(name)
|
||||
MolangUiPacketLimits.requireValue(value.asWireValue(), name)
|
||||
}
|
||||
}
|
||||
|
||||
fun requireVariableName(name: String): String {
|
||||
MolangUiPacketLimits.requireIdentifier(name, "variable name")
|
||||
return name.removePrefix("v.").also {
|
||||
require(namePattern.matches(it)) { "invalid variable '$name'" }
|
||||
}
|
||||
}
|
||||
|
||||
fun validateVariables(values: Map<String, Double>) {
|
||||
val names = mutableSetOf<String>()
|
||||
values.forEach { (name, value) ->
|
||||
val canonical = requireVariableName(name)
|
||||
require(names.add(canonical) && value.isFinite()) {
|
||||
"invalid or duplicate variable '$name'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ParameterValue.asMoLangValue(): MoValue = when (this) {
|
||||
is ParameterValue.StringValue -> StringValue(value)
|
||||
is ParameterValue.NumberValue -> DoubleValue(value)
|
||||
is ParameterValue.BooleanValue -> DoubleValue(value)
|
||||
is ParameterValue.Vec3Value -> StringValue(asWireValue())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateLibraryRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetTree
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.packs.resources.ResourceManager
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** Client asset registry. Server code must use [MolangUiContractRegistry]. */
|
||||
object MolangUiRegistry {
|
||||
private const val DIRECTORY = "molang_ui"
|
||||
private val definitions = linkedMapOf<ResourceLocation, MolangUiDefinition>()
|
||||
private val parseFailures = AtomicLong()
|
||||
|
||||
fun reload(manager: ResourceManager) {
|
||||
TemplateLibraryRegistry.reload(manager)
|
||||
val next = linkedMapOf<ResourceLocation, MolangUiDefinition>()
|
||||
manager.listResources(DIRECTORY) { it.path.endsWith(".json") && !it.path.contains("/templates/") }
|
||||
.forEach { (identifier, resource) ->
|
||||
val guiId = ResourceLocation.fromNamespaceAndPath(
|
||||
identifier.namespace,
|
||||
identifier.path.removePrefix("$DIRECTORY/").removeSuffix(".json")
|
||||
)
|
||||
try {
|
||||
val json = resource.openAsReader().use { JsonParser.parseReader(it).asJsonObject }
|
||||
next[guiId] = parse(guiId, json)
|
||||
} catch (error: Exception) {
|
||||
parseFailures.incrementAndGet()
|
||||
Cobblemon.LOGGER.error("Rejected Molang UI view '$identifier': ${error.message}")
|
||||
}
|
||||
}
|
||||
require(next.size <= MolangUiPacketLimits.MAX_CONTRACTS) { "too many Molang UI views" }
|
||||
definitions.clear()
|
||||
definitions.putAll(next)
|
||||
Cobblemon.LOGGER.info("Loaded ${definitions.size} validated Molang UI views")
|
||||
}
|
||||
|
||||
internal fun parse(guiId: ResourceLocation, json: JsonObject): MolangUiDefinition {
|
||||
require(!json.has("blocks_input")) { "blocks_input is unsupported; input handling is determined by mode" }
|
||||
val formatVersion = json.get("format_version").requireUiInt("format_version")
|
||||
require(formatVersion == MOLANG_UI_FORMAT_VERSION) { "unsupported format_version $formatVersion" }
|
||||
val contractVersion = json.get("contract_version").requireUiString("contract_version")
|
||||
MolangUiPacketLimits.requireIdentifier(contractVersion, "contract_version")
|
||||
val mode = GuiMode.fromString(json.get("mode").requireUiString("mode"))
|
||||
?: throw IllegalArgumentException("unknown mode '${json.get("mode").asString}'")
|
||||
val parameterJson = json.getAsJsonObject("parameters")
|
||||
require((parameterJson?.size() ?: 0) <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many parameters" }
|
||||
val parameters = parameterJson?.entrySet()?.associate { (name, value) ->
|
||||
MolangUiParameters.requireName(name)
|
||||
val type = ParameterType.fromId(value.requireUiString("parameters.$name"))
|
||||
?: throw IllegalArgumentException("unknown parameter type for '$name'")
|
||||
name to type
|
||||
}?.toMap() ?: emptyMap()
|
||||
val actions = json.getAsJsonArray("actions")?.map { ActionKey(it.requireUiString("actions")) }?.toSet() ?: emptySet()
|
||||
require(actions.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many declared actions" }
|
||||
val variables = json.getAsJsonObject("variables")?.entrySet()?.associate { (name, value) ->
|
||||
MolangUiPacketLimits.requireIdentifier(name, "variable name")
|
||||
name to value.requireUiNumber("variables.$name")
|
||||
}?.toMap() ?: emptyMap()
|
||||
MolangUiParameters.validateVariables(variables)
|
||||
require(!json.has("imports")) { "template imports are unsupported; use qualified template references" }
|
||||
val root = WidgetTree.parse(json)
|
||||
val referencedActions = linkedSetOf<ActionKey>().also { collectActions(root, it) }
|
||||
val undeclared = referencedActions - actions
|
||||
require(undeclared.isEmpty()) { "widgets reference undeclared actions: ${undeclared.joinToString { it.value }}" }
|
||||
return MolangUiDefinition(
|
||||
guiId, formatVersion, contractVersion, mode, parameters, actions,
|
||||
json.get("exclusive")?.requireUiBoolean("exclusive") ?: false,
|
||||
root, variables
|
||||
)
|
||||
}
|
||||
|
||||
private fun collectActions(node: WidgetDefinition, output: MutableSet<ActionKey>) {
|
||||
node.events.values.forEach { output += it.action }
|
||||
node.children.forEach { collectActions(it, output) }
|
||||
node.itemTemplate?.let { collectActions(it, output) }
|
||||
}
|
||||
|
||||
fun getDefinition(guiId: ResourceLocation): MolangUiDefinition? = definitions[guiId]
|
||||
fun getAllDefinitions(): Map<ResourceLocation, MolangUiDefinition> = definitions.toMap()
|
||||
fun getVersionMap(): Map<ResourceLocation, String> = definitions.mapValues { it.value.contractVersion }
|
||||
fun parseFailureCount(): Long = parseFailures.get()
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.bedrockk.molang.runtime.value.MoValue
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
import com.cobblemon.mod.common.api.molang.function.GeneralMoLangFunctions
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.world.item.Item
|
||||
import net.minecraft.world.item.ItemStack
|
||||
|
||||
/** Builds a server runtime with only UI-scoped capabilities. It never exposes q.player. */
|
||||
internal object MolangUiServerRuntimeFactory {
|
||||
fun create(session: MolangUiSession): MoLangRuntime = MoLangRuntime().also { runtime ->
|
||||
GeneralMoLangFunctions.functions.filterKeys { it in MolangUiParameters.GENERAL_FUNCTIONS }.forEach { (name, function) ->
|
||||
runtime.environment.query.addFunction(name) { function(it) }
|
||||
}
|
||||
runtime.environment.query.addFunction("parameter") { params ->
|
||||
session.getParameter(params.getString(0))?.asMoLangValue() ?: DoubleValue.ZERO
|
||||
}
|
||||
runtime.environment.query.addFunction("update_parameter") { params ->
|
||||
val name = params.getString(0)
|
||||
val raw = params.get<MoValue>(1).asString()
|
||||
val type = session.contract.parameters[name]?.type
|
||||
DoubleValue(type != null && ParameterValue.parse(type, raw)?.let { session.updateParameters(mapOf(name to it)) } == true)
|
||||
}
|
||||
runtime.environment.query.addFunction("update_variable") { params ->
|
||||
val value = params.getDouble(1)
|
||||
DoubleValue(value.isFinite() && session.updateVariables(mapOf(params.getString(0) to value)))
|
||||
}
|
||||
runtime.environment.query.addFunction("close_ui") {
|
||||
session.close()
|
||||
DoubleValue.ONE
|
||||
}
|
||||
runtime.environment.query.addFunction("count_item") { params ->
|
||||
val item = validatedItem(params.getString(0)) ?: return@addFunction DoubleValue.ZERO
|
||||
DoubleValue(session.player.inventory.items.filter { it.`is`(item) }.sumOf(ItemStack::getCount).toDouble())
|
||||
}
|
||||
runtime.environment.query.addFunction("remove_items") { params ->
|
||||
val item = validatedItem(params.getString(0)) ?: return@addFunction DoubleValue.ZERO
|
||||
val count = params.getDouble(1).asNonnegativeInt() ?: return@addFunction DoubleValue.ZERO
|
||||
DoubleValue(count > 0 && removeAtomically(session, item, count))
|
||||
}
|
||||
runtime.environment.query.addFunction("slot") { params ->
|
||||
val index = params.getDouble(1).asNonnegativeInt() ?: return@addFunction StringValue("")
|
||||
val stack = session.containerMenu?.getStack(params.getString(0), index)
|
||||
if (stack == null || stack.isEmpty) StringValue("") else StringValue(BuiltInRegistries.ITEM.getKey(stack.item).toString())
|
||||
}
|
||||
runtime.environment.query.addFunction("replace_slot") { params ->
|
||||
val itemId = params.getString(5)
|
||||
validatedItem(itemId) ?: return@addFunction DoubleValue.ZERO
|
||||
val inputIndex = params.getDouble(1).asNonnegativeInt() ?: return@addFunction DoubleValue.ZERO
|
||||
val consumeCount = params.getDouble(2).asNonnegativeInt() ?: return@addFunction DoubleValue.ZERO
|
||||
val outputIndex = params.getDouble(4).asNonnegativeInt() ?: return@addFunction DoubleValue.ZERO
|
||||
val outputCount = params.getDouble(6).asNonnegativeInt() ?: return@addFunction DoubleValue.ZERO
|
||||
val result = session.containerMenu?.replaceSlotAtomically(
|
||||
params.getString(0), inputIndex, consumeCount,
|
||||
params.getString(3), outputIndex, itemId, outputCount
|
||||
) ?: false
|
||||
DoubleValue(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validatedItem(raw: String) = ResourceLocation.tryParse(raw)
|
||||
?.takeIf(BuiltInRegistries.ITEM::containsKey)
|
||||
?.let(BuiltInRegistries.ITEM::get)
|
||||
|
||||
private fun Double.asNonnegativeInt(): Int? =
|
||||
takeIf { it.isFinite() && it in 0.0..Int.MAX_VALUE.toDouble() && it % 1.0 == 0.0 }?.toInt()
|
||||
|
||||
private fun removeAtomically(session: MolangUiSession, item: Item, count: Int): Boolean {
|
||||
val stacks = session.player.inventory.items.filter { it.`is`(item) }
|
||||
if (stacks.sumOf(ItemStack::getCount) < count) return false
|
||||
var remaining = count
|
||||
stacks.forEach { stack ->
|
||||
if (remaining == 0) return@forEach
|
||||
val removed = minOf(stack.count, remaining)
|
||||
stack.shrink(removed)
|
||||
remaining -= removed
|
||||
}
|
||||
session.player.inventory.setChanged()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.core.BlockPos
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.molangui.container.CollectionSource
|
||||
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
|
||||
import com.cobblemon.mod.common.api.molangui.container.ServerCollectionSetup
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiClosePacket
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiOpenPacket
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiUpdatePacket
|
||||
import com.cobblemon.mod.common.util.asExpressionLike
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.Container
|
||||
import net.minecraft.world.MenuProvider
|
||||
import net.minecraft.world.SimpleContainer
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import net.minecraft.world.entity.player.Player
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||
import java.util.UUID
|
||||
|
||||
internal class MolangUiSession(
|
||||
override val sessionId: UUID,
|
||||
override val uiId: ResourceLocation,
|
||||
val player: ServerPlayer,
|
||||
val contract: MolangUiContract,
|
||||
initialParameters: Map<String, ParameterValue>,
|
||||
val blockPos: BlockPos?
|
||||
) : MolangUiSessionHandle {
|
||||
override var state: SessionState = SessionState.OPENING
|
||||
private set
|
||||
private val parameters = initialParameters.toMutableMap()
|
||||
internal var containerMenu: MolangContainerMenu? = null
|
||||
private var clientWasNotified = false
|
||||
|
||||
override fun updateParameters(parameters: Map<String, ParameterValue>): Boolean {
|
||||
assertServerThread()
|
||||
if (state != SessionState.ACTIVE || !validateParameterUpdate(parameters)) return false
|
||||
MolangUiUpdatePacket(sessionId, parameters.mapValues { it.value.asWireValue() }).sendToPlayer(player)
|
||||
this.parameters.putAll(parameters)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun updateVariables(variables: Map<String, Double>): Boolean {
|
||||
assertServerThread()
|
||||
if (state != SessionState.ACTIVE || variables.size > MolangUiPacketLimits.MAX_ACTION_FIELDS) return false
|
||||
try {
|
||||
MolangUiParameters.validateVariables(variables)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
MolangUiUpdatePacket(sessionId, emptyMap(), variables).sendToPlayer(player)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun close() = closeInternal(notifyClient = true, closeMenu = true)
|
||||
|
||||
internal fun closeFromClient() = closeInternal(notifyClient = false, closeMenu = true)
|
||||
internal fun closeFromMenu() = closeInternal(notifyClient = true, closeMenu = false)
|
||||
|
||||
internal fun activate() {
|
||||
assertServerThread()
|
||||
check(state == SessionState.OPENING)
|
||||
state = SessionState.ACTIVE
|
||||
}
|
||||
|
||||
internal fun notifyOpened(hasContainer: Boolean) {
|
||||
check(state == SessionState.OPENING)
|
||||
clientWasNotified = true
|
||||
MolangUiOpenPacket(
|
||||
sessionId, uiId, contract.contractVersion, contract.mode,
|
||||
parameters.mapValues { it.value.asWireValue() }, contract.exclusive,
|
||||
hasContainer, contract.collections.mapValues { it.value.layout }
|
||||
).sendToPlayer(player)
|
||||
}
|
||||
|
||||
internal fun getParameter(name: String): ParameterValue? = parameters[name]
|
||||
|
||||
private fun validateParameterUpdate(values: Map<String, ParameterValue>): Boolean =
|
||||
values.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS && values.all { (name, value) ->
|
||||
contract.parameters[name]?.type == value.type && value.asWireValue().length <= MolangUiPacketLimits.MAX_VALUE_LENGTH
|
||||
}
|
||||
|
||||
private fun closeInternal(notifyClient: Boolean, closeMenu: Boolean) {
|
||||
assertServerThread()
|
||||
if (state == SessionState.CLOSING || state == SessionState.CLOSED) return
|
||||
state = SessionState.CLOSING
|
||||
val menu = containerMenu
|
||||
containerMenu = null
|
||||
try {
|
||||
cleanup("notify client") {
|
||||
if (notifyClient && clientWasNotified) MolangUiClosePacket(sessionId).sendToPlayer(player)
|
||||
}
|
||||
cleanup("close container") {
|
||||
if (closeMenu && menu != null && player.containerMenu === menu) player.closeContainer()
|
||||
}
|
||||
cleanup("release items") { menu?.releaseOwnedItemsOnce(player) }
|
||||
} finally {
|
||||
state = SessionState.CLOSED
|
||||
MolangUiService.remove(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun cleanup(operation: String, action: () -> Unit) {
|
||||
try {
|
||||
action()
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.error("Failed to $operation while closing Molang UI session $sessionId", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertServerThread() {
|
||||
check(player.server.isSameThread) { "Molang UI sessions are confined to the server thread" }
|
||||
}
|
||||
}
|
||||
|
||||
object MolangUiService {
|
||||
private const val MAX_OVERLAYS_PER_PLAYER = 8
|
||||
private val sessions = linkedMapOf<UUID, MolangUiSession>()
|
||||
private val disabled = mutableMapOf<UUID, Set<ResourceLocation>>()
|
||||
private val rateLimiters = mutableMapOf<UUID, MolangUiRateLimiter>()
|
||||
|
||||
fun open(player: ServerPlayer, request: MolangUiOpenRequest): MolangUiOpenResult {
|
||||
check(player.server.isSameThread) { "MolangUiService.open must run on the server thread" }
|
||||
val contract = MolangUiContractRegistry.getContract(request.uiId)
|
||||
?: return MolangUiOpenResult.Rejected(RejectionReason.UNKNOWN_UI, "Unknown Molang UI '${request.uiId}'")
|
||||
// Intentionally does not wait for the compatibility round-trip; the client re-validates on open.
|
||||
if (disabled[player.uuid]?.contains(request.uiId) == true) {
|
||||
return MolangUiOpenResult.Rejected(RejectionReason.INCOMPATIBLE_CLIENT, "Client view is missing or incompatible")
|
||||
}
|
||||
val parameters = resolveParameters(contract, request.parameters)
|
||||
?: return MolangUiOpenResult.Rejected(RejectionReason.INVALID_PARAMETERS, "Parameters do not match the contract")
|
||||
val playerSessions = sessions.values.filter { it.player.uuid == player.uuid && it.state != SessionState.CLOSED }
|
||||
if (contract.mode == GuiMode.OVERLAY && playerSessions.count { it.contract.mode == GuiMode.OVERLAY } >= MAX_OVERLAYS_PER_PLAYER) {
|
||||
return MolangUiOpenResult.Rejected(RejectionReason.SESSION_LIMIT, "At most $MAX_OVERLAYS_PER_PLAYER overlays may be active")
|
||||
}
|
||||
val collectionSetup = try {
|
||||
createCollections(player, contract, request.blockPos)
|
||||
?: return MolangUiOpenResult.Rejected(RejectionReason.INVALID_CONTAINER, "The bound block container is unavailable or invalid")
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.error("Failed to prepare Molang UI '${request.uiId}'", error)
|
||||
return MolangUiOpenResult.Rejected(RejectionReason.INTERNAL_ERROR, error.message ?: "Unable to prepare UI")
|
||||
}
|
||||
val session = MolangUiSession(UUID.randomUUID(), request.uiId, player, contract, parameters, request.blockPos)
|
||||
sessions[session.sessionId] = session
|
||||
MolangUiDiagnostics.sessionOpened()
|
||||
return try {
|
||||
if (contract.mode == GuiMode.SCREEN) {
|
||||
playerSessions.filter { it.contract.mode == GuiMode.SCREEN }.forEach(MolangUiSession::close)
|
||||
}
|
||||
check(session.state == SessionState.OPENING) { "Molang UI closed while replacing its previous screen" }
|
||||
MolangUiActionRegistry.executeOnOpen(session)
|
||||
check(session.state == SessionState.OPENING) { "Molang UI closed during on_open" }
|
||||
val hasContainer = contract.collections.isNotEmpty()
|
||||
session.notifyOpened(hasContainer)
|
||||
check(session.state == SessionState.OPENING) { "Molang UI closed while notifying the client" }
|
||||
if (hasContainer) openContainer(session, collectionSetup)
|
||||
session.activate()
|
||||
MolangUiOpenResult.Opened(session)
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.error("Failed to open Molang UI '${request.uiId}'", error)
|
||||
session.close()
|
||||
MolangUiOpenResult.Rejected(RejectionReason.INTERNAL_ERROR, error.message ?: "Unable to open UI")
|
||||
}
|
||||
}
|
||||
|
||||
fun handleAction(sessionId: UUID, player: ServerPlayer, actionRaw: String, raw: Map<String, String>) {
|
||||
check(player.server.isSameThread)
|
||||
val session = sessions[sessionId]
|
||||
if (session == null || session.player.uuid != player.uuid || session.state != SessionState.ACTIVE) {
|
||||
MolangUiDiagnostics.actionRejected()
|
||||
return
|
||||
}
|
||||
if (!rateLimiters.getOrPut(sessionId) { MolangUiRateLimiter() }.tryAcquire()) {
|
||||
MolangUiDiagnostics.actionRejected()
|
||||
return
|
||||
}
|
||||
val action = runCatching { ActionKey(actionRaw) }.getOrNull()
|
||||
val definition = action?.let(session.contract.actions::get)
|
||||
if (action == null || definition == null || raw.keys != definition.parameters.keys || raw.size > MolangUiPacketLimits.MAX_ACTION_FIELDS) {
|
||||
MolangUiDiagnostics.actionRejected()
|
||||
return
|
||||
}
|
||||
val typed = linkedMapOf<String, ParameterValue>()
|
||||
for ((name, type) in definition.parameters) {
|
||||
val value = raw[name]
|
||||
if (value == null || value.length > MolangUiPacketLimits.MAX_VALUE_LENGTH) {
|
||||
MolangUiDiagnostics.actionRejected(); return
|
||||
}
|
||||
typed[name] = ParameterValue.parse(type, value) ?: run { MolangUiDiagnostics.actionRejected(); return }
|
||||
}
|
||||
try {
|
||||
MolangUiActionRegistry.dispatch(session, action, typed)
|
||||
} catch (error: Exception) {
|
||||
MolangUiDiagnostics.actionRejected()
|
||||
Cobblemon.LOGGER.error("Molang UI action '${action.value}' failed in session $sessionId", error)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleClosed(sessionId: UUID, player: ServerPlayer) {
|
||||
val session = sessions[sessionId] ?: return
|
||||
if (session.player.uuid == player.uuid) session.closeFromClient() else MolangUiDiagnostics.actionRejected()
|
||||
}
|
||||
|
||||
fun applyCompatibility(player: ServerPlayer, clientVersions: Map<ResourceLocation, String>) {
|
||||
val incompatible = MolangUiContractRegistry.versions().filter { (id, version) -> clientVersions[id] != version }.keys
|
||||
if (incompatible.isEmpty()) disabled.remove(player.uuid) else disabled[player.uuid] = incompatible
|
||||
MolangUiDiagnostics.compatibilityFailures(incompatible.size.toLong())
|
||||
sessions.values.filter { it.player.uuid == player.uuid && it.uiId in incompatible }.toList().forEach(MolangUiSession::close)
|
||||
}
|
||||
|
||||
fun requestCompatibility(player: ServerPlayer) = MolangUiContractRegistry.sync(player)
|
||||
|
||||
fun closeAllForPlayer(playerId: UUID) {
|
||||
sessions.values.filter { it.player.uuid == playerId }.toList().forEach(MolangUiSession::close)
|
||||
disabled.remove(playerId)
|
||||
}
|
||||
|
||||
fun closeAll() {
|
||||
sessions.values.toList().forEach(MolangUiSession::close)
|
||||
disabled.clear()
|
||||
}
|
||||
|
||||
fun getSession(sessionId: UUID): MolangUiSessionHandle? = sessions[sessionId]
|
||||
fun getSessionForPlayer(player: ServerPlayer, id: ResourceLocation): MolangUiSessionHandle? =
|
||||
sessions.values.firstOrNull { it.player.uuid == player.uuid && it.uiId == id && it.state == SessionState.ACTIVE }
|
||||
fun activeSessionCount(): Int = sessions.size
|
||||
|
||||
internal fun remove(sessionId: UUID) {
|
||||
if (sessions.remove(sessionId) != null) MolangUiDiagnostics.sessionClosed()
|
||||
rateLimiters.remove(sessionId)
|
||||
}
|
||||
|
||||
private fun resolveParameters(contract: MolangUiContract, supplied: Map<String, ParameterValue>): Map<String, ParameterValue>? {
|
||||
if (supplied.keys.any { it !in contract.parameters }) return null
|
||||
val values = linkedMapOf<String, ParameterValue>()
|
||||
for ((name, definition) in contract.parameters) {
|
||||
val suppliedValue = supplied[name]
|
||||
val value = suppliedValue ?: when (val default = definition.default) {
|
||||
is ParameterDefault.Literal -> default.value
|
||||
is ParameterDefault.Molang -> runCatching {
|
||||
val result = default.expression.asExpressionLike().resolveString(MoLangRuntime())
|
||||
ParameterValue.parse(definition.type, result)
|
||||
}.getOrNull()
|
||||
null -> null
|
||||
}
|
||||
if (value == null) {
|
||||
if (definition.required) return null else continue
|
||||
}
|
||||
if (value.type != definition.type || value.asWireValue().length > MolangUiPacketLimits.MAX_VALUE_LENGTH) return null
|
||||
values[name] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
private fun createCollections(
|
||||
player: ServerPlayer,
|
||||
contract: MolangUiContract,
|
||||
blockPos: BlockPos?
|
||||
): List<ServerCollectionSetup>? {
|
||||
val setups = mutableListOf<ServerCollectionSetup>()
|
||||
for ((name, config) in contract.collections) {
|
||||
val container: Container = when (config.source) {
|
||||
CollectionSource.SESSION -> SimpleContainer(config.size)
|
||||
CollectionSource.BLOCK_ENTITY -> {
|
||||
val pos = blockPos ?: return null
|
||||
if (player.distanceToSqr(pos.x + .5, pos.y + .5, pos.z + .5) > 64.0 || !player.level().hasChunk(pos.x shr 4, pos.z shr 4)) return null
|
||||
val blockEntity = player.level().getBlockEntity(pos)
|
||||
if (blockEntity !is Container) return null
|
||||
if ((config.slots ?: (0 until config.size).toList()).any { it >= blockEntity.containerSize }) return null
|
||||
blockEntity
|
||||
}
|
||||
}
|
||||
setups += ServerCollectionSetup(name, config, container)
|
||||
}
|
||||
return setups
|
||||
}
|
||||
|
||||
private fun openContainer(session: MolangUiSession, setup: List<ServerCollectionSetup>) {
|
||||
val owned = setup.filter { it.config.source == CollectionSource.SESSION }.mapTo(mutableSetOf()) { it.name }
|
||||
val opened = session.player.openMenu(object : MenuProvider {
|
||||
override fun getDisplayName() = Component.empty()
|
||||
override fun createMenu(containerId: Int, inventory: Inventory, player: Player): AbstractContainerMenu =
|
||||
MolangContainerMenu(containerId, inventory).also { menu ->
|
||||
check(session.state == SessionState.OPENING) { "Molang UI closed before creating its container" }
|
||||
session.containerMenu = menu
|
||||
menu.initializeServer(
|
||||
setup, session.contract.shiftClickRules, owned, session.contract.closeItemsPolicy,
|
||||
owner = session.player, session = session, blockPos = session.blockPos
|
||||
)
|
||||
}
|
||||
})
|
||||
check(opened.isPresent) { "player rejected the Molang UI container menu" }
|
||||
}
|
||||
}
|
||||
|
||||
internal class MolangUiRateLimiter(private var lastNanos: Long = System.nanoTime()) {
|
||||
private var tokens = 40.0
|
||||
fun tryAcquire(now: Long = System.nanoTime()): Boolean {
|
||||
val elapsed = (now - lastNanos).coerceAtLeast(0) / 1_000_000_000.0
|
||||
tokens = minOf(40.0, tokens + elapsed * 20.0)
|
||||
lastNanos = now
|
||||
if (tokens < 1.0) return false
|
||||
tokens -= 1.0
|
||||
return true
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.container
|
||||
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.core.registries.Registries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.tags.TagKey
|
||||
import net.minecraft.world.item.ItemStack
|
||||
|
||||
data class CollectionConfig(
|
||||
val size: Int,
|
||||
val allowedItems: List<ItemFilterEntry> = emptyList(),
|
||||
val takeOnly: Boolean = false,
|
||||
val source: CollectionSource = CollectionSource.SESSION,
|
||||
val slots: List<Int>? = null,
|
||||
val layout: SlotLayout
|
||||
) {
|
||||
init {
|
||||
require(size in 1..MAX_COLLECTION_SIZE) { "collection size must be in 1..$MAX_COLLECTION_SIZE" }
|
||||
require(layout.slotCount == size) { "collection size must match its grid dimensions" }
|
||||
require(slots == null || slots.size == size) { "block collection slot mapping must match collection size" }
|
||||
require(slots == null || slots.distinct().size == slots.size) { "block collection slot mapping contains duplicates" }
|
||||
require(slots == null || slots.all { it >= 0 }) { "block collection slot indices must be nonnegative" }
|
||||
require(source == CollectionSource.BLOCK_ENTITY || slots == null) { "slot mapping is only valid for block_entity sources" }
|
||||
}
|
||||
|
||||
fun isItemAllowed(stack: ItemStack): Boolean {
|
||||
return !takeOnly && matchesFilter(stack)
|
||||
}
|
||||
|
||||
fun matchesFilter(stack: ItemStack): Boolean {
|
||||
if (allowedItems.isEmpty()) return true
|
||||
return allowedItems.any { it.matches(stack) }
|
||||
}
|
||||
|
||||
companion object { const val MAX_COLLECTION_SIZE = 256 }
|
||||
}
|
||||
|
||||
enum class CollectionSource(val id: String) {
|
||||
SESSION("session"), BLOCK_ENTITY("block_entity");
|
||||
companion object { fun fromId(id: String) = entries.firstOrNull { it.id == id } }
|
||||
}
|
||||
|
||||
data class ShiftClickRule(
|
||||
val from: List<String>,
|
||||
val to: List<String>
|
||||
) {
|
||||
companion object {
|
||||
const val PLAYER_INVENTORY = "player_inventory"
|
||||
const val PLAYER_HOTBAR = "player_hotbar"
|
||||
val PLAYER_SIDE_NAMES = listOf(PLAYER_INVENTORY, PLAYER_HOTBAR)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ItemFilterEntry {
|
||||
fun matches(stack: ItemStack): Boolean
|
||||
|
||||
data class Item(val id: ResourceLocation) : ItemFilterEntry {
|
||||
override fun matches(stack: ItemStack): Boolean {
|
||||
if (!BuiltInRegistries.ITEM.containsKey(id)) return false
|
||||
return stack.`is`(BuiltInRegistries.ITEM.get(id))
|
||||
}
|
||||
}
|
||||
|
||||
data class Tag(val id: ResourceLocation) : ItemFilterEntry {
|
||||
override fun matches(stack: ItemStack): Boolean = stack.`is`(TagKey.create(Registries.ITEM, id))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(value: String): ItemFilterEntry? {
|
||||
val trimmed = value.trim()
|
||||
val isTag = trimmed.startsWith('#')
|
||||
val id = ResourceLocation.tryParse(if (isTag) trimmed.substring(1) else trimmed) ?: return null
|
||||
return if (isTag) Tag(id) else Item(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class CloseItemsPolicy(val id: String) {
|
||||
DROP("drop"),
|
||||
RETURN("return"),
|
||||
DESTROY("destroy");
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): CloseItemsPolicy? = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.container
|
||||
|
||||
import net.minecraft.world.Container
|
||||
|
||||
data class ServerCollectionSetup(
|
||||
val name: String,
|
||||
val config: CollectionConfig,
|
||||
val container: Container
|
||||
)
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.container
|
||||
|
||||
import com.cobblemon.mod.common.CobblemonMenuType
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiSession
|
||||
import com.cobblemon.mod.common.api.molangui.SessionState
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.Container
|
||||
import net.minecraft.world.SimpleContainer
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import net.minecraft.world.entity.player.Player
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||
import net.minecraft.world.inventory.Slot
|
||||
import net.minecraft.world.item.ItemStack
|
||||
|
||||
private data class CollectionSlotRange(
|
||||
val name: String,
|
||||
val startIndex: Int,
|
||||
val endIndex: Int
|
||||
)
|
||||
|
||||
class MolangContainerMenu(
|
||||
containerId: Int,
|
||||
private val playerInventory: Inventory
|
||||
) : AbstractContainerMenu(CobblemonMenuType.MOLANG_UI, containerId) {
|
||||
|
||||
private val collections: MutableMap<String, Container> = mutableMapOf()
|
||||
|
||||
private val collectionRanges: MutableList<CollectionSlotRange> = mutableListOf()
|
||||
private var playerInventoryStart: Int = 0
|
||||
private var playerHotbarStart: Int = 0
|
||||
private var playerHotbarEnd: Int = 0
|
||||
|
||||
private var shiftClickRules: List<ShiftClickRule> = emptyList()
|
||||
|
||||
private val collectionConfigs: MutableMap<String, CollectionConfig> = mutableMapOf()
|
||||
private val sessionOwnedCollections: MutableSet<String> = mutableSetOf()
|
||||
private var closeItemsPolicy: CloseItemsPolicy = CloseItemsPolicy.RETURN
|
||||
private var owner: ServerPlayer? = null
|
||||
private var owningSession: MolangUiSession? = null
|
||||
private var blockPos: BlockPos? = null
|
||||
private var originalBlockContainer: Container? = null
|
||||
private var ownedItemsReleased = false
|
||||
|
||||
internal fun initializeServer(
|
||||
collectionSetup: List<ServerCollectionSetup>,
|
||||
shiftClickRules: List<ShiftClickRule> = emptyList(),
|
||||
sessionOwned: Set<String> = emptySet(),
|
||||
closeItemsPolicy: CloseItemsPolicy = CloseItemsPolicy.RETURN,
|
||||
owner: ServerPlayer,
|
||||
session: MolangUiSession,
|
||||
blockPos: BlockPos? = null
|
||||
) {
|
||||
this.shiftClickRules = shiftClickRules
|
||||
this.sessionOwnedCollections.addAll(sessionOwned)
|
||||
this.closeItemsPolicy = closeItemsPolicy
|
||||
this.owner = owner
|
||||
this.owningSession = session
|
||||
this.blockPos = blockPos
|
||||
|
||||
for (setup in collectionSetup) {
|
||||
val (name, config, container) = setup
|
||||
collections[name] = container
|
||||
if (config.source == CollectionSource.BLOCK_ENTITY) originalBlockContainer = container
|
||||
collectionConfigs[name] = config
|
||||
|
||||
val startIndex = slots.size
|
||||
addCollectionSlots(name, config, container)
|
||||
val endIndex = slots.size
|
||||
collectionRanges.add(CollectionSlotRange(name, startIndex, endIndex))
|
||||
}
|
||||
|
||||
addPlayerSlots()
|
||||
}
|
||||
|
||||
fun initializeClient(
|
||||
collectionLayouts: Map<String, SlotLayout>
|
||||
) {
|
||||
SlotLayout.validateCollections(collectionLayouts)
|
||||
for ((name, layout) in collectionLayouts) {
|
||||
val config = CollectionConfig(size = layout.slotCount, layout = layout)
|
||||
val container = SimpleContainer(layout.slotCount)
|
||||
collections[name] = container
|
||||
collectionConfigs[name] = config
|
||||
|
||||
val startIndex = slots.size
|
||||
addCollectionSlots(name, config, container)
|
||||
val endIndex = slots.size
|
||||
collectionRanges.add(CollectionSlotRange(name, startIndex, endIndex))
|
||||
}
|
||||
|
||||
addPlayerSlots()
|
||||
}
|
||||
|
||||
private fun addCollectionSlots(
|
||||
name: String,
|
||||
config: CollectionConfig,
|
||||
container: Container
|
||||
) {
|
||||
val layout = config.layout
|
||||
var containerIndex = 0
|
||||
for (row in 0 until layout.rows) {
|
||||
for (col in 0 until layout.columns) {
|
||||
val x = layout.offsetX + col * (layout.slotWidth + layout.spacingX)
|
||||
val y = layout.offsetY + row * (layout.slotHeight + layout.spacingY)
|
||||
val mappedIndex = config.slots?.getOrNull(containerIndex) ?: containerIndex
|
||||
require(mappedIndex in 0 until container.containerSize) { "collection '$name' maps outside its container" }
|
||||
addSlot(MolangSlot(
|
||||
container = container,
|
||||
index = mappedIndex,
|
||||
x = x,
|
||||
y = y,
|
||||
collectionConfig = config
|
||||
))
|
||||
containerIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPlayerSlots() {
|
||||
playerInventoryStart = slots.size
|
||||
|
||||
for (row in 0 until 3) {
|
||||
for (col in 0 until 9) {
|
||||
val inventoryIndex = 9 + row * 9 + col
|
||||
val x = 8 + col * 18
|
||||
val y = 84 + row * 18
|
||||
addSlot(Slot(playerInventory, inventoryIndex, x, y))
|
||||
}
|
||||
}
|
||||
|
||||
playerHotbarStart = slots.size
|
||||
|
||||
for (col in 0 until 9) {
|
||||
addSlot(Slot(playerInventory, col, 8 + col * 18, 142))
|
||||
}
|
||||
|
||||
playerHotbarEnd = slots.size
|
||||
}
|
||||
|
||||
override fun quickMoveStack(player: Player, index: Int): ItemStack {
|
||||
val slot = slots.getOrNull(index) ?: return ItemStack.EMPTY
|
||||
if (!slot.hasItem() || !slot.mayPickup(player)) return ItemStack.EMPTY
|
||||
|
||||
val slotItem = slot.item
|
||||
val original = slotItem.copy()
|
||||
|
||||
val clickedCollection = collectionRanges.firstOrNull { index >= it.startIndex && index < it.endIndex }
|
||||
val isPlayerInventory = index >= playerInventoryStart && index < playerInventoryEnd()
|
||||
val isHotbar = index >= playerHotbarStart && index < playerHotbarEnd
|
||||
|
||||
val sourceName = clickedCollection?.name ?: when {
|
||||
isPlayerInventory -> ShiftClickRule.PLAYER_INVENTORY
|
||||
isHotbar -> ShiftClickRule.PLAYER_HOTBAR
|
||||
else -> return ItemStack.EMPTY
|
||||
}
|
||||
val destinations = findShiftClickRule(sourceName)?.to ?: if (clickedCollection != null) {
|
||||
ShiftClickRule.PLAYER_SIDE_NAMES
|
||||
} else {
|
||||
collectionRanges.map { it.name }
|
||||
}
|
||||
if (!tryMoveToCollections(slotItem, slot, destinations)) return ItemStack.EMPTY
|
||||
|
||||
if (slotItem.isEmpty) {
|
||||
slot.setByPlayer(ItemStack.EMPTY)
|
||||
} else {
|
||||
slot.setChanged()
|
||||
}
|
||||
|
||||
if (slotItem.count == original.count) return ItemStack.EMPTY
|
||||
|
||||
slot.onTake(player, slotItem)
|
||||
return original
|
||||
}
|
||||
|
||||
override fun stillValid(player: Player): Boolean {
|
||||
val expectedOwner = owner ?: return true // client menu
|
||||
if (player.uuid != expectedOwner.uuid || owningSession?.state != SessionState.ACTIVE) return false
|
||||
val pos = blockPos ?: return true
|
||||
if (player.distanceToSqr(pos.x + .5, pos.y + .5, pos.z + .5) > 64.0) return false
|
||||
return player.level().getBlockEntity(pos) === originalBlockContainer
|
||||
}
|
||||
|
||||
override fun removed(player: Player) {
|
||||
try {
|
||||
super.removed(player)
|
||||
} finally {
|
||||
try {
|
||||
releaseOwnedItemsOnce(player)
|
||||
} finally {
|
||||
owningSession?.closeFromMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseOwnedItemsOnce(player: Player) {
|
||||
if (ownedItemsReleased) return
|
||||
ownedItemsReleased = true
|
||||
for (name in sessionOwnedCollections) {
|
||||
val container = collections[name] ?: continue
|
||||
when (closeItemsPolicy) {
|
||||
CloseItemsPolicy.DROP -> clearContainer(player, container)
|
||||
CloseItemsPolicy.RETURN -> returnItemsToPlayer(player, container)
|
||||
CloseItemsPolicy.DESTROY -> container.clearContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getStack(collection: String, index: Int): ItemStack? {
|
||||
return collectionSlot(collection, index)?.item
|
||||
}
|
||||
|
||||
fun replaceSlotAtomically(
|
||||
inputCollection: String,
|
||||
inputIndex: Int,
|
||||
consumeCount: Int,
|
||||
outputCollection: String,
|
||||
outputIndex: Int,
|
||||
outputItemId: String,
|
||||
outputCount: Int
|
||||
): Boolean {
|
||||
if (consumeCount <= 0 || outputCount <= 0) return false
|
||||
val input = collectionSlot(inputCollection, inputIndex) ?: return false
|
||||
val output = collectionSlot(outputCollection, outputIndex) ?: return false
|
||||
val id = ResourceLocation.tryParse(outputItemId)?.takeIf(BuiltInRegistries.ITEM::containsKey) ?: return false
|
||||
val produced = ItemStack(BuiltInRegistries.ITEM.get(id), outputCount)
|
||||
val outputConfig = collectionConfigs[outputCollection] ?: return false
|
||||
if (!outputConfig.matchesFilter(produced)) return false
|
||||
val source = input.item
|
||||
if (source.count < consumeCount) return false
|
||||
val remaining = source.copyWithCount(source.count - consumeCount)
|
||||
val sameSlot = input.container === output.container && input.containerSlot == output.containerSlot
|
||||
val existing = if (sameSlot) remaining else output.item
|
||||
if (!existing.isEmpty && !ItemStack.isSameItemSameComponents(existing, produced)) return false
|
||||
val limit = output.getMaxStackSize(produced)
|
||||
if (existing.count > limit || outputCount > limit - existing.count) return false
|
||||
val result = if (existing.isEmpty) produced else existing.copyWithCount(existing.count + outputCount)
|
||||
if (!sameSlot) input.set(remaining)
|
||||
output.set(result)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun playerInventoryEnd() = playerHotbarStart
|
||||
|
||||
private fun findShiftClickRule(fromCollection: String): ShiftClickRule? {
|
||||
return shiftClickRules.firstOrNull { rule ->
|
||||
rule.from.contains(fromCollection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectionSlot(name: String, index: Int): Slot? {
|
||||
val range = collectionRanges.firstOrNull { it.name == name } ?: return null
|
||||
if (index !in 0 until range.endIndex - range.startIndex) return null
|
||||
return slots[range.startIndex + index]
|
||||
}
|
||||
|
||||
private fun tryMoveToCollections(stack: ItemStack, source: Slot, toNames: List<String>): Boolean {
|
||||
val originalCount = stack.count
|
||||
for (name in toNames) {
|
||||
val indices = when (name) {
|
||||
ShiftClickRule.PLAYER_INVENTORY -> playerInventoryStart until playerHotbarStart
|
||||
ShiftClickRule.PLAYER_HOTBAR -> playerHotbarStart until playerHotbarEnd
|
||||
else -> collectionRanges.firstOrNull { it.name == name }?.let { it.startIndex until it.endIndex } ?: continue
|
||||
}
|
||||
for (merge in listOf(true, false)) {
|
||||
for (index in indices) {
|
||||
if (stack.isEmpty) return true
|
||||
val destination = slots[index]
|
||||
if (destination.container === source.container && destination.containerSlot == source.containerSlot) continue
|
||||
if (destination.hasItem() != merge || destination.item.count >= destination.getMaxStackSize(stack)) continue
|
||||
destination.safeInsert(stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
return stack.count < originalCount
|
||||
}
|
||||
|
||||
private fun returnItemsToPlayer(player: Player, container: Container) {
|
||||
val inventory = player.inventory
|
||||
for (i in 0 until container.containerSize) {
|
||||
val stack = container.getItem(i)
|
||||
if (!stack.isEmpty) {
|
||||
if (!inventory.add(stack)) {
|
||||
player.drop(stack, false)
|
||||
}
|
||||
container.setItem(i, ItemStack.EMPTY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.container
|
||||
|
||||
import net.minecraft.world.Container
|
||||
import net.minecraft.world.inventory.Slot
|
||||
import net.minecraft.world.item.ItemStack
|
||||
|
||||
class MolangSlot(
|
||||
container: Container,
|
||||
index: Int,
|
||||
x: Int,
|
||||
y: Int,
|
||||
private val collectionConfig: CollectionConfig
|
||||
) : Slot(container, index, x, y) {
|
||||
|
||||
override fun mayPlace(stack: ItemStack): Boolean {
|
||||
return collectionConfig.isItemAllowed(stack) && container.canPlaceItem(containerSlot, stack)
|
||||
}
|
||||
|
||||
override fun getMaxStackSize(stack: ItemStack): Int = container.getMaxStackSize(stack)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.container
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
|
||||
data class SlotLayout(
|
||||
val offsetX: Int,
|
||||
val offsetY: Int,
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
val slotWidth: Int = 18,
|
||||
val slotHeight: Int = 18,
|
||||
val spacingX: Int = 0,
|
||||
val spacingY: Int = 0
|
||||
) {
|
||||
init {
|
||||
require(columns in 1..MAX_GRID_DIMENSION && rows in 1..MAX_GRID_DIMENSION) { "slot grid dimensions must be in 1..$MAX_GRID_DIMENSION" }
|
||||
validateAxis(offsetX, columns, slotWidth, spacingX)
|
||||
validateAxis(offsetY, rows, slotHeight, spacingY)
|
||||
}
|
||||
|
||||
val slotCount: Int get() = columns * rows
|
||||
|
||||
private fun validateAxis(offset: Int, count: Int, size: Int, spacing: Int) {
|
||||
require(size > 0 && spacing >= 0) { "slot size must be positive and spacing nonnegative" }
|
||||
val stride = size.toLong() + spacing
|
||||
val lastSlotOffset = (count - 1) * stride
|
||||
require(stride <= Int.MAX_VALUE && lastSlotOffset <= Int.MAX_VALUE && offset + lastSlotOffset + size <= Int.MAX_VALUE) {
|
||||
"slot grid coordinates exceed the integer range"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_GRID_DIMENSION = 16
|
||||
|
||||
fun validateCollections(layouts: Map<String, SlotLayout>) {
|
||||
require(layouts.size <= MolangUiPacketLimits.MAX_CONTRACTS) { "too many container collections" }
|
||||
layouts.keys.forEach { name ->
|
||||
MolangUiPacketLimits.requireIdentifier(name, "collection name")
|
||||
require(name !in ShiftClickRule.PLAYER_SIDE_NAMES) { "collection name '$name' is reserved for player slots" }
|
||||
}
|
||||
// Native container packets encode slot indices as signed shorts.
|
||||
require(layouts.values.sumOf { it.slotCount.toLong() } + Inventory.INVENTORY_SIZE <= Short.MAX_VALUE + 1L) {
|
||||
"container slot indices exceed the native packet range"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.event
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.struct.QueryStruct
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetType
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
|
||||
import com.cobblemon.mod.common.util.asExpressionLike
|
||||
import java.util.UUID
|
||||
|
||||
object ClientEventProcessor {
|
||||
|
||||
fun processEvent(
|
||||
event: WidgetEvent,
|
||||
widget: WidgetDefinition,
|
||||
runtime: MoLangRuntime,
|
||||
sessionId: UUID
|
||||
) {
|
||||
val context = QueryStruct(hashMapOf())
|
||||
event.data.forEach { (key, value) -> context.addFunction(key) { DoubleValue(value) } }
|
||||
context.addFunction("type") { StringValue(event.type.id) }
|
||||
context.addFunction("widget_id") { StringValue(event.widgetId) }
|
||||
val functions = runtime.environment.query.functions
|
||||
val previous = functions.remove("event")
|
||||
runtime.environment.query.addFunction("event") { context }
|
||||
try {
|
||||
widget.clientEvents[event.type.clientId]?.asExpressionLike()?.resolve(runtime)
|
||||
widget.events[event.type.id]?.let { serverHandler ->
|
||||
// Action arguments may depend on variables changed by the client handler.
|
||||
val parameters = serverHandler.parameters.mapValues { (_, property) -> property.resolveString(runtime) }
|
||||
require(parameters.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many action parameters" }
|
||||
parameters.forEach { (name, value) ->
|
||||
MolangUiPacketLimits.requireIdentifier(name, "action parameter")
|
||||
MolangUiPacketLimits.requireValue(value, name)
|
||||
}
|
||||
MolangUiActionPacket(sessionId, serverHandler.action.value, parameters).sendToServer()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.warn("Failed UI event {} on widget {}: {}", event.type.id, event.widgetId, error.message)
|
||||
} finally {
|
||||
functions.remove("event")
|
||||
if (previous != null) functions["event"] = previous
|
||||
}
|
||||
}
|
||||
|
||||
fun hitTest(root: WidgetInstance, x: Float, y: Float): WidgetInstance? {
|
||||
if (!root.visible || !root.enabled) return null
|
||||
val isScrollPanel = root.definition.type == WidgetType.SCROLL_PANEL
|
||||
if (isScrollPanel && !root.containsPoint(x, y)) return null
|
||||
|
||||
for (child in root.children.asReversed()) {
|
||||
if (!child.visible) continue
|
||||
val testY = if (isScrollPanel) y - root.scrollOffset else y
|
||||
val hit = hitTest(child, x, testY)
|
||||
if (hit != null) return hit
|
||||
}
|
||||
return if (root.containsPoint(x, y)) root else null
|
||||
}
|
||||
}
|
||||
|
||||
enum class WidgetEventType(val id: String) {
|
||||
CLICK("on_click"),
|
||||
CHANGE("on_change"),
|
||||
HOVER_ENTER("on_hover_enter"),
|
||||
HOVER_EXIT("on_hover_exit"),
|
||||
SCROLL("on_scroll"),
|
||||
KEY("on_key");
|
||||
|
||||
val clientId: String get() = "${id}_client"
|
||||
}
|
||||
|
||||
data class WidgetEvent(
|
||||
val type: WidgetEventType,
|
||||
val widgetId: String,
|
||||
val data: Map<String, Double> = emptyMap()
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.layout
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
|
||||
object LayoutEngine {
|
||||
|
||||
fun layout(root: WidgetInstance, screenWidth: Float, screenHeight: Float, runtime: MoLangRuntime): Unit = root.withItemContext(runtime) {
|
||||
require(screenWidth.isFinite() && screenHeight.isFinite() && screenWidth >= 0f && screenHeight >= 0f) {
|
||||
"invalid screen dimensions"
|
||||
}
|
||||
if (!root.visible) return@withItemContext
|
||||
root.computedWidth = root.widthExpr.resolve(screenWidth, runtime)
|
||||
root.computedHeight = root.heightExpr.resolve(screenHeight, runtime)
|
||||
val anchor = root.definition.getAnchor()
|
||||
val offsetX = root.offsetXExpr.resolve(screenWidth, runtime)
|
||||
val offsetY = root.offsetYExpr.resolve(screenHeight, runtime)
|
||||
root.absoluteX = (screenWidth - root.computedWidth) * anchor.xFactor + offsetX
|
||||
root.absoluteY = (screenHeight - root.computedHeight) * anchor.yFactor + offsetY
|
||||
validateBounds(root)
|
||||
layoutChildren(root, runtime)
|
||||
}
|
||||
|
||||
private fun layoutChildren(parent: WidgetInstance, runtime: MoLangRuntime): Unit = parent.withItemContext(runtime) {
|
||||
val layout = parent.definition.getStringProperty("layout")
|
||||
val spacing = parent.definition.getProperty("spacing")?.resolveFloat(runtime) ?: 0f
|
||||
require(spacing.isFinite()) { "non-finite spacing at '${parent.definition.id}'" }
|
||||
val padding = parent.paddingProperties.map { it.resolveFloat(runtime) }
|
||||
require(padding.size == 4 && padding.all { it.isFinite() && it >= 0f }) {
|
||||
"invalid padding at '${parent.definition.id}'"
|
||||
}
|
||||
val innerWidth = (parent.computedWidth - padding[3] - padding[1]).coerceAtLeast(0f)
|
||||
val innerHeight = (parent.computedHeight - padding[0] - padding[2]).coerceAtLeast(0f)
|
||||
val innerX = parent.absoluteX + padding[3]
|
||||
val innerY = parent.absoluteY + padding[0]
|
||||
|
||||
for (child in parent.children) {
|
||||
if (!child.visible) continue
|
||||
child.computedWidth = child.withItemContext(runtime) { child.widthExpr.resolve(innerWidth, runtime) }
|
||||
child.computedHeight = child.withItemContext(runtime) { child.heightExpr.resolve(innerHeight, runtime) }
|
||||
}
|
||||
|
||||
when (layout) {
|
||||
"stack_vertical" -> layoutStackVertical(parent, innerX, innerY, innerWidth, innerHeight, spacing, runtime)
|
||||
"stack_horizontal" -> layoutStackHorizontal(parent, innerX, innerY, innerWidth, innerHeight, spacing, runtime)
|
||||
"grid" -> layoutGrid(parent, innerX, innerY, innerWidth, spacing, runtime)
|
||||
else -> layoutAnchored(parent, innerX, innerY, innerWidth, innerHeight, runtime)
|
||||
}
|
||||
|
||||
for (child in parent.children) {
|
||||
if (!child.visible) continue
|
||||
validateBounds(child)
|
||||
layoutChildren(child, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutAnchored(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, runtime: MoLangRuntime) {
|
||||
for (child in parent.children) {
|
||||
if (!child.visible) continue
|
||||
val anchor = child.definition.getAnchor()
|
||||
val offsetX = child.withItemContext(runtime) { child.offsetXExpr.resolve(innerWidth, runtime) }
|
||||
val offsetY = child.withItemContext(runtime) { child.offsetYExpr.resolve(innerHeight, runtime) }
|
||||
child.absoluteX = innerX + (innerWidth - child.computedWidth) * anchor.xFactor + offsetX
|
||||
child.absoluteY = innerY + (innerHeight - child.computedHeight) * anchor.yFactor + offsetY
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutStackVertical(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, spacing: Float, runtime: MoLangRuntime) {
|
||||
val visibleChildren = parent.children.filter { it.visible }
|
||||
resolveFillSizes(visibleChildren, innerHeight, spacing, isVertical = true)
|
||||
var currentY = innerY
|
||||
for (child in visibleChildren) {
|
||||
child.absoluteX = innerX + child.withItemContext(runtime) { child.offsetXExpr.resolve(innerWidth, runtime) }
|
||||
child.absoluteY = currentY + child.withItemContext(runtime) { child.offsetYExpr.resolve(innerHeight, runtime) }
|
||||
currentY += child.computedHeight + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutStackHorizontal(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, spacing: Float, runtime: MoLangRuntime) {
|
||||
val visibleChildren = parent.children.filter { it.visible }
|
||||
resolveFillSizes(visibleChildren, innerWidth, spacing, isVertical = false)
|
||||
var currentX = innerX
|
||||
for (child in visibleChildren) {
|
||||
child.absoluteX = currentX + child.withItemContext(runtime) { child.offsetXExpr.resolve(innerWidth, runtime) }
|
||||
child.absoluteY = innerY + child.withItemContext(runtime) { child.offsetYExpr.resolve(innerHeight, runtime) }
|
||||
currentX += child.computedWidth + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutGrid(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, spacing: Float, runtime: MoLangRuntime) {
|
||||
val columns = (parent.definition.getProperty("columns")?.resolveInt(runtime) ?: 1).coerceIn(1, 256)
|
||||
val visibleChildren = parent.children.filter { it.visible }
|
||||
val cellWidth = ((innerWidth - spacing * (columns - 1)) / columns).coerceAtLeast(0f)
|
||||
var col = 0
|
||||
var currentY = innerY
|
||||
var rowHeight = 0f
|
||||
for (child in visibleChildren) {
|
||||
if (child.widthExpr is SizeExpression.Fill) child.computedWidth = cellWidth
|
||||
child.absoluteX = innerX + col * (cellWidth + spacing)
|
||||
child.absoluteY = currentY
|
||||
rowHeight = maxOf(rowHeight, child.computedHeight)
|
||||
col++
|
||||
if (col >= columns) {
|
||||
col = 0
|
||||
currentY += rowHeight + spacing
|
||||
rowHeight = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveFillSizes(children: List<WidgetInstance>, totalSpace: Float, spacing: Float, isVertical: Boolean) {
|
||||
val totalSpacing = spacing * (children.size - 1).coerceAtLeast(0)
|
||||
var usedSpace = totalSpacing
|
||||
var fillCount = 0
|
||||
for (child in children) {
|
||||
val expr = if (isVertical) child.heightExpr else child.widthExpr
|
||||
if (expr is SizeExpression.Fill) {
|
||||
fillCount++
|
||||
} else {
|
||||
usedSpace += if (isVertical) child.computedHeight else child.computedWidth
|
||||
}
|
||||
}
|
||||
if (fillCount > 0) {
|
||||
val fillSize = ((totalSpace - usedSpace) / fillCount).coerceAtLeast(0f)
|
||||
for (child in children) {
|
||||
val expr = if (isVertical) child.heightExpr else child.widthExpr
|
||||
if (expr is SizeExpression.Fill) {
|
||||
if (isVertical) child.computedHeight = fillSize else child.computedWidth = fillSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateBounds(widget: WidgetInstance) {
|
||||
fun validAxis(position: Float, size: Float): Boolean =
|
||||
position.isFinite() && size.isFinite() && size >= 0f && size.toDouble() <= Int.MAX_VALUE &&
|
||||
position.toDouble() >= Int.MIN_VALUE && position.toDouble() + size.toDouble() <= Int.MAX_VALUE
|
||||
|
||||
require(validAxis(widget.absoluteX, widget.computedWidth) && validAxis(widget.absoluteY, widget.computedHeight)) {
|
||||
"widget '${widget.definition.id}' has invalid or overflowing bounds"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.layout
|
||||
|
||||
import com.cobblemon.mod.common.api.molang.ExpressionLike
|
||||
import com.cobblemon.mod.common.util.asExpressionLike
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetExpression
|
||||
|
||||
sealed class SizeExpression {
|
||||
abstract fun resolve(parentSize: Float, runtime: MoLangRuntime): Float
|
||||
|
||||
data class Pixels(val value: Float) : SizeExpression() {
|
||||
init { require(value.isFinite()) { "pixel size must be finite" } }
|
||||
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = value
|
||||
}
|
||||
|
||||
data class Percent(val fraction: Float) : SizeExpression() {
|
||||
init { require(fraction.isFinite()) { "percentage must be finite" } }
|
||||
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = parentSize * fraction
|
||||
}
|
||||
|
||||
data class PercentWithOffset(val fraction: Float, val offset: Float) : SizeExpression() {
|
||||
init { require(fraction.isFinite() && offset.isFinite()) { "percentage and offset must be finite" } }
|
||||
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = parentSize * fraction + offset
|
||||
}
|
||||
|
||||
data object Fill : SizeExpression() {
|
||||
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = parentSize
|
||||
}
|
||||
|
||||
data class MolangSize(val expression: ExpressionLike) : SizeExpression() {
|
||||
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = expression.resolveFloat(runtime)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PERCENT_PATTERN = Regex("""^(\d+(?:\.\d+)?)%$""")
|
||||
private val PERCENT_OFFSET_PATTERN = Regex("""^(\d+(?:\.\d+)?)%\s*([+-])\s*(\d+(?:\.\d+)?)$""")
|
||||
|
||||
fun parse(value: Any): SizeExpression {
|
||||
return when (value) {
|
||||
is Number -> Pixels(value.toFloat())
|
||||
is String -> parseString(value)
|
||||
is WidgetExpression -> MolangSize(value.source.asExpressionLike())
|
||||
else -> throw IllegalArgumentException("size must be a number, percentage, fill, or explicit Molang expression")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseString(value: String): SizeExpression {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed == "fill" -> Fill
|
||||
trimmed.toFloatOrNull() != null -> Pixels(trimmed.toFloat())
|
||||
PERCENT_PATTERN.matches(trimmed) -> {
|
||||
val pct = PERCENT_PATTERN.matchEntire(trimmed)!!.groupValues[1].toFloat()
|
||||
Percent(pct / 100f)
|
||||
}
|
||||
PERCENT_OFFSET_PATTERN.matches(trimmed) -> {
|
||||
val match = PERCENT_OFFSET_PATTERN.matchEntire(trimmed)!!
|
||||
val pct = match.groupValues[1].toFloat() / 100f
|
||||
val sign = if (match.groupValues[2] == "+") 1f else -1f
|
||||
val offset = match.groupValues[3].toFloat() * sign
|
||||
PercentWithOffset(pct, offset)
|
||||
}
|
||||
else -> throw IllegalArgumentException("invalid size '$value'; Molang requires an explicit expression object")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.template
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonElement
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.packs.resources.ResourceManager
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
|
||||
|
||||
object TemplateLibraryRegistry {
|
||||
private val gson = Gson()
|
||||
private val libraries: MutableMap<ResourceLocation, Map<String, TemplateDefinition>> = mutableMapOf()
|
||||
|
||||
private const val DIRECTORY = "molang_ui/templates"
|
||||
|
||||
fun reload(manager: ResourceManager) {
|
||||
val next = mutableMapOf<ResourceLocation, Map<String, TemplateDefinition>>()
|
||||
val resources = manager.listResources(DIRECTORY) { it.path.endsWith(".json") }
|
||||
for ((location, resource) in resources) {
|
||||
try {
|
||||
val json = resource.openAsReader().use { gson.fromJson(it, JsonObject::class.java) }
|
||||
val namespace = location.namespace
|
||||
val name = location.path.removePrefix("$DIRECTORY/").removeSuffix(".json")
|
||||
val libraryId = ResourceLocation.fromNamespaceAndPath(namespace, name)
|
||||
val templates = parseTemplates(json, namespace)
|
||||
next[libraryId] = templates
|
||||
} catch (e: Exception) {
|
||||
Cobblemon.LOGGER.error("Rejected Molang UI template library '$location': ${e.message}")
|
||||
}
|
||||
}
|
||||
libraries.clear()
|
||||
libraries.putAll(next)
|
||||
}
|
||||
|
||||
fun getTemplate(qualifiedName: String): TemplateDefinition? {
|
||||
val dotIndex = qualifiedName.lastIndexOf('.')
|
||||
if (dotIndex == -1) return null
|
||||
val libraryName = qualifiedName.substring(0, dotIndex)
|
||||
val templateName = qualifiedName.substring(dotIndex + 1)
|
||||
val library = if (':' in libraryName) {
|
||||
ResourceLocation.tryParse(libraryName)?.let(libraries::get)
|
||||
} else {
|
||||
libraries.entries.singleOrNull { it.key.path == libraryName }?.value
|
||||
}
|
||||
return library?.get(templateName)
|
||||
}
|
||||
|
||||
private fun parseTemplates(json: JsonObject, namespace: String): Map<String, TemplateDefinition> {
|
||||
val result = mutableMapOf<String, TemplateDefinition>()
|
||||
val templatesObj = json.getAsJsonObject("templates") ?: return result
|
||||
for ((name, element) in templatesObj.entrySet()) {
|
||||
val templateJson = element.asJsonObject
|
||||
val inputs = mutableMapOf<String, TemplateInput>()
|
||||
templateJson.getAsJsonObject("inputs")?.let { inputsObj ->
|
||||
for ((inputName, inputEl) in inputsObj.entrySet()) {
|
||||
val inputObj = inputEl.asJsonObject
|
||||
val type = TemplateInputType.fromId(inputObj.get("type")?.asString ?: "string")
|
||||
?: throw IllegalArgumentException("unknown template input type for '$inputName'")
|
||||
val default = inputObj.get("default")
|
||||
require(default == null || type.accepts(default)) { "invalid template default at '$name.$inputName'" }
|
||||
inputs[inputName] = TemplateInput(type, default)
|
||||
}
|
||||
}
|
||||
val extends = templateJson.get("extends")?.asString
|
||||
result[name] = TemplateDefinition(
|
||||
name = name,
|
||||
namespace = namespace,
|
||||
inputs = inputs,
|
||||
extends = extends,
|
||||
body = templateJson
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class TemplateDefinition(
|
||||
val name: String,
|
||||
val namespace: String,
|
||||
val inputs: Map<String, TemplateInput> = emptyMap(),
|
||||
val extends: String? = null,
|
||||
val body: JsonObject
|
||||
)
|
||||
|
||||
data class TemplateInput(
|
||||
val type: TemplateInputType = TemplateInputType.STRING,
|
||||
val default: JsonElement? = null
|
||||
)
|
||||
|
||||
enum class TemplateInputType(val id: String) {
|
||||
STRING("string"), NUMBER("number"), BOOLEAN("boolean"), EXPRESSION("expression"), OBJECT("object"), ARRAY("array");
|
||||
|
||||
fun accepts(value: JsonElement): Boolean = when (this) {
|
||||
STRING -> value.isJsonPrimitive && value.asJsonPrimitive.isString
|
||||
NUMBER -> value.isJsonPrimitive && value.asJsonPrimitive.isNumber && value.asDouble.isFinite()
|
||||
BOOLEAN -> value.isJsonPrimitive && value.asJsonPrimitive.isBoolean
|
||||
EXPRESSION -> value.isJsonObject && value.asJsonObject.size() == 1 && value.asJsonObject.get("molang")?.let {
|
||||
it.isJsonPrimitive && it.asJsonPrimitive.isString
|
||||
} == true
|
||||
OBJECT -> value.isJsonObject
|
||||
ARRAY -> value.isJsonArray
|
||||
}
|
||||
|
||||
companion object { fun fromId(id: String) = entries.firstOrNull { it.id == id } }
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.template
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetTree
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonPrimitive
|
||||
|
||||
object TemplateResolver {
|
||||
fun resolve(root: JsonObject, localTemplates: Map<String, TemplateDefinition>): JsonObject =
|
||||
Resolution(localTemplates).resolveNode(root, emptyList(), 1)
|
||||
|
||||
private class Resolution(private val localTemplates: Map<String, TemplateDefinition>) {
|
||||
private var nodes = 0
|
||||
private var values = 0
|
||||
|
||||
fun resolveNode(node: JsonObject, chain: List<String>, depth: Int): JsonObject {
|
||||
require(depth <= WidgetTree.MAX_DEPTH) { "template expansion exceeds maximum widget depth ${WidgetTree.MAX_DEPTH}" }
|
||||
require(++nodes <= WidgetTree.MAX_NODES) { "template expansion exceeds maximum widget count ${WidgetTree.MAX_NODES}" }
|
||||
val result = JsonObject()
|
||||
node.entrySet().filter { it.key != "controls" && it.key != "item_template" }.forEach { (key, value) ->
|
||||
result.add(key, copy(value))
|
||||
}
|
||||
node.getAsJsonArray("controls")?.let { controls ->
|
||||
val resolvedControls = JsonArray()
|
||||
controls.forEach { element ->
|
||||
val wrapper = element.takeIf(JsonElement::isJsonObject)?.asJsonObject
|
||||
?: throw IllegalArgumentException("control entries must be objects")
|
||||
require(wrapper.size() == 1) { "control entries must contain exactly one widget" }
|
||||
val (key, value) = wrapper.entrySet().single()
|
||||
val output = JsonObject()
|
||||
require(value.isJsonObject) { "widget '$key' must be an object" }
|
||||
output.add(key.substringBefore('@'), resolveWidgetEntry(key, value.asJsonObject, chain, depth + 1))
|
||||
resolvedControls.add(output)
|
||||
}
|
||||
result.add("controls", resolvedControls)
|
||||
}
|
||||
node.getAsJsonObject("item_template")?.let {
|
||||
result.add("item_template", resolveNode(it, chain, depth + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun resolveWidgetEntry(
|
||||
key: String,
|
||||
overrides: JsonObject,
|
||||
chain: List<String>,
|
||||
depth: Int
|
||||
): JsonObject {
|
||||
if ('@' !in key) return resolveNode(overrides, chain, depth)
|
||||
val templateRef = key.substringAfter('@')
|
||||
require(templateRef !in chain) { "template expansion cycle: ${(chain + templateRef).joinToString(" -> ")}" }
|
||||
require(depth <= WidgetTree.MAX_DEPTH) { "template expansion exceeds maximum widget depth ${WidgetTree.MAX_DEPTH}" }
|
||||
val template = findTemplate(templateRef)
|
||||
?: throw IllegalArgumentException("missing template '$templateRef'")
|
||||
val inherited = resolveTemplateChain(templateRef, template, emptyList())
|
||||
val inputs = inherited.inputs.mapValues { (name, input) ->
|
||||
val supplied = overrides.get(name)
|
||||
val value = supplied ?: input.default
|
||||
?: throw IllegalArgumentException("missing required template input '$name' for '$templateRef'")
|
||||
require(input.type.accepts(value)) {
|
||||
"template input '$name' for '$templateRef' must be ${input.type.id}"
|
||||
}
|
||||
value
|
||||
}
|
||||
val substituted = copy(inherited.body, inputs).asJsonObject
|
||||
val nonInputs = JsonObject().also { obj ->
|
||||
overrides.entrySet().filter { it.key !in inherited.inputs }.forEach { (name, value) -> obj.add(name, value) }
|
||||
}
|
||||
return resolveNode(mergeJson(substituted, nonInputs), chain + templateRef, depth)
|
||||
}
|
||||
|
||||
private fun resolveTemplateChain(
|
||||
ref: String,
|
||||
template: TemplateDefinition,
|
||||
chain: List<String>
|
||||
): TemplateDefinition {
|
||||
require(ref !in chain) { "template inheritance cycle: ${(chain + ref).joinToString(" -> ")}" }
|
||||
require(chain.size < WidgetTree.MAX_DEPTH) { "template inheritance exceeds maximum depth ${WidgetTree.MAX_DEPTH}" }
|
||||
val nextChain = chain + ref
|
||||
val body = JsonObject().also { obj ->
|
||||
template.body.entrySet().filter { it.key != "inputs" && it.key != "extends" }
|
||||
.forEach { (name, value) -> obj.add(name, value) }
|
||||
}
|
||||
val parentRef = template.extends ?: return template.copy(body = body)
|
||||
val parent = findTemplate(parentRef)
|
||||
?: throw IllegalArgumentException("missing parent template '$parentRef' referenced by '$ref'")
|
||||
val inherited = resolveTemplateChain(parentRef, parent, nextChain)
|
||||
return template.copy(inputs = inherited.inputs + template.inputs, body = mergeJson(inherited.body, body))
|
||||
}
|
||||
|
||||
private fun findTemplate(ref: String) =
|
||||
localTemplates[ref] ?: TemplateLibraryRegistry.getTemplate(ref)
|
||||
|
||||
private fun copy(element: JsonElement, inputs: Map<String, JsonElement>? = null, depth: Int = 0): JsonElement {
|
||||
require(depth <= 128) { "template JSON exceeds maximum depth 128" }
|
||||
require(++values <= 1_048_576) { "template expansion exceeds maximum JSON work" }
|
||||
if (inputs != null && element.isJsonPrimitive && element.asJsonPrimitive.isString) {
|
||||
val text = element.asString
|
||||
if (text.startsWith('$') && text.length > 1 && text.indexOf('$', 1) == -1) {
|
||||
val supplied = inputs[text.substring(1)]
|
||||
?: throw IllegalArgumentException("unknown template input reference '$text'")
|
||||
return copy(supplied, depth = depth + 1)
|
||||
}
|
||||
return JsonPrimitive(text)
|
||||
}
|
||||
if (element.isJsonArray) return JsonArray().also { out -> element.asJsonArray.forEach { out.add(copy(it, inputs, depth + 1)) } }
|
||||
if (element.isJsonObject) return JsonObject().also { out ->
|
||||
element.asJsonObject.entrySet().forEach { (key, value) ->
|
||||
out.add(key, copy(value, inputs, depth + 1))
|
||||
}
|
||||
}
|
||||
return element.deepCopy()
|
||||
}
|
||||
|
||||
private fun mergeJson(base: JsonObject, overlay: JsonObject): JsonObject {
|
||||
val result = copy(base).asJsonObject
|
||||
overlay.entrySet().forEach { (key, value) ->
|
||||
val existing = result.get(key)
|
||||
result.add(key, if (value.isJsonObject && existing?.isJsonObject == true) {
|
||||
mergeJson(existing.asJsonObject, value.asJsonObject)
|
||||
} else copy(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.widget
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.ActionKey
|
||||
|
||||
data class WidgetDefinition(
|
||||
val id: String,
|
||||
val type: WidgetType,
|
||||
val properties: Map<String, WidgetProperty> = emptyMap(),
|
||||
val children: List<WidgetDefinition> = emptyList(),
|
||||
val events: Map<String, ServerWidgetEvent> = emptyMap(),
|
||||
val clientEvents: Map<String, String> = emptyMap(),
|
||||
val hoverOverrides: Map<String, WidgetProperty> = emptyMap(),
|
||||
val itemTemplate: WidgetDefinition? = null
|
||||
) {
|
||||
fun getProperty(name: String): WidgetProperty? = properties[name]
|
||||
fun getStringProperty(name: String): String? = (properties[name]?.raw as? String)
|
||||
fun getAnchor(): Anchor = Anchor.fromId(getStringProperty("anchor") ?: "top_left")
|
||||
}
|
||||
|
||||
data class ServerWidgetEvent(
|
||||
val action: ActionKey,
|
||||
val parameters: Map<String, WidgetProperty> = emptyMap()
|
||||
)
|
||||
|
||||
enum class WidgetType(val id: String) {
|
||||
PANEL("panel"),
|
||||
LABEL("label"),
|
||||
IMAGE("image"),
|
||||
FILL("fill"),
|
||||
BUTTON("button"),
|
||||
LIST("list"),
|
||||
PROGRESS_BAR("progress_bar"),
|
||||
SCROLL_PANEL("scroll_panel"),
|
||||
POKEMON_RENDER("pokemon_render"),
|
||||
NUMBER_INPUT("number_input"),
|
||||
ITEM_SLOT("item_slot"),
|
||||
SLOT_GRID("slot_grid");
|
||||
|
||||
companion object {
|
||||
private val BY_ID = entries.associateBy { it.id }
|
||||
fun fromId(id: String): WidgetType? = BY_ID[id]
|
||||
}
|
||||
}
|
||||
|
||||
enum class Anchor(val id: String, val xFactor: Float, val yFactor: Float) {
|
||||
TOP_LEFT("top_left", 0f, 0f),
|
||||
TOP_CENTER("top_center", 0.5f, 0f),
|
||||
TOP_RIGHT("top_right", 1f, 0f),
|
||||
CENTER_LEFT("center_left", 0f, 0.5f),
|
||||
CENTER("center", 0.5f, 0.5f),
|
||||
CENTER_RIGHT("center_right", 1f, 0.5f),
|
||||
BOTTOM_LEFT("bottom_left", 0f, 1f),
|
||||
BOTTOM_CENTER("bottom_center", 0.5f, 1f),
|
||||
BOTTOM_RIGHT("bottom_right", 1f, 1f);
|
||||
|
||||
companion object {
|
||||
private val BY_ID = entries.associateBy { it.id }
|
||||
fun fromId(id: String): Anchor = BY_ID[id] ?: TOP_LEFT
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.widget
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.struct.QueryStruct
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.bedrockk.molang.runtime.value.MoValue
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.api.molangui.layout.SizeExpression
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParser
|
||||
|
||||
internal object WidgetExpansion {
|
||||
private data class Node(val definition: WidgetDefinition, val context: QueryStruct?, val children: List<Node>)
|
||||
|
||||
fun refresh(definition: WidgetDefinition, runtime: MoLangRuntime, previous: WidgetInstance? = null): WidgetInstance {
|
||||
var count = 0
|
||||
fun plan(def: WidgetDefinition, context: QueryStruct?, depth: Int): Node = withItemContext(runtime, context) {
|
||||
require(depth <= WidgetTree.MAX_DEPTH && ++count <= WidgetTree.MAX_NODES) {
|
||||
"expanded widget tree exceeds ${WidgetTree.MAX_NODES} widgets or depth ${WidgetTree.MAX_DEPTH}"
|
||||
}
|
||||
val children = if (def.type == WidgetType.LIST) {
|
||||
val template = requireNotNull(def.itemTemplate) { "list '${def.id}' requires item_template" }
|
||||
items(def, runtime).mapIndexed { index, values ->
|
||||
val item = QueryStruct(hashMapOf())
|
||||
values.forEach { (key, value) -> item.addFunction(key) { value } }
|
||||
item.addFunction("index") { DoubleValue(index.toDouble()) }
|
||||
plan(template.copy(id = "${template.id}[$index]"), item, depth + 1)
|
||||
}
|
||||
} else def.children.map { plan(it, context, depth + 1) }
|
||||
Node(def, context, children)
|
||||
}
|
||||
val planned = plan(definition, null, 1)
|
||||
fun apply(node: Node, old: WidgetInstance?, parent: WidgetInstance?): WidgetInstance {
|
||||
val widget = old?.takeIf { it.definition == node.definition } ?: WidgetInstance(node.definition, parent).apply {
|
||||
val size = node.definition.getProperty("size")?.raw as? List<*>
|
||||
if (size?.size == 2) {
|
||||
widthExpr = SizeExpression.parse(size[0] ?: 0)
|
||||
heightExpr = SizeExpression.parse(size[1] ?: 0)
|
||||
}
|
||||
val offset = node.definition.getProperty("offset")?.raw as? List<*>
|
||||
if (offset?.size == 2) {
|
||||
offsetXExpr = SizeExpression.parse(offset[0] ?: 0)
|
||||
offsetYExpr = SizeExpression.parse(offset[1] ?: 0)
|
||||
}
|
||||
}
|
||||
widget.itemContext = node.context
|
||||
val children = node.children.mapIndexed { index, child -> apply(child, widget.children.getOrNull(index), widget) }
|
||||
widget.children.clear()
|
||||
widget.children.addAll(children)
|
||||
return widget
|
||||
}
|
||||
return apply(planned, previous, null)
|
||||
}
|
||||
|
||||
private fun items(definition: WidgetDefinition, runtime: MoLangRuntime): List<Map<String, MoValue>> =
|
||||
when (definition.getStringProperty("source_type") ?: "data") {
|
||||
"count" -> {
|
||||
val count = definition.getProperty("source")?.resolveDouble(runtime) ?: 0.0
|
||||
require(count.isFinite() && count in 0.0..WidgetTree.MAX_NODES.toDouble() && count % 1.0 == 0.0) {
|
||||
"list '${definition.id}' requires a whole count between 0 and ${WidgetTree.MAX_NODES}"
|
||||
}
|
||||
List(count.toInt()) { emptyMap() }
|
||||
}
|
||||
"data" -> {
|
||||
val source = definition.getProperty("source")?.resolveString(runtime) ?: "[]"
|
||||
require(source.length <= MolangUiPacketLimits.MAX_VALUE_LENGTH) { "list '${definition.id}' source is too large" }
|
||||
val array = JsonParser.parseString(source).asJsonArray
|
||||
require(array.size() <= WidgetTree.MAX_NODES) { "list '${definition.id}' has too many items" }
|
||||
array.map { element -> element.asJsonObject.entrySet().associate { (key, item) -> key to value(item) } }
|
||||
}
|
||||
else -> throw IllegalArgumentException("unsupported list source_type at '${definition.id}'")
|
||||
}
|
||||
|
||||
private fun value(element: JsonElement): MoValue {
|
||||
if (!element.isJsonPrimitive) return StringValue(element.toString())
|
||||
val primitive = element.asJsonPrimitive
|
||||
return when {
|
||||
primitive.isBoolean -> DoubleValue(primitive.asBoolean)
|
||||
primitive.isNumber -> DoubleValue(primitive.asDouble.also { require(it.isFinite()) { "non-finite list item value" } })
|
||||
else -> StringValue(primitive.asString)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.widget
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.layout.SizeExpression
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.struct.QueryStruct
|
||||
|
||||
class WidgetInstance(
|
||||
val definition: WidgetDefinition,
|
||||
val parent: WidgetInstance? = null
|
||||
) {
|
||||
var absoluteX: Float = 0f
|
||||
var absoluteY: Float = 0f
|
||||
var computedWidth: Float = 0f
|
||||
var computedHeight: Float = 0f
|
||||
|
||||
var widthExpr: SizeExpression = SizeExpression.Pixels(0f)
|
||||
var heightExpr: SizeExpression = SizeExpression.Pixels(0f)
|
||||
var offsetXExpr: SizeExpression = SizeExpression.Pixels(0f)
|
||||
var offsetYExpr: SizeExpression = SizeExpression.Pixels(0f)
|
||||
|
||||
internal val paddingProperties: List<WidgetProperty> =
|
||||
(definition.getProperty("padding")?.raw as? List<*>)?.map {
|
||||
when (it) {
|
||||
is Number -> WidgetProperty.literal(it)
|
||||
is WidgetExpression -> WidgetProperty.molang(it.source)
|
||||
else -> throw IllegalArgumentException("invalid padding at '${definition.id}'")
|
||||
}
|
||||
} ?: List(4) { WidgetProperty.literal(0) }
|
||||
|
||||
val children: MutableList<WidgetInstance> = mutableListOf()
|
||||
|
||||
var visible: Boolean = true
|
||||
var enabled: Boolean = true
|
||||
var hovered: Boolean = false
|
||||
|
||||
var scrollOffset: Float = 0f
|
||||
var rendererState: Any? = null
|
||||
var itemContext: QueryStruct? = null
|
||||
|
||||
inline fun <T> withItemContext(runtime: MoLangRuntime, action: () -> T): T =
|
||||
withItemContext(runtime, itemContext, action)
|
||||
|
||||
fun containsPoint(x: Float, y: Float): Boolean {
|
||||
return x >= absoluteX && x <= absoluteX + computedWidth &&
|
||||
y >= absoluteY && y <= absoluteY + computedHeight
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> withItemContext(runtime: MoLangRuntime, context: QueryStruct?, action: () -> T): T {
|
||||
val functions = runtime.environment.query.functions
|
||||
val previous = functions.remove("item")
|
||||
if (context != null) runtime.environment.query.addFunction("item") { context }
|
||||
try {
|
||||
return action()
|
||||
} finally {
|
||||
functions.remove("item")
|
||||
if (previous != null) functions["item"] = previous
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.widget
|
||||
|
||||
import com.cobblemon.mod.common.api.molang.ExpressionLike
|
||||
import com.cobblemon.mod.common.util.asExpressionLike
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
|
||||
class WidgetProperty private constructor(val raw: Any, expressionSource: String?) {
|
||||
val expression: ExpressionLike? = expressionSource?.asExpressionLike()
|
||||
|
||||
fun resolveDouble(runtime: MoLangRuntime): Double {
|
||||
return expression?.resolveDouble(runtime) ?: when (raw) {
|
||||
is Number -> raw.toDouble()
|
||||
is String -> parseNumericString(raw)
|
||||
is Boolean -> if (raw) 1.0 else 0.0
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveString(runtime: MoLangRuntime): String {
|
||||
return expression?.resolveString(runtime) ?: raw.toString()
|
||||
}
|
||||
|
||||
fun resolveBoolean(runtime: MoLangRuntime): Boolean {
|
||||
return expression?.resolveBoolean(runtime) ?: when (raw) {
|
||||
is Boolean -> raw
|
||||
is Number -> raw.toDouble() != 0.0
|
||||
is String -> raw.toBooleanStrictOrNull() ?: (raw.isNotEmpty())
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveInt(runtime: MoLangRuntime): Int {
|
||||
if (expression == null && raw is String && raw.startsWith("0x", ignoreCase = true)) {
|
||||
return raw.substring(2).toLongOrNull(16)?.toInt() ?: 0
|
||||
}
|
||||
return resolveDouble(runtime).toInt()
|
||||
}
|
||||
|
||||
fun resolveFloat(runtime: MoLangRuntime): Float = resolveDouble(runtime).toFloat()
|
||||
|
||||
companion object {
|
||||
fun literal(value: Any): WidgetProperty = WidgetProperty(value, null)
|
||||
fun molang(source: String): WidgetProperty = WidgetProperty(source, source)
|
||||
|
||||
private fun parseNumericString(value: String): Double {
|
||||
value.toDoubleOrNull()?.let { return it }
|
||||
if (value.startsWith("0x", ignoreCase = true)) {
|
||||
value.substring(2).toLongOrNull(16)?.let { return it.toDouble() }
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class WidgetExpression(val source: String)
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui.widget
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.layout.SizeExpression
|
||||
import com.cobblemon.mod.common.api.molangui.ActionKey
|
||||
import com.cobblemon.mod.common.api.molangui.requireUiString
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateInput
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateInputType
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateResolver
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
|
||||
object WidgetTree {
|
||||
const val MAX_DEPTH = 32
|
||||
const val MAX_NODES = 2048
|
||||
private val UNSUPPORTED_PROPERTIES = setOf(
|
||||
"transition", "enter", "exit", "static", "on_submit", "on_submit_client", "on_slot_change", "on_slot_change_client"
|
||||
)
|
||||
private val EVENT_NAMES = setOf(
|
||||
"on_click", "on_change", "on_hover_enter", "on_hover_exit", "on_scroll", "on_key"
|
||||
)
|
||||
|
||||
fun parse(guiJson: JsonObject): WidgetDefinition {
|
||||
val localTemplates = parseLocalTemplates(guiJson)
|
||||
val rootJson = guiJson.getAsJsonObject("root")
|
||||
?: throw IllegalArgumentException("UI definition is missing 'root'")
|
||||
val resolved = TemplateResolver.resolve(rootJson, localTemplates)
|
||||
val ids = mutableSetOf<String>()
|
||||
val count = intArrayOf(0)
|
||||
return parseWidget("root", resolved, ids, 1, count)
|
||||
}
|
||||
|
||||
fun parseWidget(id: String, json: JsonObject): WidgetDefinition =
|
||||
parseWidget(id, json, mutableSetOf(), 1, intArrayOf(0))
|
||||
|
||||
private fun parseWidget(
|
||||
id: String,
|
||||
json: JsonObject,
|
||||
ids: MutableSet<String>,
|
||||
depth: Int,
|
||||
count: IntArray
|
||||
): WidgetDefinition {
|
||||
require(id.isNotBlank() && id.length <= 128) { "invalid widget id '$id'" }
|
||||
require(ids.add(id)) { "duplicate widget id '$id'" }
|
||||
require(depth <= MAX_DEPTH) { "widget tree exceeds maximum depth $MAX_DEPTH at '$id'" }
|
||||
count[0]++
|
||||
require(count[0] <= MAX_NODES) { "widget tree exceeds maximum node count $MAX_NODES" }
|
||||
|
||||
val typeName = json.get("type")?.asString ?: "panel"
|
||||
val type = WidgetType.fromId(typeName)
|
||||
?: throw IllegalArgumentException("unsupported widget renderer '$typeName' at '$id'")
|
||||
val properties = linkedMapOf<String, WidgetProperty>()
|
||||
val events = linkedMapOf<String, ServerWidgetEvent>()
|
||||
val clientEvents = linkedMapOf<String, String>()
|
||||
var hoverOverrides = emptyMap<String, WidgetProperty>()
|
||||
val reserved = setOf("type", "controls", "item_template", "hover", "inputs", "extends")
|
||||
json.entrySet().forEach { (key, value) ->
|
||||
require(key !in UNSUPPORTED_PROPERTIES) { "unsupported widget property '$id.$key'" }
|
||||
when {
|
||||
key.endsWith("_client") && key.removeSuffix("_client") in EVENT_NAMES ->
|
||||
clientEvents[key] = readExpression(value, "$id.$key")
|
||||
key in EVENT_NAMES -> {
|
||||
events[key] = parseServerEvent(value, "$id.$key")
|
||||
}
|
||||
key == "hover" && value.isJsonObject -> hoverOverrides = parsePropertyMap(value.asJsonObject, "$id.hover")
|
||||
key !in reserved -> properties[key] = parseProperty(value, "$id.$key")
|
||||
}
|
||||
}
|
||||
|
||||
val children = mutableListOf<WidgetDefinition>()
|
||||
json.getAsJsonArray("controls")?.forEach { element ->
|
||||
require(element.isJsonObject && element.asJsonObject.size() == 1) { "controls under '$id' must be single-entry objects" }
|
||||
val (childId, childValue) = element.asJsonObject.entrySet().single()
|
||||
require(childValue.isJsonObject) { "widget '$childId' must be an object" }
|
||||
children += parseWidget(childId, childValue.asJsonObject, ids, depth + 1, count)
|
||||
}
|
||||
|
||||
val itemTemplate = json.getAsJsonObject("item_template")?.let { template ->
|
||||
parseWidget("$id#item_template", template, ids, depth + 1, count)
|
||||
}
|
||||
validateGeometry(id, type, properties)
|
||||
return WidgetDefinition(
|
||||
id, type, properties.toMap(), children.toList(), events.toMap(), clientEvents.toMap(),
|
||||
hoverOverrides.toMap(), itemTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseLocalTemplates(guiJson: JsonObject): Map<String, TemplateDefinition> {
|
||||
val result = linkedMapOf<String, TemplateDefinition>()
|
||||
guiJson.getAsJsonObject("templates")?.entrySet()?.forEach { (name, element) ->
|
||||
val body = element.asJsonObject
|
||||
val inputs = linkedMapOf<String, TemplateInput>()
|
||||
body.getAsJsonObject("inputs")?.entrySet()?.forEach { (inputName, inputElement) ->
|
||||
val input = inputElement.asJsonObject
|
||||
val type = TemplateInputType.fromId(input.get("type")?.asString ?: "string")
|
||||
?: throw IllegalArgumentException("unknown template input type at '$name.$inputName'")
|
||||
val default = input.get("default")
|
||||
require(default == null || type.accepts(default)) { "invalid template default at '$name.$inputName'" }
|
||||
inputs[inputName] = TemplateInput(type, default)
|
||||
}
|
||||
result[name] = TemplateDefinition(name, "", inputs.toMap(), body.get("extends")?.asString, body)
|
||||
}
|
||||
return result.toMap()
|
||||
}
|
||||
|
||||
private fun parsePropertyMap(json: JsonObject, path: String) = json.entrySet().associate { (key, value) ->
|
||||
key to parseProperty(value, "$path.$key")
|
||||
}
|
||||
|
||||
private fun parseProperty(value: JsonElement, path: String): WidgetProperty {
|
||||
if (value.isJsonObject && value.asJsonObject.has("molang")) {
|
||||
require(value.asJsonObject.size() == 1) {
|
||||
"expression '$path' must contain only a string 'molang' field"
|
||||
}
|
||||
return WidgetProperty.molang(value.asJsonObject.get("molang").requireUiString("$path.molang"))
|
||||
}
|
||||
return WidgetProperty.literal(jsonToLiteral(value, path))
|
||||
}
|
||||
|
||||
private fun jsonToLiteral(element: JsonElement, path: String): Any = when {
|
||||
element.isJsonPrimitive -> element.asJsonPrimitive.let { when {
|
||||
it.isNumber -> it.asDouble.also { number -> require(number.isFinite()) { "non-finite number at '$path'" } }
|
||||
it.isBoolean -> it.asBoolean
|
||||
else -> it.asString
|
||||
} }
|
||||
element.isJsonArray -> element.asJsonArray.mapIndexed { index, item ->
|
||||
if (item.isJsonObject && item.asJsonObject.has("molang")) {
|
||||
require(item.asJsonObject.size() == 1) { "expression '$path[$index]' contains unknown fields" }
|
||||
WidgetExpression(item.asJsonObject.get("molang").requireUiString("$path[$index].molang"))
|
||||
} else jsonToLiteral(item, "$path[$index]")
|
||||
}.toList()
|
||||
element.isJsonObject -> element.asJsonObject.entrySet().associate { (key, item) -> key to jsonToLiteral(item, "$path.$key") }.toMap()
|
||||
else -> throw IllegalArgumentException("null is not a supported widget value at '$path'")
|
||||
}
|
||||
|
||||
private fun readExpression(element: JsonElement, path: String): String {
|
||||
require(element.isJsonObject && element.asJsonObject.size() == 1) { "client event '$path' must be an explicit molang object" }
|
||||
return element.asJsonObject.get("molang").requireUiString("$path.molang").also {
|
||||
require(!it.contains("send_action")) { "client event '$path' may not dispatch server actions" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseServerEvent(element: JsonElement, path: String): ServerWidgetEvent {
|
||||
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
|
||||
val action = element.asString
|
||||
require(action.startsWith("action@") && action.length > 7) { "server event '$path' must declare exactly one action" }
|
||||
return ServerWidgetEvent(ActionKey(action.substringAfter("action@")))
|
||||
}
|
||||
require(element.isJsonObject) { "server event '$path' must be an object" }
|
||||
val obj = element.asJsonObject
|
||||
val action = ActionKey(obj.get("action")?.asString
|
||||
?: throw IllegalArgumentException("server event '$path' is missing action"))
|
||||
val parameters = obj.getAsJsonObject("parameters")?.entrySet()?.associate { (name, value) ->
|
||||
name to parseProperty(value, "$path.parameters.$name")
|
||||
}?.toMap() ?: emptyMap()
|
||||
require(obj.keySet().all { it == "action" || it == "parameters" }) { "server event '$path' contains unknown fields" }
|
||||
return ServerWidgetEvent(action, parameters)
|
||||
}
|
||||
|
||||
private fun validateGeometry(id: String, type: WidgetType, properties: Map<String, WidgetProperty>) {
|
||||
listOf("size", "offset", "grid_dimensions", "slot_size", "slot_spacing").forEach { key ->
|
||||
val raw = properties[key]?.raw ?: return@forEach
|
||||
require(raw is List<*> && raw.size == 2) { "'$id.$key' must contain exactly two values" }
|
||||
raw.filterIsInstance<Double>().forEach { require(it.isFinite()) { "non-finite geometry at '$id.$key'" } }
|
||||
}
|
||||
listOf("size", "offset").forEach { key ->
|
||||
(properties[key]?.raw as? List<*>)?.forEach { SizeExpression.parse(requireNotNull(it)) }
|
||||
}
|
||||
properties["padding"]?.raw?.let { padding ->
|
||||
require(padding is List<*> && padding.size == 4 && padding.all { it is Number || it is WidgetExpression }) {
|
||||
"'$id.padding' must contain four numbers or explicit Molang expressions"
|
||||
}
|
||||
padding.filterIsInstance<Number>().forEach {
|
||||
require(it.toFloat().isFinite() && it.toFloat() >= 0f) { "invalid padding at '$id'" }
|
||||
}
|
||||
}
|
||||
if (type == WidgetType.SLOT_GRID) {
|
||||
val dimensions = properties["grid_dimensions"]?.raw as? List<*>
|
||||
?: throw IllegalArgumentException("slot grid '$id' is missing grid_dimensions")
|
||||
require(dimensions.all { it is Double && it in 1.0..16.0 && it == it.toInt().toDouble() }) {
|
||||
"slot grid '$id' must have integer dimensions in 1..16"
|
||||
}
|
||||
val collectionName = properties["collection_name"]?.raw
|
||||
require(collectionName is String && collectionName.isNotBlank()) { "slot grid '$id' is missing collection_name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.vending
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.data.JsonDataRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.ActionKey
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiActionContext
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiActionHandler
|
||||
import com.cobblemon.mod.common.api.molangui.ParameterValue
|
||||
import com.cobblemon.mod.common.api.reactive.SimpleObservable
|
||||
import com.cobblemon.mod.common.util.adapters.IdentifierAdapter
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.server.packs.PackType
|
||||
|
||||
data class CatalogEntry(val item: ResourceLocation, val price: Int)
|
||||
|
||||
data class VendingMachineCatalog(val entries: List<CatalogEntry> = emptyList()) {
|
||||
|
||||
val pageCount: Int get() = if (entries.isEmpty()) 1 else (entries.size + PAGE_SIZE - 1) / PAGE_SIZE
|
||||
|
||||
fun clampPage(page: Int): Int = page.coerceIn(0, pageCount - 1)
|
||||
|
||||
fun pageParameters(page: Int): Map<String, ParameterValue> {
|
||||
val safePage = clampPage(page)
|
||||
val parameters = linkedMapOf<String, ParameterValue>(
|
||||
"page" to ParameterValue.NumberValue(safePage.toDouble()),
|
||||
"page_count" to ParameterValue.NumberValue(pageCount.toDouble()),
|
||||
"page_text" to ParameterValue.StringValue("${safePage + 1}/$pageCount")
|
||||
)
|
||||
for (row in 0 until PAGE_SIZE) {
|
||||
val entry = entries.getOrNull(safePage * PAGE_SIZE + row)
|
||||
parameters["row${row}_item"] = ParameterValue.StringValue(entry?.item?.toString() ?: "")
|
||||
parameters["row${row}_price"] = ParameterValue.StringValue(entry?.price?.toString() ?: "")
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
|
||||
companion object { const val PAGE_SIZE = 3 }
|
||||
}
|
||||
|
||||
object VendingMachinePageHandler : MolangUiActionHandler {
|
||||
val ACTION = ActionKey("vending_page")
|
||||
|
||||
override fun handle(context: MolangUiActionContext) {
|
||||
val requested = (context.parameters["page"] as? ParameterValue.NumberValue)?.value ?: return
|
||||
val catalog = VendingMachineCatalogs.catalog(VendingMachineCatalogs.DEFAULT)
|
||||
context.session.updateParameters(catalog.pageParameters(requested.toInt()))
|
||||
}
|
||||
}
|
||||
|
||||
object VendingMachineCatalogs : JsonDataRegistry<VendingMachineCatalog> {
|
||||
override val id = cobblemonResource("vending_machine")
|
||||
override val type = PackType.SERVER_DATA
|
||||
override val observable = SimpleObservable<VendingMachineCatalogs>()
|
||||
override val typeToken: TypeToken<VendingMachineCatalog> = TypeToken.get(VendingMachineCatalog::class.java)
|
||||
override val resourcePath = "vending_machine"
|
||||
override val gson = GsonBuilder()
|
||||
.registerTypeAdapter(ResourceLocation::class.java, IdentifierAdapter)
|
||||
.create()
|
||||
|
||||
val DEFAULT = cobblemonResource("default")
|
||||
|
||||
private val catalogs = mutableMapOf<ResourceLocation, VendingMachineCatalog>()
|
||||
|
||||
fun catalog(id: ResourceLocation): VendingMachineCatalog = catalogs[id] ?: VendingMachineCatalog()
|
||||
|
||||
override fun reload(data: Map<ResourceLocation, VendingMachineCatalog>) {
|
||||
catalogs.clear()
|
||||
data.forEach { (id, catalog) -> catalogs[id] = validate(id, catalog) }
|
||||
Cobblemon.LOGGER.info("Loaded ${catalogs.size} vending machine catalogs")
|
||||
observable.emit(this)
|
||||
}
|
||||
|
||||
override fun sync(player: ServerPlayer) = Unit
|
||||
|
||||
private fun validate(id: ResourceLocation, catalog: VendingMachineCatalog): VendingMachineCatalog {
|
||||
val kept = catalog.entries.filter { entry ->
|
||||
val known = BuiltInRegistries.ITEM.containsKey(entry.item)
|
||||
if (!known) Cobblemon.LOGGER.error("Vending catalog '$id' drops unknown item '${entry.item}'")
|
||||
else if (entry.price <= 0) Cobblemon.LOGGER.error("Vending catalog '$id' drops '${entry.item}' with price ${entry.price}")
|
||||
known && entry.price > 0
|
||||
}
|
||||
return VendingMachineCatalog(kept)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.block
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiOpenRequest
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.api.vending.VendingMachineCatalogs
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.Direction
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.InteractionResult
|
||||
import net.minecraft.world.entity.player.Player
|
||||
import net.minecraft.world.item.context.BlockPlaceContext
|
||||
import net.minecraft.world.level.Level
|
||||
import net.minecraft.world.level.block.Block
|
||||
import net.minecraft.world.level.block.Rotation
|
||||
import net.minecraft.world.level.block.state.BlockState
|
||||
import net.minecraft.world.level.block.state.StateDefinition
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties.HORIZONTAL_FACING
|
||||
import net.minecraft.world.phys.BlockHitResult
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
class VendingMachineBlock(settings: Properties) : Block(settings) {
|
||||
init {
|
||||
registerDefaultState(stateDefinition.any().setValue(HORIZONTAL_FACING, Direction.NORTH))
|
||||
}
|
||||
|
||||
override fun getStateForPlacement(context: BlockPlaceContext): BlockState =
|
||||
defaultBlockState().setValue(HORIZONTAL_FACING, context.horizontalDirection.opposite)
|
||||
|
||||
override fun createBlockStateDefinition(builder: StateDefinition.Builder<Block, BlockState>) {
|
||||
builder.add(HORIZONTAL_FACING)
|
||||
}
|
||||
|
||||
override fun rotate(state: BlockState, rotation: Rotation): BlockState =
|
||||
state.setValue(HORIZONTAL_FACING, rotation.rotate(state.getValue(HORIZONTAL_FACING)))
|
||||
|
||||
override fun useWithoutItem(
|
||||
state: BlockState,
|
||||
world: Level,
|
||||
pos: BlockPos,
|
||||
player: Player,
|
||||
hitResult: BlockHitResult
|
||||
): InteractionResult {
|
||||
if (player !is ServerPlayer) return InteractionResult.SUCCESS
|
||||
val catalog = VendingMachineCatalogs.catalog(VendingMachineCatalogs.DEFAULT)
|
||||
MolangUiService.open(player, MolangUiOpenRequest(UI_ID, catalog.pageParameters(0), pos))
|
||||
return InteractionResult.SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val UI_ID = cobblemonResource("vending_machine")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ import com.cobblemon.mod.common.client.gui.RideControlsOverlay
|
||||
import com.cobblemon.mod.common.client.gui.battle.BattleOverlay
|
||||
import com.cobblemon.mod.common.client.gui.cookingpot.CookingPotScreen
|
||||
import com.cobblemon.mod.common.client.gui.party.PartyTutorialToasts
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiRegistry
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiClientManager
|
||||
import com.cobblemon.mod.common.client.gui.molangui.renderer.WidgetRendererRegistry
|
||||
import com.cobblemon.mod.common.client.net.molangui.MolangUiCompatibilityRequestHandler
|
||||
import com.cobblemon.mod.common.client.gui.tmmachine.TMMachineScreen
|
||||
import com.cobblemon.mod.common.client.particle.BedrockParticleOptionsRepository
|
||||
import com.cobblemon.mod.common.client.render.ClientPlayerIcon
|
||||
@@ -122,6 +126,8 @@ object CobblemonClient {
|
||||
}
|
||||
|
||||
fun onLogout() {
|
||||
MolangUiClientManager.cleanup()
|
||||
MolangUiCompatibilityRequestHandler.reset()
|
||||
storage.onLogout()
|
||||
battle = null
|
||||
battleOverlay.onLogout()
|
||||
@@ -163,6 +169,8 @@ object CobblemonClient {
|
||||
}
|
||||
|
||||
PlatformEvents.CLIENT_ENTITY_UNLOAD.subscribe { event -> EntitySoundTracker.clear(event.entity.id) }
|
||||
WidgetRendererRegistry.registerDefaults()
|
||||
PlatformEvents.CLIENT_TICK_POST.subscribe { MolangUiClientManager.tick() }
|
||||
PlatformEvents.CLIENT_TICK_POST.subscribe { event ->
|
||||
val player = event.client.player
|
||||
if (player != null) {
|
||||
@@ -423,12 +431,15 @@ object CobblemonClient {
|
||||
fun beforeChatRender(context: GuiGraphics, partialDeltaTicks: Float) {
|
||||
val partialDeltaTicks = Minecraft.getInstance().timer // Checking that this even works
|
||||
// ClientTaskTracker.update(partialDeltaTicks / 20f)
|
||||
if (battle == null) {
|
||||
overlay.render(context, partialDeltaTicks)
|
||||
} else {
|
||||
battleOverlay.render(context, partialDeltaTicks)
|
||||
if (!MolangUiClientManager.hasExclusiveOverlay()) {
|
||||
if (battle == null) {
|
||||
overlay.render(context, partialDeltaTicks)
|
||||
} else {
|
||||
battleOverlay.render(context, partialDeltaTicks)
|
||||
}
|
||||
rideControlsOverlay.render(context, partialDeltaTicks)
|
||||
}
|
||||
rideControlsOverlay.render(context, partialDeltaTicks)
|
||||
MolangUiClientManager.renderOverlays(context, partialDeltaTicks.getGameTimeDeltaPartialTick(false))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -442,6 +453,9 @@ object CobblemonClient {
|
||||
|
||||
private fun registerMenuScreens() {
|
||||
MenuScreens.register(CobblemonMenuType.COOKING_POT, ::CookingPotScreen)
|
||||
MenuScreens.register(CobblemonMenuType.MOLANG_UI) { menu, playerInventory, _ ->
|
||||
MolangUiClientManager.createContainerScreen(menu, playerInventory)
|
||||
}
|
||||
MenuScreens.register(CobblemonMenuType.TM_MACHINE, ::TMMachineScreen)
|
||||
}
|
||||
|
||||
@@ -510,6 +524,8 @@ object CobblemonClient {
|
||||
|
||||
BerryModelRepository.reload(resourceManager)
|
||||
MiscModelRepository.reload(resourceManager)
|
||||
MolangUiRegistry.reload(resourceManager)
|
||||
MolangUiClientManager.onResourceReload()
|
||||
LOGGER.info("Loaded assets")
|
||||
}
|
||||
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.molangui.GuiMode
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiParameters
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
|
||||
import com.cobblemon.mod.common.api.molangui.container.SlotLayout
|
||||
import com.cobblemon.mod.common.client.net.molangui.MolangUiCompatibilityRequestHandler
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiClosedPacket
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.client.gui.screens.Screen
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import java.util.UUID
|
||||
|
||||
object MolangUiClientManager {
|
||||
private class Session(
|
||||
var definition: MolangUiDefinition,
|
||||
val tree: MolangUiWidgetTree,
|
||||
val slotLayouts: Map<String, SlotLayout>,
|
||||
val preparedAtNanos: Long = System.nanoTime()
|
||||
) {
|
||||
var screen: Screen? = null
|
||||
val overlay = if (definition.mode == GuiMode.OVERLAY) {
|
||||
MolangUiOverlay(tree, definition.exclusive)
|
||||
} else null
|
||||
}
|
||||
|
||||
private val sessions = linkedMapOf<UUID, Session>()
|
||||
private var pendingContainerId: UUID? = null
|
||||
|
||||
fun open(
|
||||
sessionId: UUID, guiId: ResourceLocation, contractVersion: String, mode: GuiMode, args: Map<String, String>,
|
||||
exclusive: Boolean = false,
|
||||
hasContainer: Boolean = false, slotLayouts: Map<String, SlotLayout> = emptyMap()
|
||||
) {
|
||||
if (sessionId in sessions) return
|
||||
tick()
|
||||
val definition = MolangUiRegistry.getDefinition(guiId)
|
||||
if (definition == null || definition.contractVersion != contractVersion || definition.mode != mode ||
|
||||
definition.exclusive != exclusive ||
|
||||
(hasContainer && mode != GuiMode.SCREEN)) {
|
||||
Cobblemon.LOGGER.error("Rejected incompatible Molang UI open for '{}'", guiId)
|
||||
notifyClosed(sessionId)
|
||||
return
|
||||
}
|
||||
if (mode == GuiMode.OVERLAY && sessions.values.count { it.overlay != null } >= 8) {
|
||||
notifyClosed(sessionId)
|
||||
return
|
||||
}
|
||||
val tree = try {
|
||||
MolangUiWidgetTree(guiId, sessionId, definition.root,
|
||||
MolangUiParameters.decode(definition.parameters, args), definition.variables)
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.error("Failed to prepare Molang UI '{}'", guiId, error)
|
||||
notifyClosed(sessionId)
|
||||
return
|
||||
}
|
||||
if (mode == GuiMode.SCREEN) {
|
||||
sessions.filterValues { it.definition.mode == GuiMode.SCREEN }.keys.toList().forEach { close(it, true) }
|
||||
}
|
||||
val session = Session(definition, tree, slotLayouts.toMap())
|
||||
sessions[sessionId] = session
|
||||
if (mode == GuiMode.SCREEN) {
|
||||
if (hasContainer) {
|
||||
pendingContainerId = sessionId
|
||||
} else {
|
||||
val screen = MolangUiScreen(sessionId, tree)
|
||||
session.screen = screen
|
||||
val client = Minecraft.getInstance()
|
||||
client.execute {
|
||||
if (sessions[sessionId] === session) client.setScreen(screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createContainerScreen(menu: MolangContainerMenu, playerInventory: Inventory): AbstractContainerScreen<MolangContainerMenu> {
|
||||
tick()
|
||||
val id = pendingContainerId
|
||||
pendingContainerId = null
|
||||
val session = id?.let(sessions::get)
|
||||
?: return RejectedMolangUiContainerScreen(menu, playerInventory)
|
||||
return try {
|
||||
menu.initializeClient(session.slotLayouts)
|
||||
MolangUiContainerScreen(menu, playerInventory, session.tree.sessionId, session.tree).also { session.screen = it }
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.error("Failed to prepare Molang UI container '{}'", session.tree.guiId, error)
|
||||
close(session.tree.sessionId, true)
|
||||
RejectedMolangUiContainerScreen(menu, playerInventory)
|
||||
}
|
||||
}
|
||||
|
||||
fun close(sessionId: UUID) = close(sessionId, false)
|
||||
|
||||
private fun close(sessionId: UUID, notifyServer: Boolean) {
|
||||
val session = sessions.remove(sessionId) ?: return
|
||||
if (pendingContainerId == sessionId) pendingContainerId = null
|
||||
session.tree.cleanup()
|
||||
if (notifyServer) notifyClosed(sessionId)
|
||||
val screen = session.screen ?: return
|
||||
val client = Minecraft.getInstance()
|
||||
client.execute {
|
||||
if (client.screen === screen) {
|
||||
if (screen is MolangUiContainerScreen && client.player?.containerMenu === screen.menu) {
|
||||
if (notifyServer) client.player?.closeContainer() else client.player?.clientSideCloseContainer()
|
||||
} else client.setScreen(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onScreenRemoved(sessionId: UUID, screen: Screen, tree: MolangUiWidgetTree) {
|
||||
tree.cleanup()
|
||||
if (sessions[sessionId]?.screen !== screen) return
|
||||
sessions.remove(sessionId)
|
||||
notifyClosed(sessionId)
|
||||
}
|
||||
|
||||
fun updateArgs(sessionId: UUID, args: Map<String, String>, variables: Map<String, Double> = emptyMap()) {
|
||||
val session = sessions[sessionId] ?: return
|
||||
try {
|
||||
val parameters = MolangUiParameters.decode(session.definition.parameters, args)
|
||||
MolangUiParameters.validateVariables(variables)
|
||||
if (variables.isNotEmpty()) session.tree.updateVariables(variables)
|
||||
if (parameters.isNotEmpty()) session.tree.updateParameters(parameters)
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.warn("Rejected invalid Molang UI update for '{}'", session.tree.guiId, error)
|
||||
close(sessionId, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun renderOverlays(guiGraphics: GuiGraphics, delta: Float) {
|
||||
sessions.values.toList().forEach { it.overlay?.render(guiGraphics, delta) }
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
val old = sessions.values.toList()
|
||||
sessions.clear()
|
||||
pendingContainerId = null
|
||||
old.forEach { it.tree.cleanup() }
|
||||
}
|
||||
|
||||
fun hasExclusiveOverlay(): Boolean = sessions.values.any { it.overlay?.exclusive == true }
|
||||
|
||||
fun onResourceReload() {
|
||||
for ((id, session) in sessions.toMap()) {
|
||||
val previous = session.definition
|
||||
val definition = MolangUiRegistry.getDefinition(session.tree.guiId)
|
||||
if (definition == null || definition.contractVersion != previous.contractVersion ||
|
||||
definition.mode != previous.mode ||
|
||||
definition.exclusive != previous.exclusive || definition.parameters != previous.parameters ||
|
||||
definition.actions != previous.actions) {
|
||||
close(id, true)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
session.tree.rebuildFromDefinition(definition)
|
||||
session.definition = definition
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.error("Failed to reload Molang UI '{}'", session.tree.guiId, error)
|
||||
close(id, true)
|
||||
}
|
||||
}
|
||||
MolangUiCompatibilityRequestHandler.respond()
|
||||
}
|
||||
|
||||
fun tick(nowNanos: Long = System.nanoTime()) {
|
||||
val id = pendingContainerId ?: return
|
||||
val session = sessions[id] ?: return
|
||||
if (nowNanos - session.preparedAtNanos > 5_000_000_000L) close(id, true)
|
||||
}
|
||||
|
||||
private fun notifyClosed(sessionId: UUID) {
|
||||
if (Minecraft.getInstance().connection == null) return
|
||||
try {
|
||||
MolangUiClosedPacket(sessionId).sendToServer()
|
||||
} catch (error: Exception) {
|
||||
Cobblemon.LOGGER.warn("Failed to notify server of closed Molang UI '{}'", sessionId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
import com.cobblemon.mod.common.api.molang.function.GeneralMoLangFunctions
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiParameters
|
||||
import com.cobblemon.mod.common.api.molangui.ParameterValue
|
||||
import com.cobblemon.mod.common.api.molangui.asMoLangValue
|
||||
import net.minecraft.client.resources.language.I18n
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
|
||||
internal object MolangUiClientRuntimeFactory {
|
||||
fun create(guiId: ResourceLocation, parameters: Map<String, ParameterValue>, variables: Map<String, Double>) =
|
||||
MoLangRuntime().also { runtime ->
|
||||
GeneralMoLangFunctions.functions.filterKeys { it in MolangUiParameters.GENERAL_FUNCTIONS }.forEach { (name, function) ->
|
||||
runtime.environment.query.addFunction(name) { function(it) }
|
||||
}
|
||||
runtime.environment.query.addFunction("gui_id") { StringValue(guiId.toString()) }
|
||||
runtime.environment.query.addFunction("translate") { params ->
|
||||
StringValue(I18n.get(params.getString(0), *params.params.drop(1).map { it.asString() }.toTypedArray()))
|
||||
}
|
||||
runtime.environment.query.addFunction("item_name") { params ->
|
||||
val id = ResourceLocation.tryParse(params.getString(0))
|
||||
val item = id?.takeIf(BuiltInRegistries.ITEM::containsKey)?.let(BuiltInRegistries.ITEM::get)
|
||||
StringValue(item?.description?.string ?: "")
|
||||
}
|
||||
updateParameters(runtime, parameters)
|
||||
updateVariables(runtime, variables)
|
||||
}
|
||||
|
||||
fun updateFrame(runtime: MoLangRuntime, guiTime: Float, delta: Float, width: Int, height: Int, mouseX: Double, mouseY: Double) {
|
||||
runtime.environment.query.addFunction("gui_time") { DoubleValue(guiTime.toDouble()) }
|
||||
runtime.environment.query.addFunction("delta") { DoubleValue(delta.toDouble()) }
|
||||
runtime.environment.query.addFunction("screen_width") { DoubleValue(width.toDouble()) }
|
||||
runtime.environment.query.addFunction("screen_height") { DoubleValue(height.toDouble()) }
|
||||
runtime.environment.query.addFunction("mouse_x") { DoubleValue(mouseX) }
|
||||
runtime.environment.query.addFunction("mouse_y") { DoubleValue(mouseY) }
|
||||
}
|
||||
|
||||
fun updateParameters(runtime: MoLangRuntime, parameters: Map<String, ParameterValue>) {
|
||||
MolangUiParameters.validate(parameters)
|
||||
parameters.forEach { (key, value) ->
|
||||
val resolved = value.asMoLangValue()
|
||||
runtime.environment.query.addFunction(key) { resolved }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateVariables(runtime: MoLangRuntime, variables: Map<String, Double>) {
|
||||
MolangUiParameters.validateVariables(variables)
|
||||
variables.forEach { (key, value) ->
|
||||
runtime.environment.setSimpleVariable(key.removePrefix("v."), DoubleValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import java.util.UUID
|
||||
|
||||
class MolangUiContainerScreen(
|
||||
menu: MolangContainerMenu,
|
||||
playerInventory: Inventory,
|
||||
val sessionId: UUID,
|
||||
val widgetTree: MolangUiWidgetTree
|
||||
) : AbstractContainerScreen<MolangContainerMenu>(menu, playerInventory, Component.empty()) {
|
||||
private val widgetButtons = mutableSetOf<Int>()
|
||||
|
||||
init {
|
||||
titleLabelX = Int.MAX_VALUE
|
||||
titleLabelY = Int.MAX_VALUE
|
||||
inventoryLabelX = Int.MAX_VALUE
|
||||
inventoryLabelY = Int.MAX_VALUE
|
||||
}
|
||||
|
||||
override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, delta: Float) {
|
||||
widgetTree.render(guiGraphics, width, height, mouseX.toDouble(), mouseY.toDouble(), delta)
|
||||
super.render(guiGraphics, mouseX, mouseY, delta)
|
||||
}
|
||||
|
||||
override fun renderBg(guiGraphics: GuiGraphics, delta: Float, mouseX: Int, mouseY: Int) {
|
||||
}
|
||||
|
||||
override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
|
||||
if (menu.slots.any { it.isActive && isHovering(it.x, it.y, 16, 16, mouseX, mouseY) }) {
|
||||
return super.mouseClicked(mouseX, mouseY, button)
|
||||
}
|
||||
if (widgetTree.onClick(mouseX, mouseY, button)) {
|
||||
widgetButtons.add(button)
|
||||
return true
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button)
|
||||
}
|
||||
|
||||
override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean =
|
||||
widgetButtons.remove(button) || super.mouseReleased(mouseX, mouseY, button)
|
||||
|
||||
override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, dragX: Double, dragY: Double): Boolean =
|
||||
button in widgetButtons || super.mouseDragged(mouseX, mouseY, button, dragX, dragY)
|
||||
|
||||
override fun mouseScrolled(mouseX: Double, mouseY: Double, horizontalAmount: Double, verticalAmount: Double): Boolean {
|
||||
val handled = widgetTree.onScroll(mouseX, mouseY, verticalAmount)
|
||||
return handled || super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)
|
||||
}
|
||||
|
||||
override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
|
||||
val handled = widgetTree.onKeyPress(keyCode, scanCode, modifiers)
|
||||
if (handled) return true
|
||||
return super.keyPressed(keyCode, scanCode, modifiers)
|
||||
}
|
||||
|
||||
override fun removed() {
|
||||
try {
|
||||
super.removed()
|
||||
} finally {
|
||||
widgetButtons.clear()
|
||||
MolangUiClientManager.onScreenRemoved(sessionId, this, widgetTree)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isPauseScreen(): Boolean = false
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
class MolangUiOverlay(
|
||||
private val widgetTree: MolangUiWidgetTree,
|
||||
val exclusive: Boolean
|
||||
) {
|
||||
fun render(guiGraphics: GuiGraphics, delta: Float) {
|
||||
val mc = Minecraft.getInstance()
|
||||
val screenWidth = mc.window.guiScaledWidth
|
||||
val screenHeight = mc.window.guiScaledHeight
|
||||
if (screenWidth <= 0 || screenHeight <= 0 || mc.window.screenWidth <= 0 || mc.window.screenHeight <= 0) return
|
||||
val mouseX = (mc.mouseHandler.xpos() * screenWidth / mc.window.screenWidth)
|
||||
val mouseY = (mc.mouseHandler.ypos() * screenHeight / mc.window.screenHeight)
|
||||
|
||||
widgetTree.render(guiGraphics, screenWidth, screenHeight, mouseX, mouseY, delta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.client.gui.screens.Screen
|
||||
import net.minecraft.network.chat.Component
|
||||
import java.util.UUID
|
||||
|
||||
class MolangUiScreen(
|
||||
val sessionId: UUID,
|
||||
val widgetTree: MolangUiWidgetTree
|
||||
) : Screen(Component.empty()) {
|
||||
|
||||
override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, delta: Float) {
|
||||
widgetTree.render(guiGraphics, width, height, mouseX.toDouble(), mouseY.toDouble(), delta)
|
||||
}
|
||||
|
||||
override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
|
||||
val handled = widgetTree.onClick(mouseX, mouseY, button)
|
||||
return handled || super.mouseClicked(mouseX, mouseY, button)
|
||||
}
|
||||
|
||||
override fun mouseScrolled(mouseX: Double, mouseY: Double, horizontalAmount: Double, verticalAmount: Double): Boolean {
|
||||
val handled = widgetTree.onScroll(mouseX, mouseY, verticalAmount)
|
||||
return handled || super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)
|
||||
}
|
||||
|
||||
override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
|
||||
val handled = widgetTree.onKeyPress(keyCode, scanCode, modifiers)
|
||||
if (handled) return true
|
||||
return super.keyPressed(keyCode, scanCode, modifiers)
|
||||
}
|
||||
|
||||
override fun removed() {
|
||||
try {
|
||||
super.removed()
|
||||
} finally {
|
||||
MolangUiClientManager.onScreenRemoved(sessionId, this, widgetTree)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isPauseScreen(): Boolean = false
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.mojang.blaze3d.systems.RenderSystem
|
||||
import com.cobblemon.mod.common.client.render.RenderAlphaScope
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.ParameterValue
|
||||
import com.cobblemon.mod.common.api.molangui.event.ClientEventProcessor
|
||||
import com.cobblemon.mod.common.api.molangui.event.WidgetEvent
|
||||
import com.cobblemon.mod.common.api.molangui.event.WidgetEventType
|
||||
import com.cobblemon.mod.common.api.molangui.layout.LayoutEngine
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetExpansion
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetType
|
||||
import com.cobblemon.mod.common.client.gui.molangui.renderer.NumberInputRenderer
|
||||
import com.cobblemon.mod.common.client.gui.molangui.renderer.WidgetRendererRegistry
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.client.Minecraft
|
||||
import com.cobblemon.mod.common.util.lang
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import org.lwjgl.glfw.GLFW.GLFW_KEY_ESCAPE
|
||||
import java.util.UUID
|
||||
|
||||
class MolangUiWidgetTree(
|
||||
val guiId: ResourceLocation,
|
||||
val sessionId: UUID,
|
||||
var rootDefinition: WidgetDefinition,
|
||||
parameters: Map<String, ParameterValue>,
|
||||
variables: Map<String, Double>,
|
||||
private val nanoTime: () -> Long = System::nanoTime
|
||||
) {
|
||||
private val startedAt = nanoTime()
|
||||
private var previousFrame = startedAt
|
||||
private val currentParameters = parameters.toMutableMap()
|
||||
val runtime: MoLangRuntime = MolangUiClientRuntimeFactory.create(guiId, parameters, variables)
|
||||
var root: WidgetInstance = WidgetExpansion.refresh(rootDefinition, runtime)
|
||||
private set
|
||||
private var hoveredPath: List<WidgetInstance> = emptyList()
|
||||
private var hoverRevision = 0L
|
||||
private var expansionError: String? = null
|
||||
private var closed = false
|
||||
private var lastWidth = 0
|
||||
private var lastHeight = 0
|
||||
|
||||
fun updateParameters(parameters: Map<String, ParameterValue>) {
|
||||
MolangUiClientRuntimeFactory.updateParameters(runtime, parameters)
|
||||
currentParameters.putAll(parameters)
|
||||
}
|
||||
|
||||
fun updateVariables(variables: Map<String, Double>) {
|
||||
MolangUiClientRuntimeFactory.updateVariables(runtime, variables)
|
||||
}
|
||||
|
||||
fun rebuildFromDefinition(newDefinition: MolangUiDefinition) {
|
||||
this.rootDefinition = newDefinition.root
|
||||
|
||||
val newVariables = newDefinition.variables.filterKeys { it.removePrefix("v.") !in runtime.environment.variable.map }
|
||||
MolangUiClientRuntimeFactory.updateVariables(runtime, newVariables)
|
||||
|
||||
MolangUiClientRuntimeFactory.updateParameters(runtime, currentParameters)
|
||||
|
||||
prepare(lastWidth, lastHeight)
|
||||
}
|
||||
|
||||
internal fun prepare(width: Int, height: Int): Boolean {
|
||||
if (closed) return false
|
||||
lastWidth = width
|
||||
lastHeight = height
|
||||
try {
|
||||
root = WidgetExpansion.refresh(rootDefinition, runtime, root)
|
||||
fun visibility(widget: WidgetInstance, parentEnabled: Boolean) {
|
||||
widget.withItemContext(runtime) {
|
||||
widget.visible = widget.definition.getProperty("visible")?.resolveBoolean(runtime) ?: true
|
||||
widget.enabled = parentEnabled && (widget.definition.getProperty("enabled")?.resolveBoolean(runtime) ?: true)
|
||||
}
|
||||
widget.children.forEach { visibility(it, widget.enabled) }
|
||||
}
|
||||
visibility(root, true)
|
||||
LayoutEngine.layout(root, width.toFloat(), height.toFloat(), runtime)
|
||||
clampScrollOffsets(root)
|
||||
expansionError = null
|
||||
return true
|
||||
} catch (error: Exception) {
|
||||
clearHover()
|
||||
val reason = error.message ?: error.javaClass.simpleName
|
||||
if (reason != expansionError) Cobblemon.LOGGER.warn("Invalid Molang UI {}: {}", guiId, reason)
|
||||
expansionError = reason
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun render(guiGraphics: GuiGraphics, screenWidth: Int, screenHeight: Int, mouseX: Double, mouseY: Double, delta: Float) {
|
||||
if (closed) return
|
||||
val now = nanoTime()
|
||||
val elapsed = ((now - startedAt).coerceAtLeast(0) / 1_000_000_000.0).toFloat()
|
||||
val frameDuration = ((now - previousFrame).coerceAtLeast(0) / 1_000_000_000.0).toFloat()
|
||||
previousFrame = now
|
||||
MolangUiClientRuntimeFactory.updateFrame(runtime, elapsed, frameDuration, screenWidth, screenHeight, mouseX, mouseY)
|
||||
if (!prepare(screenWidth, screenHeight)) {
|
||||
if (!closed) guiGraphics.drawString(Minecraft.getInstance().font, lang("molang_ui.invalid_view"), 8, 8, 0xFFFFFF)
|
||||
return
|
||||
}
|
||||
|
||||
if (!updateHover(mouseX.toFloat(), mouseY.toFloat())) return
|
||||
|
||||
renderWidget(root, guiGraphics, mouseX.toFloat(), mouseY.toFloat(), delta)
|
||||
}
|
||||
|
||||
private fun updateHover(mouseX: Float, mouseY: Float): Boolean {
|
||||
val target = ClientEventProcessor.hitTest(root, mouseX, mouseY)
|
||||
val next = generateSequence(target) { it.parent }.toList().asReversed()
|
||||
if (next == hoveredPath) return true
|
||||
val previous = hoveredPath
|
||||
val shared = previous.zip(next).takeWhile { (old, new) -> old === new }.size
|
||||
val exited = previous.drop(shared).asReversed()
|
||||
val entered = next.drop(shared)
|
||||
exited.forEach { it.hovered = false }
|
||||
entered.forEach { it.hovered = true }
|
||||
hoveredPath = next
|
||||
val revision = ++hoverRevision
|
||||
var handled = false
|
||||
for (widget in exited) {
|
||||
handled = dispatchHover(widget, WidgetEventType.HOVER_EXIT) || handled
|
||||
if (closed || hoverRevision != revision) return false
|
||||
}
|
||||
for (widget in entered) {
|
||||
handled = dispatchHover(widget, WidgetEventType.HOVER_ENTER) || handled
|
||||
if (closed || hoverRevision != revision) return false
|
||||
}
|
||||
return !handled || prepare(lastWidth, lastHeight)
|
||||
}
|
||||
|
||||
private fun dispatchHover(widget: WidgetInstance, type: WidgetEventType): Boolean {
|
||||
val definition = widget.definition
|
||||
if (type.id !in definition.events && type.clientId !in definition.clientEvents) return false
|
||||
widget.withItemContext(runtime) {
|
||||
ClientEventProcessor.processEvent(WidgetEvent(type, definition.id), definition, runtime, sessionId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun clearHover() {
|
||||
hoveredPath.forEach { it.hovered = false }
|
||||
hoveredPath = emptyList()
|
||||
hoverRevision++
|
||||
}
|
||||
|
||||
fun onClick(mouseX: Double, mouseY: Double, button: Int): Boolean {
|
||||
if (!prepare(lastWidth, lastHeight)) return false
|
||||
val hit = ClientEventProcessor.hitTest(root, mouseX.toFloat(), mouseY.toFloat()) ?: return false
|
||||
|
||||
if (button == 0 && hit.definition.type == WidgetType.NUMBER_INPUT) {
|
||||
var contentY = mouseY.toFloat()
|
||||
var parent = hit.parent
|
||||
while (parent != null) {
|
||||
if (parent.definition.type == WidgetType.SCROLL_PANEL) contentY -= parent.scrollOffset
|
||||
parent = parent.parent
|
||||
}
|
||||
if (hit.withItemContext(runtime) {
|
||||
NumberInputRenderer.handleClick(hit, runtime, mouseX.toFloat(), contentY) { value ->
|
||||
bubbleEvent(hit, WidgetEventType.CHANGE, mapOf("value" to value.toDouble()))
|
||||
}
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
val data = mapOf("button" to button.toDouble())
|
||||
return bubbleEvent(hit, WidgetEventType.CLICK, data)
|
||||
}
|
||||
|
||||
fun onScroll(mouseX: Double, mouseY: Double, amount: Double): Boolean {
|
||||
if (!amount.isFinite() || !prepare(lastWidth, lastHeight)) return false
|
||||
val hit = ClientEventProcessor.hitTest(root, mouseX.toFloat(), mouseY.toFloat()) ?: return false
|
||||
|
||||
val scrollPanel = findParentOfType(hit, WidgetType.SCROLL_PANEL)
|
||||
if (scrollPanel != null) {
|
||||
scrollPanel.scrollOffset = (scrollPanel.scrollOffset.toDouble() + amount * 10.0)
|
||||
.coerceIn(minimumScroll(scrollPanel).toDouble(), 0.0).toFloat()
|
||||
bubbleEvent(hit, WidgetEventType.SCROLL, mapOf("amount" to amount))
|
||||
return true
|
||||
}
|
||||
|
||||
val data = mapOf("amount" to amount)
|
||||
return bubbleEvent(hit, WidgetEventType.SCROLL, data)
|
||||
}
|
||||
|
||||
private fun findParentOfType(widget: WidgetInstance, type: WidgetType): WidgetInstance? {
|
||||
var current: WidgetInstance? = widget
|
||||
while (current != null) {
|
||||
if (current.definition.type == type) return current
|
||||
current = current.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun bubbleEvent(target: WidgetInstance, type: WidgetEventType, data: Map<String, Double>): Boolean {
|
||||
var current: WidgetInstance? = target
|
||||
while (current != null) {
|
||||
val def = current.definition
|
||||
val hasServer = def.events.containsKey(type.id)
|
||||
val hasClient = def.clientEvents.containsKey(type.clientId)
|
||||
if (hasServer || hasClient) {
|
||||
val event = WidgetEvent(type, target.definition.id, data)
|
||||
target.withItemContext(runtime) { ClientEventProcessor.processEvent(event, def, runtime, sessionId) }
|
||||
return true
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onKeyPress(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
|
||||
if (keyCode == GLFW_KEY_ESCAPE || !prepare(lastWidth, lastHeight) || !root.visible || !root.enabled) return false
|
||||
val target = root
|
||||
val hasHandler = target.definition.events.containsKey("on_key") || target.definition.clientEvents.containsKey("on_key_client")
|
||||
if (!hasHandler) return false
|
||||
val event = WidgetEvent(WidgetEventType.KEY, target.definition.id,
|
||||
mapOf("key" to keyCode.toDouble(), "scan" to scanCode.toDouble(), "modifiers" to modifiers.toDouble()))
|
||||
target.withItemContext(runtime) { ClientEventProcessor.processEvent(event, target.definition, runtime, sessionId) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
closed = true
|
||||
clearHover()
|
||||
root.children.clear()
|
||||
root.rendererState = null
|
||||
}
|
||||
|
||||
private fun renderWidget(widget: WidgetInstance, guiGraphics: GuiGraphics, mouseX: Float, mouseY: Float, delta: Float): Unit = widget.withItemContext(runtime) {
|
||||
if (!widget.visible) return
|
||||
val opacity = widget.definition.getProperty("opacity")?.resolveFloat(runtime) ?: 1f
|
||||
if (!opacity.isFinite() || opacity <= 0f) return
|
||||
if (opacity >= 1f) {
|
||||
renderContents(widget, guiGraphics, mouseX, mouseY, delta)
|
||||
return
|
||||
}
|
||||
val color = RenderSystem.getShaderColor().copyOf()
|
||||
try {
|
||||
guiGraphics.setColor(color[0], color[1], color[2], color[3] * opacity)
|
||||
renderContents(widget, guiGraphics, mouseX, mouseY, delta)
|
||||
} finally {
|
||||
guiGraphics.setColor(color[0], color[1], color[2], color[3])
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderContents(widget: WidgetInstance, guiGraphics: GuiGraphics, mouseX: Float, mouseY: Float, delta: Float) {
|
||||
val renderer = WidgetRendererRegistry.get(widget.definition.type)
|
||||
if (widget.definition.type == WidgetType.POKEMON_RENDER || widget.definition.type == WidgetType.ITEM_SLOT) {
|
||||
RenderAlphaScope.render { renderer?.render(widget, guiGraphics, runtime, mouseX, mouseY, delta) }
|
||||
} else {
|
||||
renderer?.render(widget, guiGraphics, runtime, mouseX, mouseY, delta)
|
||||
}
|
||||
val isScrollPanel = widget.definition.type == WidgetType.SCROLL_PANEL
|
||||
val scrollOffset = widget.scrollOffset
|
||||
if (isScrollPanel) {
|
||||
val x = widget.absoluteX.toInt()
|
||||
val y = widget.absoluteY.toInt()
|
||||
val w = widget.computedWidth.toInt()
|
||||
val h = widget.computedHeight.toInt()
|
||||
guiGraphics.enableScissor(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
try {
|
||||
if (isScrollPanel) applyScrollOffset(widget, scrollOffset)
|
||||
for (child in widget.children) {
|
||||
renderWidget(child, guiGraphics, mouseX, mouseY, delta)
|
||||
}
|
||||
} finally {
|
||||
if (isScrollPanel) {
|
||||
applyScrollOffset(widget, -scrollOffset)
|
||||
guiGraphics.disableScissor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyScrollOffset(widget: WidgetInstance, offset: Float) {
|
||||
for (child in widget.children) {
|
||||
child.absoluteY += offset
|
||||
applyScrollOffset(child, offset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clampScrollOffsets(widget: WidgetInstance) {
|
||||
if (!widget.visible) return
|
||||
if (widget.definition.type == WidgetType.SCROLL_PANEL) {
|
||||
widget.scrollOffset = if (widget.scrollOffset.isFinite()) {
|
||||
widget.scrollOffset.coerceIn(minimumScroll(widget), 0f)
|
||||
} else 0f
|
||||
}
|
||||
widget.children.forEach(::clampScrollOffsets)
|
||||
}
|
||||
|
||||
private fun minimumScroll(widget: WidgetInstance): Float {
|
||||
val viewportBottom = widget.absoluteY.toDouble() + widget.computedHeight
|
||||
var contentBottom = viewportBottom
|
||||
fun collect(node: WidgetInstance) {
|
||||
if (!node.visible) return
|
||||
contentBottom = maxOf(contentBottom, node.absoluteY.toDouble() + node.computedHeight)
|
||||
if (node.definition.type != WidgetType.SCROLL_PANEL) node.children.forEach(::collect)
|
||||
}
|
||||
widget.children.forEach(::collect)
|
||||
return (viewportBottom - contentBottom).toFloat()
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
|
||||
import com.cobblemon.mod.common.util.lang
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
|
||||
internal class RejectedMolangUiContainerScreen(menu: MolangContainerMenu, inventory: Inventory) :
|
||||
AbstractContainerScreen<MolangContainerMenu>(menu, inventory, lang("molang_ui.invalid_view")) {
|
||||
override fun containerTick() {
|
||||
minecraft?.player?.closeContainer()
|
||||
}
|
||||
|
||||
override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, delta: Float) {
|
||||
graphics.drawString(font, title, 8, 8, 0xFFFFFF)
|
||||
}
|
||||
|
||||
override fun renderBg(graphics: GuiGraphics, delta: Float, mouseX: Int, mouseY: Int) {}
|
||||
override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int) = true
|
||||
override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int) = true
|
||||
override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, dragX: Double, dragY: Double) = true
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
object ButtonRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val enabled = widget.enabled
|
||||
val baseColor = widget.definition.getProperty("color")?.resolveInt(runtime) ?: 0xFF555555.toInt()
|
||||
|
||||
val color = when {
|
||||
!enabled -> 0xFF333333.toInt()
|
||||
widget.hovered -> widget.definition.hoverOverrides["color"]?.resolveInt(runtime) ?: 0xFF777777.toInt()
|
||||
else -> baseColor
|
||||
}
|
||||
|
||||
guiGraphics.fill(
|
||||
widget.absoluteX.toInt(), widget.absoluteY.toInt(),
|
||||
(widget.absoluteX + widget.computedWidth).toInt(),
|
||||
(widget.absoluteY + widget.computedHeight).toInt(),
|
||||
color
|
||||
)
|
||||
|
||||
val text = widget.definition.getProperty("text")?.resolveString(runtime) ?: return
|
||||
val contentWidth = (widget.computedWidth.toInt() - 4).coerceAtLeast(0)
|
||||
if (text.isEmpty() || contentWidth == 0) return
|
||||
val font = Minecraft.getInstance().font
|
||||
if (widget.computedHeight < font.lineHeight + 2) return
|
||||
val displayed = if (font.width(text) <= contentWidth) text else {
|
||||
val suffix = "?"
|
||||
val suffixWidth = font.width(suffix)
|
||||
if (suffixWidth > contentWidth) return
|
||||
font.plainSubstrByWidth(text, contentWidth - suffixWidth) + suffix
|
||||
}
|
||||
val textX = widget.absoluteX + (widget.computedWidth - font.width(displayed)) / 2f
|
||||
val textY = widget.absoluteY + (widget.computedHeight - font.lineHeight) / 2f
|
||||
val textColor = if (enabled) 0xFFFFFF else 0xAAAAAA
|
||||
guiGraphics.enableScissor(widget.absoluteX.toInt(), widget.absoluteY.toInt(),
|
||||
(widget.absoluteX + widget.computedWidth).toInt(), (widget.absoluteY + widget.computedHeight).toInt())
|
||||
try {
|
||||
guiGraphics.drawString(font, displayed, textX.toInt(), textY.toInt(), textColor, true)
|
||||
} finally {
|
||||
guiGraphics.disableScissor()
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
object FillRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val color = widget.definition.getProperty("color")?.resolveInt(runtime) ?: 0xFFFFFFFF.toInt()
|
||||
guiGraphics.fill(
|
||||
widget.absoluteX.toInt(), widget.absoluteY.toInt(),
|
||||
(widget.absoluteX + widget.computedWidth).toInt(),
|
||||
(widget.absoluteY + widget.computedHeight).toInt(),
|
||||
color
|
||||
)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.cobblemon.mod.common.client.render.withStandardBlend
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
|
||||
object ImageRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val textureStr = widget.definition.getProperty("texture")?.resolveString(runtime) ?: return
|
||||
val texture = ResourceLocation.tryParse(textureStr) ?: return
|
||||
if (!widget.absoluteX.isFinite() || !widget.absoluteY.isFinite()) return
|
||||
val width = pixelSize(widget.computedWidth) ?: return
|
||||
val height = pixelSize(widget.computedHeight) ?: return
|
||||
val uvProperty = widget.definition.getProperty("uv")
|
||||
val uv = uvProperty?.raw as? List<*>
|
||||
if (uvProperty != null && uv == null) return
|
||||
val sizeProperty = widget.definition.getProperty("texture_size")
|
||||
val textureSize = sizeProperty?.raw as? List<*>
|
||||
if (sizeProperty != null && textureSize == null) return
|
||||
val texW = pixelSize(component(textureSize, 0, if (uv == null) width.toFloat() else 256f)) ?: return
|
||||
val texH = pixelSize(component(textureSize, 1, if (uv == null) height.toFloat() else 256f)) ?: return
|
||||
val u = component(uv, 0, 0f) ?: return
|
||||
val v = component(uv, 1, 0f) ?: return
|
||||
val uvW = pixelSize(component(uv, 2, if (uv == null) texW.toFloat() else width.toFloat())) ?: return
|
||||
val uvH = pixelSize(component(uv, 3, if (uv == null) texH.toFloat() else height.toFloat())) ?: return
|
||||
withStandardBlend {
|
||||
guiGraphics.blit(
|
||||
texture,
|
||||
widget.absoluteX.toInt(), widget.absoluteY.toInt(), width, height,
|
||||
u, v, uvW, uvH, texW, texH
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun component(values: List<*>?, index: Int, default: Float): Float? {
|
||||
if (values == null || index >= values.size) return default
|
||||
return (values[index] as? Number)?.toFloat()?.takeIf { it.isFinite() }
|
||||
}
|
||||
|
||||
private fun pixelSize(value: Float?): Int? = value?.takeIf {
|
||||
it.isFinite() && it >= 1f && it.toDouble() <= Int.MAX_VALUE.toDouble()
|
||||
}?.toInt()
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.world.item.ItemStack
|
||||
|
||||
object ItemSlotRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val itemId = widget.definition.getProperty("item")?.resolveString(runtime) ?: return
|
||||
val resourceLocation = ResourceLocation.tryParse(itemId) ?: return
|
||||
val item = BuiltInRegistries.ITEM.get(resourceLocation)
|
||||
val stack = ItemStack(item)
|
||||
|
||||
val x = widget.absoluteX.toInt()
|
||||
val y = widget.absoluteY.toInt()
|
||||
|
||||
guiGraphics.renderItem(stack, x, y)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
object LabelRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val font = Minecraft.getInstance().font
|
||||
val text = widget.definition.getProperty("text")?.resolveString(runtime) ?: ""
|
||||
val color = widget.definition.getProperty("color")?.resolveInt(runtime) ?: 0xFFFFFF
|
||||
val shadow = widget.definition.getProperty("shadow")?.resolveBoolean(runtime) ?: false
|
||||
val alignment = widget.definition.getStringProperty("alignment") ?: "left"
|
||||
val scale = widget.definition.getProperty("scale")?.resolveFloat(runtime) ?: 1f
|
||||
if (!scale.isFinite() || scale <= 0f) return
|
||||
val textWidth = font.width(text) * scale
|
||||
|
||||
val x = when (alignment) {
|
||||
"center" -> widget.absoluteX + widget.computedWidth / 2f - textWidth / 2f
|
||||
"right" -> widget.absoluteX + widget.computedWidth - textWidth
|
||||
else -> widget.absoluteX
|
||||
}
|
||||
|
||||
if (!textWidth.isFinite() || !x.isFinite() || !widget.absoluteY.isFinite()) return
|
||||
val pose = guiGraphics.pose()
|
||||
pose.pushPose()
|
||||
try {
|
||||
pose.translate(x, widget.absoluteY, 0f)
|
||||
pose.scale(scale, scale, 1f)
|
||||
guiGraphics.drawString(font, text, 0, 0, color, shadow)
|
||||
} finally {
|
||||
pose.popPose()
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiParameters
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
object NumberInputRenderer : WidgetRenderer {
|
||||
private const val BUTTON_WIDTH = 20
|
||||
|
||||
private data class Values(val min: Int, val max: Int, val step: Int, val current: Int)
|
||||
private data class Bounds(val x: Int, val y: Int, val width: Int, val height: Int) {
|
||||
val buttonWidth = minOf(BUTTON_WIDTH, width / 3)
|
||||
val right get() = x + width
|
||||
val bottom get() = y + height
|
||||
|
||||
fun direction(mouseX: Float, mouseY: Float): Int = when {
|
||||
!mouseX.isFinite() || !mouseY.isFinite() || mouseY < y || mouseY >= bottom -> 0
|
||||
mouseX >= x && mouseX < x + buttonWidth -> -1
|
||||
mouseX >= right - buttonWidth && mouseX < right -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun render(widget: WidgetInstance, guiGraphics: GuiGraphics, runtime: MoLangRuntime, mouseX: Float, mouseY: Float, delta: Float) {
|
||||
val bounds = bounds(widget) ?: return
|
||||
val values = values(widget, runtime) ?: return
|
||||
val font = Minecraft.getInstance().font
|
||||
val textColor = widget.definition.getProperty("color")?.resolveInt(runtime) ?: 0xFFFFFF
|
||||
val buttonColor = widget.definition.getProperty("button_color")?.resolveInt(runtime) ?: 0xFF555555.toInt()
|
||||
val hoverColor = 0xFF777777.toInt()
|
||||
val hovered = if (widget.enabled) bounds.direction(mouseX, mouseY) else 0
|
||||
guiGraphics.fill(bounds.x, bounds.y, bounds.x + bounds.buttonWidth, bounds.bottom,
|
||||
if (hovered == -1 && values.current > values.min) hoverColor else buttonColor)
|
||||
guiGraphics.fill(bounds.right - bounds.buttonWidth, bounds.y, bounds.right, bounds.bottom,
|
||||
if (hovered == 1 && values.current < values.max) hoverColor else buttonColor)
|
||||
|
||||
fun drawText(text: String, left: Int, width: Int) {
|
||||
val textWidth = font.width(text)
|
||||
if (textWidth > width || font.lineHeight > bounds.height) return
|
||||
guiGraphics.drawString(font, text, left + (width - textWidth) / 2,
|
||||
bounds.y + (bounds.height - font.lineHeight) / 2, textColor, false)
|
||||
}
|
||||
drawText("?", bounds.x, bounds.buttonWidth)
|
||||
drawText(values.current.toString(), bounds.x + bounds.buttonWidth, bounds.width - 2 * bounds.buttonWidth)
|
||||
drawText("+", bounds.right - bounds.buttonWidth, bounds.buttonWidth)
|
||||
}
|
||||
|
||||
fun handleClick(widget: WidgetInstance, runtime: MoLangRuntime, mouseX: Float, mouseY: Float, onChange: (Int) -> Unit): Boolean {
|
||||
if (!widget.visible || !widget.enabled) return false
|
||||
val direction = bounds(widget)?.direction(mouseX, mouseY) ?: return false
|
||||
if (direction == 0) return false
|
||||
val values = values(widget, runtime) ?: return false
|
||||
val variable = widget.definition.getStringProperty("variable") ?: return false
|
||||
val name = try { MolangUiParameters.requireVariableName(variable) } catch (_: IllegalArgumentException) { return false }
|
||||
val newValue = (values.current.toLong() + direction * values.step.toLong())
|
||||
.coerceIn(values.min.toLong(), values.max.toLong()).toInt()
|
||||
if (newValue != values.current) {
|
||||
runtime.environment.setSimpleVariable(name, DoubleValue(newValue.toDouble()))
|
||||
onChange(newValue)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun values(widget: WidgetInstance, runtime: MoLangRuntime): Values? {
|
||||
fun integer(key: String, default: Double): Int? {
|
||||
val value = widget.definition.getProperty(key)?.resolveDouble(runtime) ?: default
|
||||
return value.takeIf { it.isFinite() && it >= Int.MIN_VALUE && it <= Int.MAX_VALUE && it % 1.0 == 0.0 }?.toInt()
|
||||
}
|
||||
val min = integer("min", 1.0) ?: return null
|
||||
val max = integer("max", 99.0) ?: return null
|
||||
val step = integer("step", 1.0) ?: return null
|
||||
val variable = widget.definition.getStringProperty("variable")?.removePrefix("v.")
|
||||
val boundValue = variable?.let { runtime.environment.variable.map[it]?.asDouble() } ?: min.toDouble()
|
||||
val current = integer("value", boundValue) ?: return null
|
||||
if (min > max || step <= 0) return null
|
||||
return Values(min, max, step, current)
|
||||
}
|
||||
|
||||
private fun bounds(widget: WidgetInstance): Bounds? {
|
||||
if (!widget.absoluteX.isFinite() || !widget.absoluteY.isFinite() ||
|
||||
!widget.computedWidth.isFinite() || !widget.computedHeight.isFinite()) return null
|
||||
val x = widget.absoluteX.toDouble()
|
||||
val y = widget.absoluteY.toDouble()
|
||||
val width = widget.computedWidth.toDouble()
|
||||
val height = widget.computedHeight.toDouble()
|
||||
if (width < 3 || height < 1 || width > Int.MAX_VALUE || height > Int.MAX_VALUE ||
|
||||
x < Int.MIN_VALUE || y < Int.MIN_VALUE || x + width > Int.MAX_VALUE || y + height > Int.MAX_VALUE) return null
|
||||
return Bounds(x.toInt(), y.toInt(), width.toInt(), height.toInt())
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
object PanelRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val color = widget.definition.getProperty("color")
|
||||
if (color != null) {
|
||||
guiGraphics.fill(
|
||||
widget.absoluteX.toInt(), widget.absoluteY.toInt(),
|
||||
(widget.absoluteX + widget.computedWidth).toInt(),
|
||||
(widget.absoluteY + widget.computedHeight).toInt(),
|
||||
color.resolveInt(runtime)
|
||||
)
|
||||
}
|
||||
ImageRenderer.render(widget, guiGraphics, runtime, mouseX, mouseY, delta)
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.cobblemon.mod.common.client.gui.drawProfilePokemon
|
||||
import com.cobblemon.mod.common.client.render.models.blockbench.FloatingState
|
||||
import com.cobblemon.mod.common.pokemon.RenderablePokemon
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
import org.joml.Quaternionf
|
||||
import org.joml.Vector3f
|
||||
import com.cobblemon.mod.common.util.math.fromEulerXYZDegrees
|
||||
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
|
||||
|
||||
object PokemonRenderRenderer : WidgetRenderer {
|
||||
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val speciesProp = widget.definition.getProperty("species")
|
||||
if (speciesProp == null) return
|
||||
val speciesName = speciesProp.resolveString(runtime)
|
||||
if (speciesName.isEmpty() || speciesName == "0" || speciesName == "0.0") return
|
||||
val species = PokemonSpecies.getByName(speciesName)
|
||||
if (species == null) return
|
||||
val aspectsStr = widget.definition.getProperty("aspects")?.resolveString(runtime) ?: ""
|
||||
val aspects = if (aspectsStr.isNotEmpty()) aspectsStr.split(",").map { it.trim() }.toSet() else emptySet()
|
||||
val scale = widget.definition.getProperty("scale")?.resolveFloat(runtime) ?: 2.5f
|
||||
val rotY = widget.definition.getProperty("rotation_y")?.resolveFloat(runtime) ?: 35f
|
||||
val rotX = widget.definition.getProperty("rotation_x")?.resolveFloat(runtime) ?: 13f
|
||||
if (!scale.isFinite() || scale <= 0f || !rotX.isFinite() || !rotY.isFinite()) return
|
||||
|
||||
val renderablePokemon = RenderablePokemon(species, aspects)
|
||||
|
||||
val state = (widget.rendererState as? FloatingState) ?: FloatingState().also { widget.rendererState = it }
|
||||
|
||||
val x = widget.absoluteX
|
||||
val y = widget.absoluteY
|
||||
val w = widget.computedWidth
|
||||
val h = widget.computedHeight
|
||||
if (!x.isFinite() || !y.isFinite() || !w.isFinite() || !h.isFinite() || w <= 0f || h <= 0f) return
|
||||
|
||||
val matrices = guiGraphics.pose()
|
||||
guiGraphics.enableScissor(x.toInt(), y.toInt(), (x + w).toInt(), (y + h).toInt())
|
||||
try {
|
||||
matrices.pushPose()
|
||||
try {
|
||||
matrices.translate((x + w * 0.5).toDouble(), y.toDouble(), 0.0)
|
||||
matrices.scale(scale, scale, scale)
|
||||
drawProfilePokemon(
|
||||
renderablePokemon = renderablePokemon,
|
||||
matrixStack = matrices,
|
||||
rotation = Quaternionf().fromEulerXYZDegrees(Vector3f(rotX, rotY, 0f)),
|
||||
state = state,
|
||||
partialTicks = delta
|
||||
)
|
||||
} finally {
|
||||
matrices.popPose()
|
||||
}
|
||||
} finally {
|
||||
guiGraphics.disableScissor()
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
object ProgressBarRenderer : WidgetRenderer {
|
||||
override fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
) {
|
||||
val value = widget.definition.getProperty("value")?.resolveDouble(runtime) ?: 0.0
|
||||
val max = widget.definition.getProperty("max")?.resolveDouble(runtime) ?: 100.0
|
||||
val bgColor = widget.definition.getProperty("background_color")?.resolveInt(runtime) ?: 0xFF333333.toInt()
|
||||
val barColor = widget.definition.getProperty("color")?.resolveInt(runtime) ?: 0xFF00FF00.toInt()
|
||||
val direction = widget.definition.getStringProperty("direction") ?: "left_to_right"
|
||||
|
||||
val x = widget.absoluteX.toInt()
|
||||
val y = widget.absoluteY.toInt()
|
||||
val w = widget.computedWidth.toInt()
|
||||
val h = widget.computedHeight.toInt()
|
||||
val fraction = if (max > 0) (value / max).coerceIn(0.0, 1.0) else 0.0
|
||||
|
||||
guiGraphics.fill(x, y, x + w, y + h, bgColor)
|
||||
when (direction) {
|
||||
"left_to_right" -> guiGraphics.fill(x, y, x + (w * fraction).toInt(), y + h, barColor)
|
||||
"right_to_left" -> guiGraphics.fill(x + w - (w * fraction).toInt(), y, x + w, y + h, barColor)
|
||||
"bottom_to_top" -> guiGraphics.fill(x, y + h - (h * fraction).toInt(), x + w, y + h, barColor)
|
||||
"top_to_bottom" -> guiGraphics.fill(x, y, x + w, y + (h * fraction).toInt(), barColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import net.minecraft.client.gui.GuiGraphics
|
||||
|
||||
interface WidgetRenderer {
|
||||
fun render(
|
||||
widget: WidgetInstance,
|
||||
guiGraphics: GuiGraphics,
|
||||
runtime: MoLangRuntime,
|
||||
mouseX: Float,
|
||||
mouseY: Float,
|
||||
delta: Float
|
||||
)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.gui.molangui.renderer
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetType
|
||||
|
||||
object WidgetRendererRegistry {
|
||||
private val renderers = mutableMapOf<WidgetType, WidgetRenderer>()
|
||||
|
||||
fun register(type: WidgetType, renderer: WidgetRenderer) {
|
||||
renderers[type] = renderer
|
||||
}
|
||||
|
||||
fun get(type: WidgetType): WidgetRenderer? = renderers[type]
|
||||
|
||||
fun registerDefaults() {
|
||||
register(WidgetType.PANEL, PanelRenderer)
|
||||
register(WidgetType.LABEL, LabelRenderer)
|
||||
register(WidgetType.IMAGE, ImageRenderer)
|
||||
register(WidgetType.FILL, FillRenderer)
|
||||
register(WidgetType.BUTTON, ButtonRenderer)
|
||||
register(WidgetType.PROGRESS_BAR, ProgressBarRenderer)
|
||||
register(WidgetType.NUMBER_INPUT, NumberInputRenderer)
|
||||
register(WidgetType.ITEM_SLOT, ItemSlotRenderer)
|
||||
register(WidgetType.SCROLL_PANEL, PanelRenderer)
|
||||
register(WidgetType.POKEMON_RENDER, PokemonRenderRenderer)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.net.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.net.ClientNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiClientManager
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiClosePacket
|
||||
import net.minecraft.client.Minecraft
|
||||
|
||||
object MolangUiCloseHandler : ClientNetworkPacketHandler<MolangUiClosePacket> {
|
||||
override fun handle(packet: MolangUiClosePacket, client: Minecraft) {
|
||||
MolangUiClientManager.close(packet.sessionId)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.net.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiRegistry
|
||||
import com.cobblemon.mod.common.api.net.ClientNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiCompatibilityRequestPacket
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiCompatibilityResponsePacket
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
|
||||
object MolangUiCompatibilityRequestHandler : ClientNetworkPacketHandler<MolangUiCompatibilityRequestPacket> {
|
||||
private var requested: Set<ResourceLocation> = emptySet()
|
||||
override fun handle(packet: MolangUiCompatibilityRequestPacket, client: Minecraft) {
|
||||
requested = packet.contracts.keys.toSet()
|
||||
respond()
|
||||
}
|
||||
fun respond() {
|
||||
if (requested.isEmpty()) return
|
||||
val available = MolangUiRegistry.getVersionMap().filterKeys { it in requested }
|
||||
MolangUiCompatibilityResponsePacket(available).sendToServer()
|
||||
}
|
||||
fun reset() { requested = emptySet() }
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.net.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.net.ClientNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiClientManager
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiOpenPacket
|
||||
import net.minecraft.client.Minecraft
|
||||
|
||||
object MolangUiOpenHandler : ClientNetworkPacketHandler<MolangUiOpenPacket> {
|
||||
override fun handle(packet: MolangUiOpenPacket, client: Minecraft) {
|
||||
MolangUiClientManager.open(packet.sessionId, packet.guiId, packet.contractVersion, packet.mode, packet.args, packet.exclusive, packet.hasContainer, packet.slotLayouts)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.net.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.net.ClientNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiClientManager
|
||||
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiUpdatePacket
|
||||
import net.minecraft.client.Minecraft
|
||||
|
||||
object MolangUiUpdateHandler : ClientNetworkPacketHandler<MolangUiUpdatePacket> {
|
||||
override fun handle(packet: MolangUiUpdatePacket, client: Minecraft) {
|
||||
MolangUiClientManager.updateArgs(packet.sessionId, packet.args, packet.variables)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.render
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem
|
||||
import org.lwjgl.opengl.GL11
|
||||
import org.lwjgl.opengl.GL14
|
||||
import kotlin.math.sqrt
|
||||
|
||||
object RenderAlphaScope {
|
||||
private class MaterialState(val color: FloatArray, val blendColor: FloatArray?)
|
||||
|
||||
private class Scope {
|
||||
val materials = ArrayDeque<MaterialState>()
|
||||
}
|
||||
|
||||
private val current = ThreadLocal<Scope>()
|
||||
|
||||
fun render(draw: () -> Unit) {
|
||||
val color = RenderSystem.getShaderColor().copyOf()
|
||||
if (!color[3].isFinite() || color[3] >= 1f) {
|
||||
draw()
|
||||
return
|
||||
}
|
||||
val blend = RenderBlendState.capture()
|
||||
val previous = current.get()
|
||||
current.set(Scope())
|
||||
try {
|
||||
draw()
|
||||
} finally {
|
||||
if (previous == null) current.remove() else current.set(previous)
|
||||
RenderSystem.setShaderColor(color[0], color[1], color[2], color[3])
|
||||
blend.restore()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun afterMaterialSetup() {
|
||||
val scope = current.get() ?: return
|
||||
val color = RenderSystem.getShaderColor().copyOf()
|
||||
val blended = GL11.glIsEnabled(GL11.GL_BLEND)
|
||||
scope.materials.addLast(MaterialState(color, if (blended) null else RenderBlendState.readColor()))
|
||||
val opacity = color[3].coerceIn(0f, 1f)
|
||||
if (!blended) {
|
||||
RenderSystem.enableBlend()
|
||||
// Cutout texels are opaque above the discard threshold; fade their coverage uniformly.
|
||||
GL14.glBlendColor(0f, 0f, 0f, opacity)
|
||||
RenderSystem.blendFuncSeparate(GL14.GL_CONSTANT_ALPHA, GL14.GL_ONE_MINUS_CONSTANT_ALPHA, GL11.GL_ONE, GL11.GL_ZERO)
|
||||
} else {
|
||||
val factor = when (GL11.glGetInteger(GL14.GL_BLEND_SRC_RGB)) {
|
||||
GL11.GL_ONE -> opacity
|
||||
// SRC_COLOR squares the source RGB, so sqrt(alpha) fades glint linearly.
|
||||
GL11.GL_SRC_COLOR -> sqrt(opacity)
|
||||
else -> 1f
|
||||
}
|
||||
if (factor != 1f) RenderSystem.setShaderColor(color[0] * factor, color[1] * factor, color[2] * factor, color[3])
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun afterMaterialClear() {
|
||||
val material = current.get()?.materials?.removeLastOrNull() ?: return
|
||||
val color = material.color
|
||||
RenderSystem.setShaderColor(color[0], color[1], color[2], color[3])
|
||||
material.blendColor?.let { RenderBlendState.restoreColor(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.client.render
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem
|
||||
import org.lwjgl.opengl.ARBImaging.GL_BLEND_COLOR
|
||||
import org.lwjgl.opengl.GL11
|
||||
import org.lwjgl.opengl.GL14
|
||||
|
||||
internal class RenderBlendState private constructor(
|
||||
private val enabled: Boolean,
|
||||
private val sourceRgb: Int,
|
||||
private val destinationRgb: Int,
|
||||
private val sourceAlpha: Int,
|
||||
private val destinationAlpha: Int,
|
||||
private val color: FloatArray
|
||||
) {
|
||||
fun restore() {
|
||||
RenderSystem.blendFuncSeparate(sourceRgb, destinationRgb, sourceAlpha, destinationAlpha)
|
||||
restoreColor(color)
|
||||
if (enabled) RenderSystem.enableBlend() else RenderSystem.disableBlend()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun capture() = RenderBlendState(
|
||||
GL11.glIsEnabled(GL11.GL_BLEND),
|
||||
GL11.glGetInteger(GL14.GL_BLEND_SRC_RGB), GL11.glGetInteger(GL14.GL_BLEND_DST_RGB),
|
||||
GL11.glGetInteger(GL14.GL_BLEND_SRC_ALPHA), GL11.glGetInteger(GL14.GL_BLEND_DST_ALPHA), readColor()
|
||||
)
|
||||
|
||||
fun readColor() = FloatArray(4).also { GL11.glGetFloatv(GL_BLEND_COLOR, it) }
|
||||
|
||||
fun restoreColor(color: FloatArray) = GL14.glBlendColor(color[0], color[1], color[2], color[3])
|
||||
}
|
||||
}
|
||||
|
||||
internal inline fun withStandardBlend(draw: () -> Unit) {
|
||||
val state = RenderBlendState.capture()
|
||||
try {
|
||||
RenderSystem.enableBlend()
|
||||
RenderSystem.defaultBlendFunc()
|
||||
draw()
|
||||
} finally {
|
||||
state.restore()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.command
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiContractRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiOpenRequest
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiOpenResult
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.util.lang
|
||||
import com.mojang.brigadier.Command
|
||||
import com.mojang.brigadier.CommandDispatcher
|
||||
import net.minecraft.commands.CommandSourceStack
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.commands.arguments.ResourceLocationArgument
|
||||
|
||||
object MolangUiCommand {
|
||||
private const val UI_ID = "ui"
|
||||
fun register(dispatcher: CommandDispatcher<CommandSourceStack>) {
|
||||
dispatcher.register(Commands.literal("molangui").requires { it.hasPermission(2) }
|
||||
.then(Commands.literal("open").then(Commands.argument(UI_ID, ResourceLocationArgument.id())
|
||||
.suggests { _, builder -> MolangUiContractRegistry.getAllContracts().keys.forEach { builder.suggest(it.toString()) }; builder.buildFuture() }
|
||||
.executes { context ->
|
||||
val id = ResourceLocationArgument.getId(context, UI_ID)
|
||||
when (val result = MolangUiService.open(context.source.playerOrException, MolangUiOpenRequest(id))) {
|
||||
is MolangUiOpenResult.Opened -> {
|
||||
context.source.sendSuccess({ lang("molang_ui.command.opened", id.toString(), result.session.sessionId.toString()) }, false)
|
||||
Command.SINGLE_SUCCESS
|
||||
}
|
||||
is MolangUiOpenResult.Rejected -> {
|
||||
context.source.sendFailure(lang("molang_ui.command.rejected", id.toString(),
|
||||
lang("molang_ui.rejection.${result.reason.name.lowercase()}")))
|
||||
0
|
||||
}
|
||||
}
|
||||
}))
|
||||
.then(Commands.literal("list").executes { context ->
|
||||
val contracts = MolangUiContractRegistry.getAllContracts()
|
||||
context.source.sendSuccess({ lang("molang_ui.command.list", contracts.size, contracts.keys.joinToString()) }, false)
|
||||
Command.SINGLE_SUCCESS
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ import com.cobblemon.mod.common.api.item.HeldItems
|
||||
import com.cobblemon.mod.common.api.mark.Marks
|
||||
import com.cobblemon.mod.common.api.moves.Moves
|
||||
import com.cobblemon.mod.common.api.moves.animations.ActionEffects
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiContractRegistry
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.api.vending.VendingMachineCatalogs
|
||||
import com.cobblemon.mod.common.api.npc.NPCClasses
|
||||
import com.cobblemon.mod.common.api.npc.NPCPresets
|
||||
import com.cobblemon.mod.common.api.pokeball.PokeBalls
|
||||
@@ -69,6 +72,14 @@ object CobblemonDataProvider : DataProvider {
|
||||
|
||||
fun registerDefaults() {
|
||||
this.register(CobblemonScripts, reloadable = true)
|
||||
this.register(MolangUiContractRegistry, reloadable = true)
|
||||
this.register(VendingMachineCatalogs, reloadable = true)
|
||||
MolangUiContractRegistry.observable.subscribe {
|
||||
server()?.execute {
|
||||
MolangUiService.closeAll()
|
||||
server()?.playerList?.players?.forEach(MolangUiService::requestCompatibility)
|
||||
}
|
||||
}
|
||||
this.register(SpeciesFeatures, reloadable = false)
|
||||
this.register(GlobalSpeciesFeatures, reloadable = false)
|
||||
this.register(SpeciesFeatureAssignments, reloadable = false)
|
||||
|
||||
@@ -330,6 +330,7 @@ object CobblemonItemGroups {
|
||||
|
||||
// entries.accept(CobblemonItems.INCENSE_SWEET) // TODO after 1.7
|
||||
entries.accept(CobblemonItems.DISPLAY_CASE)
|
||||
entries.accept(CobblemonItems.VENDING_MACHINE)
|
||||
entries.accept(CobblemonItems.APRICORN_LOG)
|
||||
entries.accept(CobblemonItems.APRICORN_WOOD)
|
||||
entries.accept(CobblemonItems.STRIPPED_APRICORN_LOG)
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.client.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import java.util.UUID
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
|
||||
class MolangUiClosePacket(
|
||||
val sessionId: UUID
|
||||
) : NetworkPacket<MolangUiClosePacket> {
|
||||
|
||||
override val id = ID
|
||||
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
buffer.writeUUID(sessionId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_close")
|
||||
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiClosePacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
return MolangUiClosePacket(buffer.readUUID())
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.client.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
|
||||
class MolangUiCompatibilityRequestPacket(val contracts: Map<ResourceLocation, String>) : NetworkPacket<MolangUiCompatibilityRequestPacket> {
|
||||
override val id = ID
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
require(contracts.size <= MolangUiPacketLimits.MAX_CONTRACTS)
|
||||
buffer.writeVarInt(contracts.size)
|
||||
contracts.forEach { (id, version) ->
|
||||
buffer.writeUtf(id.toString(), MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
buffer.writeUtf(version, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_compatibility_request")
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiCompatibilityRequestPacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
val count = buffer.readVarInt()
|
||||
require(count in 0..MolangUiPacketLimits.MAX_CONTRACTS)
|
||||
val contracts = linkedMapOf<ResourceLocation, String>()
|
||||
repeat(count) {
|
||||
val id = ResourceLocation.tryParse(buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH))
|
||||
?: throw IllegalArgumentException("invalid Molang UI id")
|
||||
contracts[id] = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
}
|
||||
return MolangUiCompatibilityRequestPacket(contracts)
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.client.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.GuiMode
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiParameters
|
||||
import com.cobblemon.mod.common.api.molangui.container.SlotLayout
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import java.util.UUID
|
||||
|
||||
class MolangUiOpenPacket(
|
||||
val sessionId: UUID, val guiId: ResourceLocation, val contractVersion: String, val mode: GuiMode,
|
||||
val args: Map<String, String>, val exclusive: Boolean,
|
||||
val hasContainer: Boolean = false, val slotLayouts: Map<String, SlotLayout> = emptyMap()
|
||||
) : NetworkPacket<MolangUiOpenPacket> {
|
||||
init {
|
||||
MolangUiPacketLimits.requireIdentifier(guiId.toString(), "UI id")
|
||||
MolangUiPacketLimits.requireIdentifier(contractVersion, "contract version")
|
||||
require(args.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS) { "too many UI parameters" }
|
||||
args.forEach { (name, value) ->
|
||||
MolangUiParameters.requireName(name)
|
||||
MolangUiPacketLimits.requireValue(value, "parameter '$name'")
|
||||
}
|
||||
require(hasContainer == slotLayouts.isNotEmpty()) { "container flag and slot layouts disagree" }
|
||||
require(!hasContainer || mode == GuiMode.SCREEN) { "overlay views cannot have containers" }
|
||||
SlotLayout.validateCollections(slotLayouts)
|
||||
}
|
||||
|
||||
override val id = ID
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
val startIndex = buffer.writerIndex()
|
||||
buffer.writeUUID(sessionId)
|
||||
buffer.writeUtf(guiId.toString(), MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
buffer.writeUtf(contractVersion, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
buffer.writeEnum(mode)
|
||||
buffer.writeVarInt(args.size)
|
||||
args.forEach { (key, value) -> buffer.writeUtf(key, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH).writeUtf(value, MolangUiPacketLimits.MAX_VALUE_LENGTH) }
|
||||
buffer.writeBoolean(exclusive).writeBoolean(hasContainer)
|
||||
if (hasContainer) {
|
||||
buffer.writeVarInt(slotLayouts.size)
|
||||
slotLayouts.forEach { (name, layout) ->
|
||||
buffer.writeUtf(name, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
listOf(layout.offsetX, layout.offsetY, layout.columns, layout.rows, layout.slotWidth, layout.slotHeight, layout.spacingX, layout.spacingY)
|
||||
.forEach(buffer::writeVarInt)
|
||||
}
|
||||
}
|
||||
require(buffer.writerIndex() - startIndex <= MolangUiPacketLimits.MAX_PACKET_BYTES) { "UI open packet is too large" }
|
||||
}
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_open")
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiOpenPacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
val sessionId = buffer.readUUID()
|
||||
val guiId = ResourceLocation.tryParse(buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)) ?: error("invalid UI id")
|
||||
val version = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
val mode = buffer.readEnum(GuiMode::class.java)
|
||||
val argCount = buffer.readVarInt()
|
||||
require(argCount in 0..MolangUiPacketLimits.MAX_ACTION_FIELDS)
|
||||
val args = linkedMapOf<String, String>()
|
||||
repeat(argCount) {
|
||||
val name = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
require(name !in args) { "duplicate UI parameter '$name'" }
|
||||
args[name] = buffer.readUtf(MolangUiPacketLimits.MAX_VALUE_LENGTH)
|
||||
}
|
||||
val exclusive = buffer.readBoolean()
|
||||
val hasContainer = buffer.readBoolean()
|
||||
val layouts = linkedMapOf<String, SlotLayout>()
|
||||
if (hasContainer) {
|
||||
val count = buffer.readVarInt()
|
||||
require(count in 0..MolangUiPacketLimits.MAX_CONTRACTS)
|
||||
repeat(count) {
|
||||
val name = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
require(name !in layouts) { "duplicate container collection '$name'" }
|
||||
layouts[name] = SlotLayout(buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt())
|
||||
}
|
||||
}
|
||||
require(!buffer.isReadable) { "unexpected trailing UI open data" }
|
||||
return MolangUiOpenPacket(sessionId, guiId, version, mode, args, exclusive, hasContainer, layouts)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.client.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import java.util.UUID
|
||||
|
||||
class MolangUiUpdatePacket(val sessionId: UUID, val args: Map<String, String>, val variables: Map<String, Double> = emptyMap()) : NetworkPacket<MolangUiUpdatePacket> {
|
||||
override val id = ID
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
require(args.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS && variables.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS)
|
||||
buffer.writeUUID(sessionId).writeVarInt(args.size)
|
||||
args.forEach { (key, value) -> buffer.writeUtf(key, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH).writeUtf(value, MolangUiPacketLimits.MAX_VALUE_LENGTH) }
|
||||
buffer.writeVarInt(variables.size)
|
||||
variables.forEach { (key, value) -> require(value.isFinite()); buffer.writeUtf(key, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH).writeDouble(value) }
|
||||
}
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_update")
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiUpdatePacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
val sessionId = buffer.readUUID()
|
||||
val argCount = buffer.readVarInt(); require(argCount in 0..MolangUiPacketLimits.MAX_ACTION_FIELDS)
|
||||
val args = linkedMapOf<String, String>()
|
||||
repeat(argCount) { args[buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)] = buffer.readUtf(MolangUiPacketLimits.MAX_VALUE_LENGTH) }
|
||||
val variableCount = buffer.readVarInt(); require(variableCount in 0..MolangUiPacketLimits.MAX_ACTION_FIELDS)
|
||||
val variables = linkedMapOf<String, Double>()
|
||||
repeat(variableCount) {
|
||||
val key = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
val value = buffer.readDouble(); require(value.isFinite()); variables[key] = value
|
||||
}
|
||||
return MolangUiUpdatePacket(sessionId, args, variables)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.server.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import java.util.UUID
|
||||
|
||||
class MolangUiActionPacket(val sessionId: UUID, val action: String, val data: Map<String, String>) : NetworkPacket<MolangUiActionPacket> {
|
||||
override val id = ID
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
require(data.size <= MolangUiPacketLimits.MAX_ACTION_FIELDS)
|
||||
buffer.writeUUID(sessionId)
|
||||
buffer.writeUtf(action, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
buffer.writeVarInt(data.size)
|
||||
data.forEach { (key, value) ->
|
||||
buffer.writeUtf(key, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
buffer.writeUtf(value, MolangUiPacketLimits.MAX_VALUE_LENGTH)
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_action")
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiActionPacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
val sessionId = buffer.readUUID()
|
||||
val action = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
val count = buffer.readVarInt()
|
||||
require(count in 0..MolangUiPacketLimits.MAX_ACTION_FIELDS)
|
||||
val data = linkedMapOf<String, String>()
|
||||
repeat(count) { data[buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)] = buffer.readUtf(MolangUiPacketLimits.MAX_VALUE_LENGTH) }
|
||||
return MolangUiActionPacket(sessionId, action, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.server.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import java.util.UUID
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
|
||||
class MolangUiClosedPacket(
|
||||
val sessionId: UUID
|
||||
) : NetworkPacket<MolangUiClosedPacket> {
|
||||
|
||||
override val id = ID
|
||||
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
buffer.writeUUID(sessionId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_closed")
|
||||
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiClosedPacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
return MolangUiClosedPacket(buffer.readUUID())
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.messages.server.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiPacketLimits
|
||||
import com.cobblemon.mod.common.api.net.NetworkPacket
|
||||
import com.cobblemon.mod.common.util.cobblemonResource
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
|
||||
class MolangUiCompatibilityResponsePacket(val views: Map<ResourceLocation, String>) : NetworkPacket<MolangUiCompatibilityResponsePacket> {
|
||||
override val id = ID
|
||||
override fun encode(buffer: RegistryFriendlyByteBuf) {
|
||||
require(views.size <= MolangUiPacketLimits.MAX_CONTRACTS)
|
||||
buffer.writeVarInt(views.size)
|
||||
views.forEach { (id, version) ->
|
||||
buffer.writeUtf(id.toString(), MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
buffer.writeUtf(version, MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
val ID = cobblemonResource("molang_ui_compatibility_response")
|
||||
fun decode(buffer: RegistryFriendlyByteBuf): MolangUiCompatibilityResponsePacket {
|
||||
require(buffer.readableBytes() <= MolangUiPacketLimits.MAX_PACKET_BYTES)
|
||||
val count = buffer.readVarInt()
|
||||
require(count in 0..MolangUiPacketLimits.MAX_CONTRACTS)
|
||||
val views = linkedMapOf<ResourceLocation, String>()
|
||||
repeat(count) {
|
||||
val id = ResourceLocation.tryParse(buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH))
|
||||
?: throw IllegalArgumentException("invalid Molang UI id")
|
||||
views[id] = buffer.readUtf(MolangUiPacketLimits.MAX_IDENTIFIER_LENGTH)
|
||||
}
|
||||
return MolangUiCompatibilityResponsePacket(views)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.serverhandling.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.api.net.ServerNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
|
||||
import net.minecraft.server.MinecraftServer
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
|
||||
object MolangUiActionHandler : ServerNetworkPacketHandler<MolangUiActionPacket> {
|
||||
override fun handle(packet: MolangUiActionPacket, server: MinecraftServer, player: ServerPlayer) {
|
||||
MolangUiService.handleAction(packet.sessionId, player, packet.action, packet.data)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.serverhandling.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.api.net.ServerNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiClosedPacket
|
||||
import net.minecraft.server.MinecraftServer
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
|
||||
object MolangUiClosedHandler : ServerNetworkPacketHandler<MolangUiClosedPacket> {
|
||||
override fun handle(packet: MolangUiClosedPacket, server: MinecraftServer, player: ServerPlayer) {
|
||||
MolangUiService.handleClosed(packet.sessionId, player)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.net.serverhandling.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.MolangUiService
|
||||
import com.cobblemon.mod.common.api.net.ServerNetworkPacketHandler
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiCompatibilityResponsePacket
|
||||
import net.minecraft.server.MinecraftServer
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
|
||||
object MolangUiCompatibilityResponseHandler : ServerNetworkPacketHandler<MolangUiCompatibilityResponsePacket> {
|
||||
override fun handle(packet: MolangUiCompatibilityResponsePacket, server: MinecraftServer, player: ServerPlayer) {
|
||||
MolangUiService.applyCompatibility(player, packet.views)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": {
|
||||
"model": "cobblemon:block/vending_machine"
|
||||
},
|
||||
"facing=east": {
|
||||
"model": "cobblemon:block/vending_machine",
|
||||
"y": 90
|
||||
},
|
||||
"facing=south": {
|
||||
"model": "cobblemon:block/vending_machine",
|
||||
"y": 180
|
||||
},
|
||||
"facing=west": {
|
||||
"model": "cobblemon:block/vending_machine",
|
||||
"y": 270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -940,6 +940,7 @@
|
||||
"block.cobblemon.green_gilded_chest": "Green Gilded Chest",
|
||||
"block.cobblemon.white_gilded_chest": "White Gilded Chest",
|
||||
"block.cobblemon.display_case": "Display Case",
|
||||
"block.cobblemon.vending_machine": "Vending Machine",
|
||||
"block.cobblemon.campfire": "Campfire",
|
||||
"block.cobblemon.campfire_pot_black": "Black Campfire Pot",
|
||||
"block.cobblemon.campfire_pot_blue": "Blue Campfire Pot",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"parent": "minecraft:block/orientable",
|
||||
"textures": {
|
||||
"front": "cobblemon:block/functional/vending_machine_front",
|
||||
"side": "cobblemon:block/functional/vending_machine_side",
|
||||
"top": "cobblemon:block/functional/vending_machine_top"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"parent": "cobblemon:block/vending_machine"
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"contract_version": "1.0.0",
|
||||
"mode": "overlay",
|
||||
"parameters": {
|
||||
"player_name": "string",
|
||||
"opponent_name": "string",
|
||||
"player_party_count": "number",
|
||||
"opponent_party_count": "number"
|
||||
},
|
||||
"actions": [],
|
||||
"root": {
|
||||
"type": "panel",
|
||||
"size": ["100%", "100%"],
|
||||
"controls": [
|
||||
{ "top_bar": {
|
||||
"type": "fill",
|
||||
"anchor": "top_center",
|
||||
"size": ["100%", 30],
|
||||
"color": "0xFF000000",
|
||||
"opacity": { "molang": "math.clamp(q.gui_time / 0.4, 0, 1)" }
|
||||
}},
|
||||
{ "bottom_bar": {
|
||||
"type": "fill",
|
||||
"anchor": "bottom_center",
|
||||
"size": ["100%", 30],
|
||||
"color": "0xFF000000",
|
||||
"opacity": { "molang": "math.clamp(q.gui_time / 0.4, 0, 1)" }
|
||||
}},
|
||||
{ "player_panel": {
|
||||
"type": "fill",
|
||||
"anchor": "center_left",
|
||||
"size": ["45%", 60],
|
||||
"color": "0xCC2244AA",
|
||||
"offset": [{ "molang": "math.lerp(-q.screen_width, 0, math.clamp((q.gui_time - 0.2) / 0.6, 0, 1))" }, -10],
|
||||
"controls": [
|
||||
{ "player_name": {
|
||||
"type": "label",
|
||||
"anchor": "center_left",
|
||||
"offset": [{ "molang": "math.lerp(-200, 10, math.clamp((q.gui_time - 0.8) / 2.2, 0, 1))" }, 0],
|
||||
"text": { "molang": "q.player_name == '' ? q.translate('cobblemon.molang_ui.battle_intro.player_fallback') : q.player_name" },
|
||||
"shadow": true
|
||||
}},
|
||||
{ "player_balls": {
|
||||
"type": "list",
|
||||
"source": { "molang": "q.player_party_count" },
|
||||
"source_type": "count",
|
||||
"layout": "stack_horizontal",
|
||||
"spacing": 4,
|
||||
"anchor": "bottom_left",
|
||||
"offset": [10, -4],
|
||||
"item_template": {
|
||||
"type": "fill",
|
||||
"size": [8, 8],
|
||||
"color": "0xFFAABBFF",
|
||||
"opacity": { "molang": "math.clamp((q.gui_time - (0.6 + q.item.index * 0.15)) / 0.3, 0, 1)" }
|
||||
}
|
||||
}}
|
||||
]
|
||||
}},
|
||||
{ "opponent_panel": {
|
||||
"type": "fill",
|
||||
"anchor": "center_right",
|
||||
"size": ["45%", 60],
|
||||
"color": "0xCCAA2244",
|
||||
"offset": [{ "molang": "math.lerp(q.screen_width, 0, math.clamp((q.gui_time - 0.4) / 0.6, 0, 1))" }, 10],
|
||||
"controls": [
|
||||
{ "opponent_name": {
|
||||
"type": "label",
|
||||
"anchor": "center_right",
|
||||
"offset": [{ "molang": "math.lerp(200, -10, math.clamp((q.gui_time - 1.0) / 2.2, 0, 1))" }, 0],
|
||||
"text": { "molang": "q.opponent_name == '' ? q.translate('cobblemon.molang_ui.battle_intro.opponent_fallback') : q.opponent_name" },
|
||||
"alignment": "right",
|
||||
"shadow": true
|
||||
}},
|
||||
{ "opponent_balls": {
|
||||
"type": "list",
|
||||
"source": { "molang": "q.opponent_party_count" },
|
||||
"source_type": "count",
|
||||
"layout": "stack_horizontal",
|
||||
"spacing": 4,
|
||||
"anchor": "bottom_right",
|
||||
"offset": [-10, -4],
|
||||
"item_template": {
|
||||
"type": "fill",
|
||||
"size": [8, 8],
|
||||
"color": "0xFFFFAABB",
|
||||
"opacity": { "molang": "math.clamp((q.gui_time - (0.9 + q.item.index * 0.15)) / 0.3, 0, 1)" }
|
||||
}
|
||||
}}
|
||||
]
|
||||
}},
|
||||
{ "vs_text": {
|
||||
"type": "label",
|
||||
"anchor": "center",
|
||||
"text": {"molang": "q.translate('cobblemon.molang_ui.battle_intro.versus')"},
|
||||
"color": "0xFFFFDD44",
|
||||
"shadow": true,
|
||||
"opacity": { "molang": "math.clamp((q.gui_time - 0.8) / 0.5, 0, 1)" },
|
||||
"scale": { "molang": "math.lerp(2.0, 1.0, math.clamp((q.gui_time - 0.8) / 0.5, 0, 1))" }
|
||||
}},
|
||||
{ "player_accent": {
|
||||
"type": "fill",
|
||||
"anchor": "top_left",
|
||||
"offset": [0, 30],
|
||||
"size": ["45%", 2],
|
||||
"color": "0xFF4466FF",
|
||||
"opacity": { "molang": "math.clamp((q.gui_time - 0.2) / 0.6, 0, 1)" }
|
||||
}},
|
||||
{ "opponent_accent": {
|
||||
"type": "fill",
|
||||
"anchor": "bottom_right",
|
||||
"offset": [0, -30],
|
||||
"size": ["45%", 2],
|
||||
"color": "0xFFFF4466",
|
||||
"opacity": { "molang": "math.clamp((q.gui_time - 0.4) / 0.6, 0, 1)" }
|
||||
}}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"templates": {
|
||||
"panel": { "inputs": {}, "type": "panel", "color": "0x00000000" },
|
||||
"label": {
|
||||
"inputs": {
|
||||
"text": { "type": "string", "default": "" },
|
||||
"color": { "type": "string", "default": "0xFFFFFFFF" }
|
||||
},
|
||||
"type": "label", "text": "$text", "color": "$color", "shadow": false
|
||||
},
|
||||
"button": {
|
||||
"inputs": { "text": { "type": "string", "default": "" } },
|
||||
"type": "button", "size": [120, 20], "text": "$text", "color": "0xFF555555",
|
||||
"hover": { "color": "0xFF777777" }
|
||||
},
|
||||
"progress_bar": {
|
||||
"inputs": {
|
||||
"value": { "type": "expression", "default": { "molang": "0" } },
|
||||
"max": { "type": "expression", "default": { "molang": "100" } },
|
||||
"color": { "type": "string", "default": "0xFF00FF00" }
|
||||
},
|
||||
"type": "progress_bar", "value": "$value", "max": "$max", "color": "$color", "background_color": "0xFF333333"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"contract_version": "1.1.0",
|
||||
"mode": "screen",
|
||||
"actions": ["buy", "vending_page"],
|
||||
"parameters": {
|
||||
"page": "number",
|
||||
"page_count": "number",
|
||||
"page_text": "string",
|
||||
"row0_item": "string",
|
||||
"row1_item": "string",
|
||||
"row2_item": "string",
|
||||
"row0_price": "string",
|
||||
"row1_price": "string",
|
||||
"row2_price": "string"
|
||||
},
|
||||
"variables": { "v.choice": 0, "v.result": 0 },
|
||||
"root": {
|
||||
"type": "panel", "size": [176, 166], "anchor": "center", "texture": "cobblemon:textures/gui/vending_machine.png",
|
||||
"controls": [
|
||||
{ "title": { "type": "label", "offset": [8, 4], "text": "Vending Machine", "color": "0xFF404040" } },
|
||||
{ "result_ok": { "type": "label", "offset": [96, 4], "size": [72, 10], "alignment": "right", "text": "Purchased!", "color": "0xFF2E7D32", "visible": { "molang": "v.result == 1" } } },
|
||||
{ "result_fail": { "type": "label", "offset": [96, 4], "size": [72, 10], "alignment": "right", "text": "Not enough", "color": "0xFF8B1A1A", "visible": { "molang": "v.result == 2" } } },
|
||||
|
||||
{ "row0": {
|
||||
"type": "button", "offset": [6, 16], "size": [118, 18], "color": "0x33000000", "hover": { "color": "0x55FFFFFF" },
|
||||
"visible": { "molang": "q.row0_item != ''" },
|
||||
"on_click_client": { "molang": "v.choice = 0; v.result = 0;" },
|
||||
"controls": [
|
||||
{ "row0_selected": { "type": "fill", "offset": [0, 0], "size": ["100%", "100%"], "color": "0x66FFD54F", "visible": { "molang": "v.choice == 0" } } },
|
||||
{ "row0_icon": { "type": "item_slot", "offset": [2, 1], "item": { "molang": "q.row0_item" } } },
|
||||
{ "row0_name": { "type": "label", "offset": [22, 5], "text": { "molang": "q.item_name(q.row0_item)" } } },
|
||||
{ "row0_price_text": { "type": "label", "offset": [84, 5], "size": [16, 10], "alignment": "right", "text": { "molang": "q.row0_price" }, "color": "0xFFFFE066" } },
|
||||
{ "row0_coin": { "type": "item_slot", "offset": [100, 1], "item": "cobblemon:relic_coin" } }
|
||||
]
|
||||
} },
|
||||
|
||||
{ "row1": {
|
||||
"type": "button", "offset": [6, 35], "size": [118, 18], "color": "0x33000000", "hover": { "color": "0x55FFFFFF" },
|
||||
"visible": { "molang": "q.row1_item != ''" },
|
||||
"on_click_client": { "molang": "v.choice = 1; v.result = 0;" },
|
||||
"controls": [
|
||||
{ "row1_selected": { "type": "fill", "offset": [0, 0], "size": ["100%", "100%"], "color": "0x66FFD54F", "visible": { "molang": "v.choice == 1" } } },
|
||||
{ "row1_icon": { "type": "item_slot", "offset": [2, 1], "item": { "molang": "q.row1_item" } } },
|
||||
{ "row1_name": { "type": "label", "offset": [22, 5], "text": { "molang": "q.item_name(q.row1_item)" } } },
|
||||
{ "row1_price_text": { "type": "label", "offset": [84, 5], "size": [16, 10], "alignment": "right", "text": { "molang": "q.row1_price" }, "color": "0xFFFFE066" } },
|
||||
{ "row1_coin": { "type": "item_slot", "offset": [100, 1], "item": "cobblemon:relic_coin" } }
|
||||
]
|
||||
} },
|
||||
|
||||
{ "row2": {
|
||||
"type": "button", "offset": [6, 54], "size": [118, 18], "color": "0x33000000", "hover": { "color": "0x55FFFFFF" },
|
||||
"visible": { "molang": "q.row2_item != ''" },
|
||||
"on_click_client": { "molang": "v.choice = 2; v.result = 0;" },
|
||||
"controls": [
|
||||
{ "row2_selected": { "type": "fill", "offset": [0, 0], "size": ["100%", "100%"], "color": "0x66FFD54F", "visible": { "molang": "v.choice == 2" } } },
|
||||
{ "row2_icon": { "type": "item_slot", "offset": [2, 1], "item": { "molang": "q.row2_item" } } },
|
||||
{ "row2_name": { "type": "label", "offset": [22, 5], "text": { "molang": "q.item_name(q.row2_item)" } } },
|
||||
{ "row2_price_text": { "type": "label", "offset": [84, 5], "size": [16, 10], "alignment": "right", "text": { "molang": "q.row2_price" }, "color": "0xFFFFE066" } },
|
||||
{ "row2_coin": { "type": "item_slot", "offset": [100, 1], "item": "cobblemon:relic_coin" } }
|
||||
]
|
||||
} },
|
||||
|
||||
{ "page_prev": {
|
||||
"type": "button", "offset": [6, 72], "size": [12, 10], "text": "<", "color": "0x55000000", "hover": { "color": "0x77FFFFFF" },
|
||||
"visible": { "molang": "q.page > 0" },
|
||||
"on_click_client": { "molang": "v.choice = 0; v.result = 0;" },
|
||||
"on_click": { "action": "vending_page", "parameters": { "page": { "molang": "q.page - 1" } } }
|
||||
} },
|
||||
{ "page_label": { "type": "label", "offset": [20, 73], "size": [90, 10], "alignment": "center", "text": { "molang": "q.page_text" }, "color": "0xFFCCCCCC" } },
|
||||
{ "page_next": {
|
||||
"type": "button", "offset": [112, 72], "size": [12, 10], "text": ">", "color": "0x55000000", "hover": { "color": "0x77FFFFFF" },
|
||||
"visible": { "molang": "q.page + 1 < q.page_count" },
|
||||
"on_click_client": { "molang": "v.choice = 0; v.result = 0;" },
|
||||
"on_click": { "action": "vending_page", "parameters": { "page": { "molang": "q.page + 1" } } }
|
||||
} },
|
||||
|
||||
{ "payment_slot": { "type": "slot_grid", "offset": [141, 22], "collection_name": "payment", "grid_dimensions": [1, 1] } },
|
||||
{ "buy": { "type": "button", "offset": [129, 44], "size": [40, 14], "text": "Buy", "color": "0xFFB71C1C", "hover": { "color": "0xFFE53935" }, "on_click": { "action": "buy", "parameters": { "choice": { "molang": "v.choice" } } } } },
|
||||
{ "output_slot": { "type": "slot_grid", "offset": [141, 62], "collection_name": "output", "grid_dimensions": [1, 1] } },
|
||||
|
||||
{ "player_inv": { "type": "slot_grid", "offset": [8, 84], "collection_name": "player_inventory", "grid_dimensions": [9, 3] } },
|
||||
{ "player_hotbar": { "type": "slot_grid", "offset": [8, 142], "collection_name": "player_hotbar", "grid_dimensions": [9, 1] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 268 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 148 B |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1.0,
|
||||
"bonus_rolls": 0.0,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "cobblemon:vending_machine"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
t.item = '';
|
||||
t.price = 0;
|
||||
v.choice == 0 ? { t.item = q.parameter('row0_item'); t.price = q.to_number(q.parameter('row0_price')); } : 0;
|
||||
v.choice == 1 ? { t.item = q.parameter('row1_item'); t.price = q.to_number(q.parameter('row1_price')); } : 0;
|
||||
v.choice == 2 ? { t.item = q.parameter('row2_item'); t.price = q.to_number(q.parameter('row2_price')); } : 0;
|
||||
t.bought = 0;
|
||||
t.item != '' && t.price > 0 && q.slot('payment', 0) == 'cobblemon:relic_coin' ? { t.bought = q.replace_slot('payment', 0, t.price, 'output', 0, t.item, 1); } : 0;
|
||||
q.update_variable('v.result', t.bought ? 1 : 2);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"contract_version": "1.0.0",
|
||||
"mode": "overlay",
|
||||
"parameters": {
|
||||
"player_name": { "type": "string" },
|
||||
"opponent_name": { "type": "string" },
|
||||
"player_party_count": { "type": "number" },
|
||||
"opponent_party_count": { "type": "number" }
|
||||
},
|
||||
"actions": {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"contract_version": "1.1.0",
|
||||
"mode": "screen",
|
||||
"parameters": {
|
||||
"page": { "type": "number", "required": false, "default": 0 },
|
||||
"page_count": { "type": "number", "required": false, "default": 1 },
|
||||
"page_text": { "type": "string", "required": false, "default": "1/1" },
|
||||
"row0_item": { "type": "string", "required": false, "default": "" },
|
||||
"row1_item": { "type": "string", "required": false, "default": "" },
|
||||
"row2_item": { "type": "string", "required": false, "default": "" },
|
||||
"row0_price": { "type": "string", "required": false, "default": "" },
|
||||
"row1_price": { "type": "string", "required": false, "default": "" },
|
||||
"row2_price": { "type": "string", "required": false, "default": "" }
|
||||
},
|
||||
"collections": {
|
||||
"payment": { "size": 1, "allowed_items": ["cobblemon:relic_coin"], "layout": { "offset": [141, 22], "grid_dimensions": [1, 1] } },
|
||||
"output": { "size": 1, "take_only": true, "layout": { "offset": [141, 62], "grid_dimensions": [1, 1] } }
|
||||
},
|
||||
"shift_click_rules": [
|
||||
{ "from": ["output"], "to": ["player_inventory", "player_hotbar"] },
|
||||
{ "from": ["player_inventory", "player_hotbar"], "to": ["payment"] },
|
||||
{ "from": ["payment"], "to": ["player_inventory", "player_hotbar"] }
|
||||
],
|
||||
"on_close_items": "return",
|
||||
"actions": {
|
||||
"buy": { "handler": "cobblemon:vending_machine_buy", "parameters": { "choice": "number" } },
|
||||
"vending_page": { "parameters": { "page": "number" } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "item": "cobblemon:poke_ball", "price": 5 },
|
||||
{ "item": "cobblemon:great_ball", "price": 10 },
|
||||
{ "item": "cobblemon:ultra_ball", "price": 20 },
|
||||
{ "item": "cobblemon:potion", "price": 8 },
|
||||
{ "item": "cobblemon:super_potion", "price": 15 },
|
||||
{ "item": "cobblemon:hyper_potion", "price": 30 },
|
||||
{ "item": "cobblemon:revive", "price": 40 },
|
||||
{ "item": "cobblemon:antidote", "price": 6 }
|
||||
]
|
||||
}
|
||||
@@ -100,6 +100,7 @@
|
||||
"cobblemon:thunder_stone_block",
|
||||
"cobblemon:water_stone_block",
|
||||
"cobblemon:display_case",
|
||||
"cobblemon:vending_machine",
|
||||
"cobblemon:white_plaque",
|
||||
"cobblemon:light_gray_plaque",
|
||||
"cobblemon:gray_plaque",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"client.RecipeBookComponentMixin",
|
||||
"client.RecipeBookTabButtonMixin",
|
||||
"client.RecipeCollectionMixin",
|
||||
"client.RenderStateShardMixin",
|
||||
"client.SearchTreeMixin",
|
||||
"client.StringRenderOutputMixin",
|
||||
"client.snapshots.SnapshotWarningMixin",
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
import com.cobblemon.mod.common.CobblemonNetwork
|
||||
import com.cobblemon.mod.common.api.molangui.event.ClientEventProcessor
|
||||
import com.cobblemon.mod.common.api.molangui.event.WidgetEvent
|
||||
import com.cobblemon.mod.common.api.molangui.event.WidgetEventType
|
||||
import com.cobblemon.mod.common.api.molangui.widget.ServerWidgetEvent
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetProperty
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetTree
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetType
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiWidgetTree
|
||||
import com.cobblemon.mod.common.junit.BootstrapMinecraft
|
||||
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
|
||||
import com.cobblemon.mod.common.util.asExpression
|
||||
import com.cobblemon.mod.common.util.resolve
|
||||
import com.google.gson.JsonParser
|
||||
import io.mockk.*
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.lwjgl.glfw.GLFW.GLFW_KEY_ESCAPE
|
||||
import java.util.UUID
|
||||
|
||||
@BootstrapMinecraft
|
||||
class ClientEventProcessorTest {
|
||||
private val runtime = MoLangRuntime()
|
||||
private val session = UUID.randomUUID()
|
||||
private val sent = mutableListOf<MolangUiActionPacket>()
|
||||
|
||||
@BeforeEach
|
||||
fun network() {
|
||||
mockkStatic(CobblemonNetwork::class)
|
||||
every { CobblemonNetwork.sendToServer(any()) } answers { sent += firstArg<MolangUiActionPacket>() }
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun cleanup() = unmockkStatic(CobblemonNetwork::class)
|
||||
|
||||
private fun definition(client: String = "v.amount = q.event.button + 10;", parameters: Map<String, WidgetProperty> = mapOf(
|
||||
"value" to WidgetProperty.molang("v.amount"), "target" to WidgetProperty.molang("q.event.widget_id")
|
||||
)) = WidgetDefinition("parent", WidgetType.PANEL,
|
||||
clientEvents = mapOf("on_click_client" to client),
|
||||
events = mapOf("on_click" to ServerWidgetEvent(ActionKey("choose"), parameters)))
|
||||
|
||||
@Test
|
||||
fun `client mutation and event context are available when server arguments are evaluated`() {
|
||||
runtime.environment.query.addFunction("event") { StringValue("outside") }
|
||||
ClientEventProcessor.processEvent(WidgetEvent(WidgetEventType.CLICK, "child", mapOf("button" to 2.0)), definition(), runtime, session)
|
||||
assertEquals(12.0, sent.single().data.getValue("value").toDouble())
|
||||
assertEquals("child", sent.single().data.getValue("target"))
|
||||
assertEquals(session, sent.single().sessionId)
|
||||
assertEquals("outside", runtime.resolve("q.event".asExpression()).asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested events restore the outer context even when sending fails`() {
|
||||
runtime.environment.query.addFunction("nested") {
|
||||
ClientEventProcessor.processEvent(WidgetEvent(WidgetEventType.CLICK, "inner", mapOf("button" to 7.0)), definition(), runtime, session)
|
||||
runtime.resolve("q.event.widget_id".asExpression())
|
||||
}
|
||||
every { CobblemonNetwork.sendToServer(any()) } throws IllegalStateException("disconnected")
|
||||
assertDoesNotThrow {
|
||||
ClientEventProcessor.processEvent(WidgetEvent(WidgetEventType.CLICK, "outer", mapOf("button" to 3.0)),
|
||||
definition("v.outer = q.nested; v.after = q.event.button;"), runtime, session)
|
||||
}
|
||||
assertEquals("outer", runtime.resolve("v.outer".asExpression()).asString())
|
||||
assertEquals(3.0, runtime.resolve("v.after".asExpression()).asDouble())
|
||||
assertFalse("event" in runtime.environment.query.functions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed handlers arguments and oversized values do not dispatch partial actions`() {
|
||||
runtime.environment.query.addFunction("fail") { throw IllegalStateException("failed expression") }
|
||||
val failure = WidgetProperty.molang("q.fail")
|
||||
for (widget in listOf(definition("q.fail;"), definition(parameters = mapOf("value" to failure)),
|
||||
definition(parameters = mapOf("value" to WidgetProperty.literal("x".repeat(MolangUiPacketLimits.MAX_VALUE_LENGTH + 1)))))) {
|
||||
assertDoesNotThrow { ClientEventProcessor.processEvent(WidgetEvent(WidgetEventType.CLICK, "child"), widget, runtime, session) }
|
||||
assertFalse("event" in runtime.environment.query.functions)
|
||||
}
|
||||
assertTrue(sent.isEmpty())
|
||||
verify(exactly = 0) { CobblemonNetwork.sendToServer(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key handlers can inspect key data but cannot swallow escape`() {
|
||||
val tree = MolangUiWidgetTree(ResourceLocation.fromNamespaceAndPath("cobblemon", "key_test"), session,
|
||||
WidgetTree.parseWidget("root", JsonParser.parseString("""{"size":[100,100],
|
||||
"on_key_client":{"molang":"v.key = q.event.key; v.scan = q.event.scan; v.modifiers = q.event.modifiers;"}}""").asJsonObject),
|
||||
emptyMap(), emptyMap())
|
||||
assertTrue(tree.prepare(100, 100))
|
||||
assertTrue(tree.onKeyPress(65, 30, 2))
|
||||
assertEquals(65.0, tree.runtime.resolve("v.key".asExpression()).asDouble())
|
||||
assertEquals(30.0, tree.runtime.resolve("v.scan".asExpression()).asDouble())
|
||||
assertEquals(2.0, tree.runtime.resolve("v.modifiers".asExpression()).asDouble())
|
||||
assertFalse(tree.onKeyPress(GLFW_KEY_ESCAPE, 1, 0))
|
||||
assertEquals(65.0, tree.runtime.resolve("v.key".asExpression()).asDouble())
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiParameters.requireName("event") }
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.CobblemonMenuType
|
||||
import com.cobblemon.mod.common.api.molangui.container.CloseItemsPolicy
|
||||
import com.cobblemon.mod.common.api.molangui.container.CollectionConfig
|
||||
import com.cobblemon.mod.common.api.molangui.container.CollectionSource
|
||||
import com.cobblemon.mod.common.api.molangui.container.ItemFilterEntry
|
||||
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
|
||||
import com.cobblemon.mod.common.api.molangui.container.ServerCollectionSetup
|
||||
import com.cobblemon.mod.common.api.molangui.container.ShiftClickRule
|
||||
import com.cobblemon.mod.common.api.molangui.container.SlotLayout
|
||||
import com.cobblemon.mod.common.junit.BootstrapMinecraft
|
||||
import com.cobblemon.mod.common.util.asExpression
|
||||
import com.cobblemon.mod.common.util.resolve
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.Container
|
||||
import net.minecraft.world.SimpleContainer
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import net.minecraft.world.flag.FeatureFlags
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||
import net.minecraft.world.inventory.MenuType
|
||||
import net.minecraft.world.item.ItemStack
|
||||
import net.minecraft.world.item.Items
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@BootstrapMinecraft
|
||||
class MolangContainerMenuTest {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun initializeMenuTypes() {
|
||||
// The test bootstrap freezes vanilla registries before Cobblemon's menu initializer runs.
|
||||
mockkStatic(MenuType::class)
|
||||
try {
|
||||
every { MenuType.register(any(), any<MenuType.MenuSupplier<AbstractContainerMenu>>()) } answers {
|
||||
MenuType(secondArg<MenuType.MenuSupplier<AbstractContainerMenu>>(), FeatureFlags.DEFAULT_FLAGS)
|
||||
}
|
||||
CobblemonMenuType.MOLANG_UI
|
||||
} finally {
|
||||
unmockkStatic(MenuType::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val player = mockk<ServerPlayer>(relaxed = true)
|
||||
private val inventory = Inventory(player)
|
||||
private val session = mockk<MolangUiSession>(relaxed = true)
|
||||
|
||||
@Test
|
||||
fun `menu removal closes its session even when item cleanup fails`() {
|
||||
val container = object : SimpleContainer(1) {
|
||||
override fun clearContent() { throw IllegalStateException("cleanup failed") }
|
||||
}
|
||||
val menu = MolangContainerMenu(1, inventory)
|
||||
menu.initializeServer(listOf(setup("input", container)), sessionOwned = setOf("input"),
|
||||
closeItemsPolicy = CloseItemsPolicy.DESTROY, owner = player, session = session)
|
||||
|
||||
assertThrows(IllegalStateException::class.java) { menu.removed(player) }
|
||||
|
||||
verify(exactly = 1) { session.closeFromMenu() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scripts address collection slots through their block mapping`() {
|
||||
val block = SimpleContainer(5)
|
||||
block.setItem(0, ItemStack(Items.EMERALD, 7))
|
||||
block.setItem(3, ItemStack(Items.DIAMOND, 4))
|
||||
val menu = menu(listOf(setup("input", block, mapping = listOf(3)), setup("output", block, mapping = listOf(4))))
|
||||
assertEquals(4, menu.getStack("input", 0)?.count)
|
||||
assertNull(menu.getStack("input", 3))
|
||||
assertNull(menu.getStack("input", Int.MAX_VALUE))
|
||||
assertTrue(menu.replaceSlotAtomically("input", 0, 2, "output", 0, "minecraft:gold_ingot", 3))
|
||||
assertEquals(2, block.getItem(3).count)
|
||||
assertTrue(block.getItem(4).`is`(Items.GOLD_INGOT))
|
||||
assertEquals(3, block.getItem(4).count)
|
||||
assertEquals(7, block.getItem(0).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected oversized outputs leave both slots unchanged`() {
|
||||
val input = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 4)) }
|
||||
val output = SimpleContainer(1)
|
||||
val menu = menu(listOf(setup("input", input), setup("output", output)))
|
||||
for (count in listOf(65, Int.MAX_VALUE)) {
|
||||
assertFalse(menu.replaceSlotAtomically("input", 0, 1, "output", 0, "minecraft:gold_ingot", count))
|
||||
assertEquals(4, input.getItem(0).count)
|
||||
assertTrue(output.getItem(0).isEmpty)
|
||||
}
|
||||
output.setItem(0, ItemStack(Items.GOLD_INGOT, 63))
|
||||
assertFalse(menu.replaceSlotAtomically("input", 0, 1, "output", 0, "minecraft:gold_ingot", Int.MAX_VALUE))
|
||||
assertEquals(63, output.getItem(0).count)
|
||||
assertEquals(4, input.getItem(0).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scripted output honors container limits and filters on take-only collections`() {
|
||||
val input = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 4)) }
|
||||
val output = object : SimpleContainer(1) {
|
||||
override fun getMaxStackSize(stack: ItemStack) = 3
|
||||
}
|
||||
val allowed = listOf(ItemFilterEntry.Item(ResourceLocation.withDefaultNamespace("gold_ingot")))
|
||||
val menu = menu(listOf(setup("input", input), setup("output", output, takeOnly = true, allowed = allowed)))
|
||||
assertFalse(menu.replaceSlotAtomically("input", 0, 1, "output", 0, "minecraft:gold_ingot", 4))
|
||||
assertFalse(menu.replaceSlotAtomically("input", 0, 1, "output", 0, "minecraft:emerald", 1))
|
||||
assertEquals(4, input.getItem(0).count)
|
||||
assertTrue(menu.replaceSlotAtomically("input", 0, 1, "output", 0, "minecraft:gold_ingot", 3))
|
||||
assertEquals(3, output.getItem(0).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aliased input and output slots transform only after validating the remainder`() {
|
||||
val block = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 4)) }
|
||||
val menu = menu(listOf(setup("input", block), setup("output", block)))
|
||||
assertFalse(menu.replaceSlotAtomically("input", 0, 1, "output", 0, "minecraft:gold_ingot", 1))
|
||||
assertEquals(4, block.getItem(0).count)
|
||||
assertTrue(menu.replaceSlotAtomically("input", 0, 4, "output", 0, "minecraft:gold_ingot", 2))
|
||||
assertTrue(block.getItem(0).`is`(Items.GOLD_INGOT))
|
||||
assertEquals(2, block.getItem(0).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inventory and hotbar use their own shift click rules`() {
|
||||
val main = SimpleContainer(1)
|
||||
val hotbar = SimpleContainer(1)
|
||||
val menu = menu(listOf(setup("main", main), setup("hotbar", hotbar)), listOf(
|
||||
ShiftClickRule(listOf(ShiftClickRule.PLAYER_INVENTORY), listOf("main")),
|
||||
ShiftClickRule(listOf(ShiftClickRule.PLAYER_HOTBAR), listOf("hotbar"))
|
||||
))
|
||||
inventory.setItem(9, ItemStack(Items.DIAMOND, 3))
|
||||
inventory.setItem(0, ItemStack(Items.EMERALD, 5))
|
||||
menu.quickMoveStack(player, 2)
|
||||
menu.quickMoveStack(player, 29)
|
||||
assertEquals(3, main.getItem(0).count)
|
||||
assertTrue(main.getItem(0).`is`(Items.DIAMOND))
|
||||
assertEquals(5, hotbar.getItem(0).count)
|
||||
assertTrue(hotbar.getItem(0).`is`(Items.EMERALD))
|
||||
assertTrue(inventory.getItem(9).isEmpty)
|
||||
assertTrue(inventory.getItem(0).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift click continues through partially filled destinations`() {
|
||||
val first = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 63)) }
|
||||
val second = SimpleContainer(2)
|
||||
val menu = menu(listOf(setup("first", first), setup("second", second)), listOf(
|
||||
ShiftClickRule(listOf(ShiftClickRule.PLAYER_INVENTORY), listOf("first", "second"))
|
||||
))
|
||||
inventory.setItem(9, ItemStack(Items.DIAMOND, 10))
|
||||
menu.quickMoveStack(player, 3)
|
||||
assertEquals(64, first.getItem(0).count)
|
||||
assertEquals(9, second.getItem(0).count)
|
||||
assertTrue(inventory.getItem(9).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift click cannot merge into filtered or take-only occupied slots`() {
|
||||
val denied = listOf(ItemFilterEntry.Item(ResourceLocation.withDefaultNamespace("emerald")))
|
||||
val filtered = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 1)) }
|
||||
val takeOnly = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 1)) }
|
||||
val destination = SimpleContainer(1)
|
||||
val menu = menu(listOf(setup("filtered", filtered, allowed = denied), setup("output", takeOnly, takeOnly = true), setup("valid", destination)))
|
||||
inventory.setItem(9, ItemStack(Items.DIAMOND, 5))
|
||||
menu.quickMoveStack(player, 3)
|
||||
assertEquals(1, filtered.getItem(0).count)
|
||||
assertEquals(1, takeOnly.getItem(0).count)
|
||||
assertEquals(5, destination.getItem(0).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift click skips aliases of the source slot`() {
|
||||
val block = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 4)) }
|
||||
val destination = SimpleContainer(1)
|
||||
val menu = menu(listOf(setup("source", block), setup("alias", block), setup("destination", destination)), listOf(
|
||||
ShiftClickRule(listOf("source"), listOf("alias", "destination"))
|
||||
))
|
||||
menu.quickMoveStack(player, 0)
|
||||
assertTrue(block.getItem(0).isEmpty)
|
||||
assertEquals(4, destination.getItem(0).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift click respects block placement restrictions and fills multiple limited slots`() {
|
||||
val restricted = object : SimpleContainer(1) {
|
||||
override fun canPlaceItem(index: Int, stack: ItemStack) = false
|
||||
}.apply { setItem(0, ItemStack(Items.DIAMOND, 1)) }
|
||||
val limited = object : SimpleContainer(2) {
|
||||
override fun getMaxStackSize(stack: ItemStack) = 2
|
||||
}
|
||||
val menu = menu(listOf(setup("restricted", restricted), setup("limited", limited)))
|
||||
inventory.setItem(9, ItemStack(Items.DIAMOND, 5))
|
||||
menu.quickMoveStack(player, 3)
|
||||
assertEquals(1, restricted.getItem(0).count)
|
||||
assertEquals(2, limited.getItem(0).count)
|
||||
assertEquals(2, limited.getItem(1).count)
|
||||
assertEquals(1, inventory.getItem(9).count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server expressions reject fractional or overflowing slot counts and indices`() {
|
||||
val input = SimpleContainer(1).apply { setItem(0, ItemStack(Items.DIAMOND, 4)) }
|
||||
val output = SimpleContainer(1)
|
||||
val menu = menu(listOf(setup("input", input), setup("output", output)))
|
||||
every { session.containerMenu } returns menu
|
||||
val runtime = MolangUiServerRuntimeFactory.create(session)
|
||||
for (expression in listOf(
|
||||
"q.replace_slot('input', 0.5, 1, 'output', 0, 'minecraft:gold_ingot', 1)",
|
||||
"q.replace_slot('input', 0, 1.5, 'output', 0, 'minecraft:gold_ingot', 1)",
|
||||
"q.replace_slot('input', 0, 1, 'output', 0.5, 'minecraft:gold_ingot', 1)",
|
||||
"q.replace_slot('input', 0, 1, 'output', 0, 'minecraft:gold_ingot', 1.5)",
|
||||
"q.replace_slot('input', 0, 1, 'output', 0, 'minecraft:gold_ingot', 4294967297)"
|
||||
)) {
|
||||
assertEquals(0.0, runtime.resolve(expression.asExpression()).asDouble(), expression)
|
||||
}
|
||||
assertEquals("", runtime.resolve("q.slot('input', 0.5)".asExpression()).asString())
|
||||
assertEquals("minecraft:diamond", runtime.resolve("q.slot('input', 0)".asExpression()).asString())
|
||||
assertEquals(4, input.getItem(0).count)
|
||||
assertTrue(output.getItem(0).isEmpty)
|
||||
}
|
||||
|
||||
private fun setup(name: String, container: Container, mapping: List<Int>? = null, takeOnly: Boolean = false,
|
||||
allowed: List<ItemFilterEntry> = emptyList()): ServerCollectionSetup {
|
||||
val size = mapping?.size ?: container.containerSize
|
||||
return ServerCollectionSetup(name, CollectionConfig(size, allowed, takeOnly,
|
||||
if (mapping == null) CollectionSource.SESSION else CollectionSource.BLOCK_ENTITY,
|
||||
mapping, SlotLayout(0, 0, size, 1)), container)
|
||||
}
|
||||
|
||||
private fun menu(setups: List<ServerCollectionSetup>, rules: List<ShiftClickRule> = emptyList()) =
|
||||
MolangContainerMenu(1, inventory).also {
|
||||
it.initializeServer(setups, rules, owner = player, session = session)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateDefinition
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateInput
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateInputType
|
||||
import com.cobblemon.mod.common.api.molangui.template.TemplateResolver
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetTree
|
||||
import com.cobblemon.mod.common.junit.BootstrapMinecraft
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@BootstrapMinecraft
|
||||
class MolangUiCodecTest {
|
||||
private val id = ResourceLocation.fromNamespaceAndPath("cobblemon", "test")
|
||||
|
||||
@Test
|
||||
fun `view codec reads mode and explicit expressions`() {
|
||||
val view = MolangUiRegistry.parse(id, json("""
|
||||
{"format_version":1,"contract_version":"1.2.3","mode":"screen",
|
||||
"parameters":{"title":"string"},"actions":[],"root":{"type":"panel","text":"q.title",
|
||||
"visible":{"molang":"v.enabled"}}}
|
||||
"""))
|
||||
assertEquals(GuiMode.SCREEN, view.mode)
|
||||
assertEquals("q.title", view.root.properties.getValue("text").raw)
|
||||
assertNull(view.root.properties.getValue("text").expression)
|
||||
assertNotNull(view.root.properties.getValue("visible").expression)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view codec rejects unknown widgets duplicate ids and inferred event scripts`() {
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot("""{"type":"unknown"}""")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot("""
|
||||
{"type":"panel","controls":[{"same":{"type":"label"}},{"same":{"type":"label"}}]}
|
||||
""")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot("""
|
||||
{"type":"button","on_click_client":"v.x = 1"}
|
||||
""")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot("""
|
||||
{"type":"button","on_click":{"action":"undeclared"}}
|
||||
""")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot("""
|
||||
{"type":"slot_grid","grid_dimensions":[1,1]}
|
||||
""")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contract codec rejects version type and collection inconsistencies`() {
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, json("""
|
||||
{"format_version":2,"contract_version":"1","mode":"screen"}
|
||||
""")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, json("""
|
||||
{"format_version":1,"contract_version":"1","mode":"screen","parameters":{"x":{"type":"wat"}}}
|
||||
""")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, json("""
|
||||
{"format_version":1,"contract_version":"1","mode":"screen","collections":{"x":{"size":2,
|
||||
"layout":{"grid_dimensions":[1,1]}}}}
|
||||
""")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contract codec preserves literal versus expression defaults`() {
|
||||
val contract = MolangUiContractRegistry.parse(id, json("""
|
||||
{"format_version":1,"contract_version":"1","mode":"overlay","parameters":{
|
||||
"literal":{"type":"string","default":"v.quantity + 1"},
|
||||
"expression":{"type":"number","default":{"molang":"v.quantity + 1"}}
|
||||
}}
|
||||
"""))
|
||||
assertEquals(ParameterValue.StringValue("v.quantity + 1"), (contract.parameters.getValue("literal").default as ParameterDefault.Literal).value)
|
||||
assertEquals("v.quantity + 1", (contract.parameters.getValue("expression").default as ParameterDefault.Molang).expression)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `templates substitute typed JSON values without text injection`() {
|
||||
val body = json("""{"inputs":{},"type":"label","text":"${'$'}text","metadata":"${'$'}metadata"}""")
|
||||
val template = TemplateDefinition("label", "", mapOf(
|
||||
"text" to TemplateInput(TemplateInputType.STRING),
|
||||
"metadata" to TemplateInput(TemplateInputType.OBJECT)
|
||||
), null, body)
|
||||
val root = json("""
|
||||
{"type":"panel","controls":[{"child@label":{"text":"\"}; malicious() //","metadata":{"nested":{"safe":true}}}}]}
|
||||
""")
|
||||
val resolved = TemplateResolver.resolve(root, mapOf("label" to template))
|
||||
val child = resolved.getAsJsonArray("controls")[0].asJsonObject.getAsJsonObject("child")
|
||||
assertEquals("\"}; malicious() //", child.get("text").asString)
|
||||
assertTrue(child.getAsJsonObject("metadata").getAsJsonObject("nested").get("safe").asBoolean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `templates report missing references and inheritance cycles`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
TemplateResolver.resolve(json("""{"type":"panel","controls":[{"x@missing":{}}]}"""), emptyMap())
|
||||
}
|
||||
val a = TemplateDefinition("a", "", emptyMap(), "b", json("""{"type":"panel"}"""))
|
||||
val b = TemplateDefinition("b", "", emptyMap(), "a", json("""{"type":"panel"}"""))
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
TemplateResolver.resolve(json("""{"type":"panel","controls":[{"x@a":{}}]}"""), mapOf("a" to a, "b" to b))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree limits depth and node count`() {
|
||||
var nested = "{\"type\":\"panel\"}"
|
||||
repeat(WidgetTree.MAX_DEPTH) { index -> nested = "{\"type\":\"panel\",\"controls\":[{\"n$index\":$nested}]}" }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot(nested)) }
|
||||
|
||||
val controls = (0..WidgetTree.MAX_NODES).joinToString(",") { index ->
|
||||
"{\"n$index\":{\"type\":\"label\"}}"
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiRegistry.parse(id, viewWithRoot("{\"type\":\"panel\",\"controls\":[$controls]}"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rate limiter allows burst then refills at twenty per second`() {
|
||||
val start = 10_000_000_000L
|
||||
val limiter = MolangUiRateLimiter(start)
|
||||
repeat(40) { assertTrue(limiter.tryAcquire(start)) }
|
||||
assertFalse(limiter.tryAcquire(start))
|
||||
repeat(20) { assertTrue(limiter.tryAcquire(start + 1_000_000_000L)) }
|
||||
assertFalse(limiter.tryAcquire(start + 1_000_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action registry rejects duplicates and supports explicit unregister`() {
|
||||
val key = ActionKey("test_registration")
|
||||
MolangUiActionRegistry.unregister(key)
|
||||
val handler = MolangUiActionHandler { }
|
||||
MolangUiActionRegistry.register(key, handler)
|
||||
try {
|
||||
assertThrows(IllegalStateException::class.java) { MolangUiActionRegistry.register(key, handler) }
|
||||
} finally {
|
||||
assertTrue(MolangUiActionRegistry.unregister(key))
|
||||
}
|
||||
assertFalse(MolangUiActionRegistry.unregister(key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bundled examples are executable contract fixtures`() {
|
||||
val relicCoin = ResourceLocation.fromNamespaceAndPath("cobblemon", "relic_coin")
|
||||
listOf("battle_intro", "vending_machine").forEach { name ->
|
||||
val resourceId = ResourceLocation.fromNamespaceAndPath("cobblemon", name)
|
||||
val view = MolangUiRegistry.parse(
|
||||
resourceId,
|
||||
resourceJson("assets/cobblemon/molang_ui/$name.json")
|
||||
)
|
||||
val contract = MolangUiContractRegistry.parse(
|
||||
resourceId,
|
||||
resourceJson("data/cobblemon/molang_ui/$name.json")
|
||||
) { item ->
|
||||
BuiltInRegistries.ITEM.containsKey(item) || item == relicCoin
|
||||
}
|
||||
|
||||
assertEquals(contract.contractVersion, view.contractVersion, name)
|
||||
assertEquals(contract.mode, view.mode, name)
|
||||
assertEquals(contract.parameters.keys, view.parameters.keys, name)
|
||||
assertEquals(contract.actions.keys, view.actions, name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewWithRoot(root: String) = json("""
|
||||
{"format_version":1,"contract_version":"1","mode":"screen","root":$root}
|
||||
""")
|
||||
|
||||
@Test
|
||||
fun `format versions cannot be truncated or coerced by either codec`() {
|
||||
listOf("1.5", "4294967297", "1e100", "\"1\"", "true", "null").forEach { version ->
|
||||
val source = json("""{"format_version":$version,"contract_version":"1","mode":"screen","root":{"type":"panel"}}""")
|
||||
assertThrows(IllegalArgumentException::class.java, { MolangUiRegistry.parse(id, source) }, version)
|
||||
assertThrows(IllegalArgumentException::class.java, { MolangUiContractRegistry.parse(id, source) }, version)
|
||||
}
|
||||
listOf("1", "1.0", "1e0").forEach { version ->
|
||||
val source = json("""{"format_version":$version,"contract_version":"1","mode":"screen","root":{"type":"panel"}}""")
|
||||
assertEquals(1, MolangUiRegistry.parse(id, source).formatVersion)
|
||||
assertEquals(1, MolangUiContractRegistry.parse(id, source).formatVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contract defaults enforce declared JSON types and finite components`() {
|
||||
val invalid = mapOf(
|
||||
"string" to listOf("12", "true", "null", "[]"),
|
||||
"number" to listOf("\"12\"", "true", "null", "1e400"),
|
||||
"boolean" to listOf("\"false\"", "0", "null"),
|
||||
"vec3" to listOf("[0,\"1\",2]", "[0,true,2]", "[0,1e400,2]", "[0,1]", "null")
|
||||
)
|
||||
invalid.forEach { (type, values) ->
|
||||
values.forEach { value ->
|
||||
val source = json("""{"format_version":1,"contract_version":"1","mode":"screen",
|
||||
"parameters":{"value":{"type":"$type","default":$value}}}""")
|
||||
assertThrows(IllegalArgumentException::class.java, { MolangUiContractRegistry.parse(id, source) }, "$type: $value")
|
||||
}
|
||||
}
|
||||
val source = json("""{"format_version":1,"contract_version":"1","mode":"screen","parameters":{
|
||||
"text":{"type":"string","default":"0012"},
|
||||
"number":{"type":"number","default":1.25},
|
||||
"flag":{"type":"boolean","default":false},
|
||||
"position":{"type":"vec3","default":[-1,2.5,3]}}}""")
|
||||
val defaults = MolangUiContractRegistry.parse(id, source).parameters.mapValues {
|
||||
(it.value.default as ParameterDefault.Literal).value
|
||||
}
|
||||
assertEquals(mapOf(
|
||||
"text" to ParameterValue.StringValue("0012"),
|
||||
"number" to ParameterValue.NumberValue(1.25),
|
||||
"flag" to ParameterValue.BooleanValue(false),
|
||||
"position" to ParameterValue.Vec3Value(-1.0, 2.5, 3.0)
|
||||
), defaults)
|
||||
val text = source.getAsJsonObject("parameters").getAsJsonObject("text")
|
||||
text.addProperty("default", "a".repeat(MolangUiPacketLimits.MAX_VALUE_LENGTH))
|
||||
MolangUiContractRegistry.parse(id, source)
|
||||
text.addProperty("default", "a".repeat(MolangUiPacketLimits.MAX_VALUE_LENGTH + 1))
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, source) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both codecs reject unsupported or malformed flags and blank contract versions`() {
|
||||
listOf("blocks_input", "exclusive").forEach { flag ->
|
||||
val values = listOf("\"true\"", "\"false\"", "1", "null") +
|
||||
if (flag == "blocks_input") listOf("true", "false") else emptyList()
|
||||
values.forEach { value ->
|
||||
val source = viewWithRoot("""{"type":"panel"}""")
|
||||
source.add(flag, JsonParser.parseString(value))
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, source) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, source) }
|
||||
}
|
||||
}
|
||||
val source = viewWithRoot("""{"type":"panel"}""")
|
||||
source.addProperty("contract_version", " ")
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, source) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, source) }
|
||||
source.addProperty("contract_version", "1")
|
||||
source.add("parameters", json("""{"x":{"type":"number","required":"false"}}"""))
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, source) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parameter declarations must fit opening packet capacity`() {
|
||||
val view = viewWithRoot("""{"type":"panel"}""")
|
||||
val contract = view.deepCopy()
|
||||
val viewParameters = JsonObject()
|
||||
val contractParameters = JsonObject()
|
||||
repeat(MolangUiPacketLimits.MAX_ACTION_FIELDS) { index ->
|
||||
viewParameters.addProperty("p$index", "number")
|
||||
contractParameters.add("p$index", json("""{"type":"number"}"""))
|
||||
}
|
||||
view.add("parameters", viewParameters)
|
||||
contract.add("parameters", contractParameters)
|
||||
assertEquals(MolangUiPacketLimits.MAX_ACTION_FIELDS, MolangUiRegistry.parse(id, view).parameters.size)
|
||||
assertEquals(MolangUiPacketLimits.MAX_ACTION_FIELDS, MolangUiContractRegistry.parse(id, contract).parameters.size)
|
||||
viewParameters.addProperty("extra", "number")
|
||||
contractParameters.add("extra", json("""{"type":"number"}"""))
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, view) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, contract) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view variables must be finite JSON numbers`() {
|
||||
val source = viewWithRoot("""{"type":"panel"}""")
|
||||
listOf("\"12\"", "\"NaN\"", "true", "null", "1e400").forEach { value ->
|
||||
source.add("variables", json("""{"x":$value}"""))
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, source) }
|
||||
}
|
||||
source.add("variables", json("""{"x":-1.25}"""))
|
||||
assertEquals(-1.25, MolangUiRegistry.parse(id, source).variables.getValue("x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit expressions require strings in defaults properties geometry and events`() {
|
||||
listOf("12", "true", "null", "[]").forEach { value ->
|
||||
val contract = json("""{"format_version":1,"contract_version":"1","mode":"screen",
|
||||
"parameters":{"x":{"type":"number","default":{"molang":$value}}}}""")
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiContractRegistry.parse(id, contract) }
|
||||
listOf(
|
||||
"""{"type":"label","text":{"molang":$value}}""",
|
||||
"""{"type":"panel","size":[{"molang":$value},20]}""",
|
||||
"""{"type":"button","on_click_client":{"molang":$value}}"""
|
||||
).forEach { root ->
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, viewWithRoot(root)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slot grid dimensions cannot silently truncate fractional values`() {
|
||||
listOf("[1.5,1]", "[1,2.1]", "[1e100,1]", "[\"1\",1]", "[0,1]", "[17,1]").forEach { size ->
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiRegistry.parse(id, viewWithRoot("""{"type":"slot_grid","collection_name":"items","grid_dimensions":$size}"""))
|
||||
}
|
||||
}
|
||||
MolangUiRegistry.parse(id, viewWithRoot("""{"type":"slot_grid","collection_name":"items","grid_dimensions":[16,16]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported widget declarations fail validation while hover remains supported`() {
|
||||
for (type in listOf("toggle", "slider", "text_input", "move_display")) {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiRegistry.parse(id, viewWithRoot("""{"type":"$type"}"""))
|
||||
}
|
||||
}
|
||||
for (property in listOf("transition", "enter", "exit", "static")) {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiRegistry.parse(id, viewWithRoot("""{"type":"panel","$property":{}}"""))
|
||||
}
|
||||
}
|
||||
val view = MolangUiRegistry.parse(id, viewWithRoot("""{"type":"button","hover":{"color":"0xFF777777"}}"""))
|
||||
assertTrue("color" in view.root.hoverOverrides)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unused template import declarations are rejected`() {
|
||||
val view = viewWithRoot("""{"type":"panel"}""").apply { add("imports", JsonParser.parseString("[]")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { MolangUiRegistry.parse(id, view) }
|
||||
}
|
||||
|
||||
private fun json(value: String) = JsonParser.parseString(value.trimIndent()).asJsonObject
|
||||
|
||||
private fun resourceJson(path: String) = MolangUiCodecTest::class.java.classLoader
|
||||
.getResourceAsStream(path)
|
||||
?.bufferedReader()
|
||||
?.use { JsonParser.parseReader(it).asJsonObject }
|
||||
?: error("Missing test fixture resource: $path")
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.MoLangRuntime
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.cobblemon.mod.common.api.molangui.layout.LayoutEngine
|
||||
import com.cobblemon.mod.common.api.molangui.layout.SizeExpression
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetExpansion
|
||||
import com.cobblemon.mod.common.api.molangui.widget.WidgetTree
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiWidgetTree
|
||||
import com.cobblemon.mod.common.junit.BootstrapMinecraft
|
||||
import com.cobblemon.mod.common.util.asExpression
|
||||
import com.cobblemon.mod.common.util.resolve
|
||||
import com.google.gson.JsonParser
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
|
||||
@BootstrapMinecraft
|
||||
class MolangUiLayoutTest {
|
||||
private fun definition(json: String) = WidgetTree.parseWidget("root", JsonParser.parseString(json).asJsonObject)
|
||||
|
||||
@Test
|
||||
fun `size literals reject implicit code unsupported auto and float overflow`() {
|
||||
for (value in listOf("v.width", "v.changed = 1;", "auto", "NaN", "Infinity", "1e100", "9".repeat(100) + "%", true)) {
|
||||
assertThrows(IllegalArgumentException::class.java) { SizeExpression.parse(value) }
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
definition("""{"size":["v.width",20]}""")
|
||||
}
|
||||
val runtime = MoLangRuntime()
|
||||
assertEquals(90f, SizeExpression.parse("50% - 10").resolve(200f, runtime))
|
||||
assertEquals(-10f, SizeExpression.parse(-10).resolve(200f, runtime))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid dynamic layout prevents clicks and recovers when the expression becomes valid`() {
|
||||
val tree = MolangUiWidgetTree(ResourceLocation.fromNamespaceAndPath("cobblemon", "layout_test"), UUID.randomUUID(),
|
||||
definition("""{"type":"button","size":[{"molang":"v.width"},20],
|
||||
"on_click_client":{"molang":"v.clicked = v.clicked + 1;"}}"""),
|
||||
emptyMap(), mapOf("width" to 30.0, "clicked" to 0.0))
|
||||
assertTrue(tree.prepare(200, 100))
|
||||
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 1e100, -1.0, Int.MAX_VALUE.toDouble())) {
|
||||
tree.runtime.environment.setSimpleVariable("width", DoubleValue(value))
|
||||
assertFalse(tree.prepare(200, 100))
|
||||
assertFalse(tree.onClick(10.0, 10.0, 0))
|
||||
}
|
||||
tree.updateVariables(mapOf("width" to 30.0))
|
||||
assertTrue(tree.prepare(200, 100))
|
||||
assertTrue(tree.onClick(10.0, 10.0, 0))
|
||||
assertEquals(1.0, tree.runtime.resolve("v.clicked".asExpression()).asDouble())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested and stacked coordinates cannot overflow integer draw endpoints`() {
|
||||
val runtime = MoLangRuntime()
|
||||
for (json in listOf(
|
||||
"""{"size":[100,100],"offset":[2000000000,0],"controls":[{"child":{"size":[100,20],"offset":[2000000000,0]}}]}""",
|
||||
"""{"size":[100,100],"layout":"stack_horizontal","controls":[{"a":{"size":[2000000000,20]}},{"b":{"size":[2000000000,20]}}]}""",
|
||||
"""{"size":[100,100],"controls":[{"child":{"size":[{"molang":"v.invalid"},20]}}]}"""
|
||||
)) {
|
||||
runtime.environment.setSimpleVariable("invalid", DoubleValue(Double.NaN))
|
||||
val root = WidgetExpansion.refresh(definition(json), runtime)
|
||||
assertThrows(IllegalArgumentException::class.java) { LayoutEngine.layout(root, 200f, 100f, runtime) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fill occupies available space and exhausted grid or padded content has zero width`() {
|
||||
val runtime = MoLangRuntime()
|
||||
val root = WidgetExpansion.refresh(definition("""{"size":["fill","fill"],"layout":"stack_horizontal","spacing":10,
|
||||
"controls":[{"fixed":{"size":[30,20]}},{"flex":{"size":["fill",20]}}]}"""), runtime)
|
||||
LayoutEngine.layout(root, 200f, 100f, runtime)
|
||||
assertEquals(200f, root.computedWidth)
|
||||
assertEquals(160f, root.children[1].computedWidth)
|
||||
assertEquals(40f, root.children[1].absoluteX)
|
||||
for (extra in listOf("\"padding\":[0,60,0,60]", "\"layout\":\"grid\",\"columns\":2,\"spacing\":200")) {
|
||||
val exhausted = WidgetExpansion.refresh(definition("""{"size":[100,20],$extra,
|
||||
"controls":[{"child":{"size":["fill",20]}}]}"""), runtime)
|
||||
LayoutEngine.layout(exhausted, 200f, 100f, runtime)
|
||||
assertEquals(0f, exhausted.children.single().computedWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `padding expressions retain row context and reject negative or nonfinite values`() {
|
||||
val runtime = MoLangRuntime()
|
||||
val root = WidgetExpansion.refresh(definition("""{"type":"list","size":[100,100],"source_type":"count","source":2,
|
||||
"layout":"stack_vertical","item_template":{"size":[100,30],"padding":[0,0,0,{"molang":"q.item.index * 10 + v.padding"}],
|
||||
"controls":[{"child":{"size":["100%",20]}}]}}"""), runtime)
|
||||
runtime.environment.setSimpleVariable("padding", DoubleValue(5.0))
|
||||
LayoutEngine.layout(root, 200f, 100f, runtime)
|
||||
assertEquals(5f, root.children[0].children.single().absoluteX)
|
||||
assertEquals(15f, root.children[1].children.single().absoluteX)
|
||||
assertEquals(85f, root.children[1].children.single().computedWidth)
|
||||
assertFalse("item" in runtime.environment.query.functions)
|
||||
for (value in listOf(-1.0, Double.NaN, Double.POSITIVE_INFINITY)) {
|
||||
runtime.environment.setSimpleVariable("padding", DoubleValue(value))
|
||||
assertThrows(IllegalArgumentException::class.java) { LayoutEngine.layout(root, 200f, 100f, runtime) }
|
||||
}
|
||||
for (padding in listOf("[1,2]", "[0,-1,0,0]", "[0,0,0,\"v.padding\"]")) {
|
||||
assertThrows(IllegalArgumentException::class.java) { definition("""{"padding":$padding}""") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nonfinite spacing rejects layout but hidden descendants do not evaluate geometry`() {
|
||||
val runtime = MoLangRuntime()
|
||||
runtime.environment.setSimpleVariable("invalid", DoubleValue(Double.NaN))
|
||||
val root = WidgetExpansion.refresh(definition("""{"size":[100,100],"spacing":{"molang":"v.spacing"},
|
||||
"controls":[{"hidden":{"size":[{"molang":"v.invalid"},20]}}]}"""), runtime)
|
||||
root.children.single().visible = false
|
||||
runtime.environment.setSimpleVariable("spacing", DoubleValue(Double.NaN))
|
||||
assertThrows(IllegalArgumentException::class.java) { LayoutEngine.layout(root, 200f, 100f, runtime) }
|
||||
runtime.environment.setSimpleVariable("spacing", DoubleValue(0.0))
|
||||
LayoutEngine.layout(root, 200f, 100f, runtime)
|
||||
runtime.environment.setSimpleVariable("invalid", DoubleValue(Double.NaN))
|
||||
root.visible = false
|
||||
LayoutEngine.layout(root, 200f, 100f, runtime)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.bedrockk.molang.runtime.value.DoubleValue
|
||||
import com.bedrockk.molang.runtime.value.StringValue
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiClientRuntimeFactory
|
||||
import com.cobblemon.mod.common.junit.BootstrapMinecraft
|
||||
import com.cobblemon.mod.common.util.asExpression
|
||||
import com.cobblemon.mod.common.util.resolve
|
||||
import com.google.gson.JsonParser
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@BootstrapMinecraft
|
||||
class MolangUiParametersTest {
|
||||
private val id = ResourceLocation.fromNamespaceAndPath("cobblemon", "parameters")
|
||||
|
||||
@Test
|
||||
fun `client queries preserve declared types through updates`() {
|
||||
val types = mapOf("code" to ParameterType.STRING, "amount" to ParameterType.NUMBER,
|
||||
"enabled" to ParameterType.BOOLEAN, "position" to ParameterType.VEC3)
|
||||
val parameters = MolangUiParameters.decode(types,
|
||||
mapOf("code" to "001", "amount" to "12.5", "enabled" to "true", "position" to "1,2,3"))
|
||||
val runtime = MolangUiClientRuntimeFactory.create(id, parameters, emptyMap())
|
||||
assertInstanceOf(StringValue::class.java, runtime.resolve("q.code".asExpression()))
|
||||
assertEquals("001", runtime.resolve("q.code".asExpression()).asString())
|
||||
assertInstanceOf(DoubleValue::class.java, runtime.resolve("q.amount".asExpression()))
|
||||
assertEquals(12.5, runtime.resolve("q.amount".asExpression()).asDouble())
|
||||
assertEquals(1.0, runtime.resolve("q.enabled".asExpression()).asDouble())
|
||||
assertEquals("1.0,2.0,3.0", runtime.resolve("q.position".asExpression()).asString())
|
||||
MolangUiClientRuntimeFactory.updateParameters(runtime,
|
||||
MolangUiParameters.decode(types, mapOf("code" to "NaN", "enabled" to "false")))
|
||||
assertEquals("NaN", runtime.resolve("q.code".asExpression()).asString())
|
||||
assertEquals(0.0, runtime.resolve("q.enabled".asExpression()).asDouble())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid parameters cannot overwrite runtime queries or partially update values`() {
|
||||
val runtime = MolangUiClientRuntimeFactory.create(id,
|
||||
mapOf("code" to ParameterValue.StringValue("original")), emptyMap())
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiClientRuntimeFactory.updateParameters(runtime, linkedMapOf(
|
||||
"code" to ParameterValue.StringValue("changed"), "gui_id" to ParameterValue.StringValue("wrong")))
|
||||
}
|
||||
assertEquals("original", runtime.resolve("q.code".asExpression()).asString())
|
||||
assertEquals(id.toString(), runtime.resolve("q.gui_id".asExpression()).asString())
|
||||
for (invalid in listOf("NaN", "Infinity", "1e400", "text")) {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiParameters.decode(mapOf("amount" to ParameterType.NUMBER), mapOf("amount" to invalid))
|
||||
}
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiParameters.decode(emptyMap(), mapOf("undeclared" to "1"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both resource codecs reject names that cannot be safe parameter queries`() {
|
||||
for (name in listOf("gui_id", "format", "translate", "mouse_x", "item", "MixedCase", "v.nested", "bad-name")) {
|
||||
val view = JsonParser.parseString("""{"format_version":1,"contract_version":"1","mode":"screen",
|
||||
"parameters":{"$name":"string"},"root":{"type":"panel"}}""").asJsonObject
|
||||
val contract = JsonParser.parseString("""{"format_version":1,"contract_version":"1","mode":"screen",
|
||||
"parameters":{"$name":{"type":"string"}}}""").asJsonObject
|
||||
assertThrows(IllegalArgumentException::class.java, { MolangUiRegistry.parse(id, view) }, name)
|
||||
assertThrows(IllegalArgumentException::class.java, { MolangUiContractRegistry.parse(id, contract) }, name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `variable updates reject invalid names aliases and nonfinite values atomically`() {
|
||||
val runtime = MolangUiClientRuntimeFactory.create(id, emptyMap(), mapOf("v.counter" to 1.0))
|
||||
val invalidUpdates = listOf(
|
||||
linkedMapOf("counter" to 2.0, "v.counter" to 3.0),
|
||||
linkedMapOf("counter" to 2.0, "nested.value" to 3.0),
|
||||
linkedMapOf("counter" to 2.0, "invalid" to Double.NaN),
|
||||
linkedMapOf("counter" to 2.0, "" to 3.0)
|
||||
)
|
||||
for (update in invalidUpdates) {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MolangUiClientRuntimeFactory.updateVariables(runtime, update)
|
||||
}
|
||||
assertEquals(1.0, runtime.resolve("v.counter".asExpression()).asDouble())
|
||||
}
|
||||
MolangUiClientRuntimeFactory.updateVariables(runtime, mapOf("counter" to 4.0))
|
||||
assertEquals(4.0, runtime.resolve("v.counter".asExpression()).asDouble())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty string parameters preserve their type and native conditional comparison`() {
|
||||
val runtime = MolangUiClientRuntimeFactory.create(id,
|
||||
mapOf("name" to ParameterValue.StringValue("")), emptyMap())
|
||||
assertEquals("", runtime.resolve("q.name".asExpression()).asString())
|
||||
assertEquals(1.0, runtime.resolve("q.name == '' ? 1 : 0".asExpression()).asDouble())
|
||||
assertEquals(0.0, runtime.resolve("q.name == 'trainer' ? 1 : 0".asExpression()).asDouble())
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Cobblemon Contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package com.cobblemon.mod.common.api.molangui
|
||||
|
||||
import com.cobblemon.mod.common.client.gui.molangui.MolangUiClientRuntimeFactory
|
||||
import com.cobblemon.mod.common.junit.BootstrapMinecraft
|
||||
import com.cobblemon.mod.common.util.asExpression
|
||||
import com.cobblemon.mod.common.util.resolve
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import net.minecraft.client.resources.language.I18n
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@BootstrapMinecraft
|
||||
class MolangUiRuntimeMigrationTest {
|
||||
private fun runtime() = MolangUiClientRuntimeFactory.create(
|
||||
ResourceLocation.fromNamespaceAndPath("cobblemon", "migration"), emptyMap(), emptyMap()
|
||||
)
|
||||
|
||||
@Test fun `format survives migration to the upstream general function holder`() {
|
||||
val runtime = runtime()
|
||||
val result = runtime.resolve("q.format(12.345, 2)".asExpression()).asString()
|
||||
assertEquals(String.format("%.2f", 12.345), result)
|
||||
}
|
||||
|
||||
@Test fun `new general functions do not widen the UI runtime permissions`() {
|
||||
val functions = runtime().environment.query.functions
|
||||
assertTrue("format" in functions)
|
||||
assertTrue("to_number" in functions)
|
||||
assertFalse("run_command" in functions)
|
||||
assertFalse("player" in functions)
|
||||
assertFalse("set_query" in functions)
|
||||
}
|
||||
|
||||
@Test fun `translated expressions pass arguments and follow language changes`() {
|
||||
mockkStatic(I18n::class)
|
||||
try {
|
||||
every { I18n.get("test.total", "7", "emeralds") } returns "Total: 7 emeralds"
|
||||
val runtime = runtime()
|
||||
val expression = "q.translate('test.total', '7', 'emeralds')".asExpression()
|
||||
assertEquals("Total: 7 emeralds", runtime.resolve(expression).asString())
|
||||
every { I18n.get("test.total", "7", "emeralds") } returns "Total : 7 émeraudes"
|
||||
assertEquals("Total : 7 émeraudes", runtime.resolve(expression).asString())
|
||||
} finally {
|
||||
unmockkStatic(I18n::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user