Compare commits

...
115 changed files with 8682 additions and 7 deletions
+2
View File
@@ -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)
}
}
}
@@ -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) }
}
}
@@ -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())
}
}
@@ -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()
}
@@ -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
}
}
@@ -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 }
}
}
@@ -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
)
@@ -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"
}
}
}
}
@@ -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")
}
}
}
}
@@ -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 } }
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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")
}
@@ -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)
}
}
}
@@ -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))
}
}
}
@@ -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
}
@@ -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
}
@@ -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()
}
}
@@ -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
}
@@ -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()
}
}
}
@@ -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
)
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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())
}
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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)
}
}
}
@@ -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
)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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() }
}
@@ -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)
}
}
@@ -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)
@@ -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())
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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())
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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] } }
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

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"
}
]
}
]
}
@@ -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",
@@ -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") }
}
}
@@ -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)
}
}
@@ -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())
}
}
@@ -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