Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ private constructor(
matches.all { (path, candidate) -> actionVerified(document, path, candidate) }
}

/** Returns an action definition only from the action catalog pinned by [document]. */
fun actionDefinition(document: SceneDocument, key: gg.grounds.scene.format.ActionKey) =
actionCatalogFor(document)?.actions?.get(key)

/** Actions that this editor slice can construct without collecting arguments. */
fun parameterlessActionsFor(
document: SceneDocument
): Collection<gg.grounds.scene.format.ActionDefinition> =
actionCatalogFor(document)?.actions?.values?.filter { it.parameters.isEmpty() }.orEmpty()

private fun actionVerified(
document: SceneDocument,
path: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ enum class SceneMutationRejection {
WRONG_ASSET_KIND,
MISSING_NPC_BOUNDS,
INVALID_SCALE,
UNKNOWN_APPLICATION_ACTION,
ACTION_REQUIRES_PARAMETERS,
INTRINSIC_INVALID,
READ_ONLY_APPLICATION_ACTION,
SELECTION_REQUIRED,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gg.grounds.scene.editor.mutation

import gg.grounds.scene.editor.catalog.SceneCatalogBinding
import gg.grounds.scene.format.ActionKey
import gg.grounds.scene.format.ActivationPolicy
import gg.grounds.scene.format.ApplicationAction
import gg.grounds.scene.format.AssetKey
Expand All @@ -12,8 +13,10 @@ import gg.grounds.scene.format.Npc
import gg.grounds.scene.format.Prop
import gg.grounds.scene.format.SceneDocument
import gg.grounds.scene.format.SceneElement
import gg.grounds.scene.format.SceneTrigger
import gg.grounds.scene.format.SceneValidation
import gg.grounds.scene.format.Transform
import gg.grounds.scene.format.TriggerBinding
import gg.grounds.scene.format.Vec3
import java.util.UUID
import net.kyori.adventure.text.Component
Expand Down Expand Up @@ -103,6 +106,13 @@ object SceneMutations {
fun setLabel(actor: UUID, target: LocalId, label: Component?): SceneMutation =
LabelEdit(actor, target, label)

fun setApplicationAction(
actor: UUID,
target: LocalId,
trigger: SceneTrigger,
action: ActionKey,
): SceneMutation = ApplicationActionEdit(actor, target, trigger, action)

fun remove(actor: UUID, target: LocalId): SceneMutation = Remove(actor, target)

private data class Create(
Expand Down Expand Up @@ -312,6 +322,59 @@ object SceneMutations {
}
}

private data class ApplicationActionEdit(
override val actor: UUID,
override val target: LocalId,
private val trigger: SceneTrigger,
private val action: ActionKey,
) : SceneMutation {
override val name = "npc.action.set"

override fun apply(
document: SceneDocument,
catalogs: SceneCatalogBinding,
): SceneMutationResult {
val definition =
catalogs.actionDefinition(document, action)
?: return rejected(document, SceneMutationRejection.UNKNOWN_APPLICATION_ACTION)
if (definition.parameters.isNotEmpty())
return rejected(document, SceneMutationRejection.ACTION_REQUIRES_PARAMETERS)
return changeElement(document, target) { element ->
val npc = element as? Npc ?: return@changeElement null
val preserved =
npc.bindings.filterNot { binding ->
binding.trigger == trigger &&
binding.conditions.isEmpty() &&
binding.cooldownMillis == 0L &&
binding.debounceMillis == 0L &&
binding.actions.all { it is ApplicationAction }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unverified application-action bindings

When an NPC has an unconditional, untimed binding for this trigger containing an unknown action or catalog-invalid arguments, this predicate still classifies the binding as replaceable and silently deletes it. Other mutations deliberately preserve such actions as READ_ONLY_APPLICATION_ACTION, so setting a different valid action can unexpectedly discard data the editor cannot safely interpret; reject the edit or only replace bindings whose existing application actions are verified.

Useful? React with 👍 / 👎.

}
Npc(
npc.id,
npc.group,
npc.transform,
npc.visible,
npc.activation,
npc.body,
npc.label,
npc.labelOffset,
npc.look,
npc.initialAnimation,
npc.interactionBounds,
npc.proximity,
preserved +
TriggerBinding(
trigger,
emptyList(),
0,
0,
listOf(ApplicationAction(action, emptyMap())),
),
)
}
}
}

private data class Remove(override val actor: UUID, override val target: LocalId) :
SceneMutation {
override val name = "element.remove"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package gg.grounds.scene.editor.catalog

import gg.grounds.lobby.scene.LobbySceneCatalogs
import gg.grounds.resourcepacks.catalog.GroundsAssetCatalog
import gg.grounds.scene.format.ActionCatalog
import gg.grounds.scene.format.ActionDefinition
import gg.grounds.scene.format.ActionKey
import gg.grounds.scene.format.ActionParameter
import gg.grounds.scene.format.ActionParameterType
import gg.grounds.scene.format.ApplicationAction
import gg.grounds.scene.format.CatalogId
import gg.grounds.scene.format.CatalogReference
import gg.grounds.scene.format.LocalId
import gg.grounds.scene.format.NoConstraints
import gg.grounds.scene.format.Npc
import gg.grounds.scene.format.SceneCatalogReferences
import gg.grounds.scene.format.SceneDocument
Expand Down Expand Up @@ -68,6 +74,44 @@ class SceneCatalogBindingTest {
)
}

@Test
fun `parameterless actions exclude catalog entries that require authored arguments`() {
val navigator = ActionKey("grounds:lobby/open_navigator")
val parameterized = ActionKey("grounds:lobby/teleport")
val binding =
SceneCatalogBinding(
GroundsAssetCatalog.catalog,
ActionCatalog(
CatalogId("grounds:actions"),
"1",
mapOf(
navigator to ActionDefinition(navigator, "Navigator", "Open", emptyMap()),
parameterized to
ActionDefinition(
parameterized,
"Teleport",
"Teleport",
mapOf(
LocalId("target") to
ActionParameter(
LocalId("target"),
ActionParameterType.STRING,
true,
null,
NoConstraints,
)
),
),
),
),
)

assertEquals(
listOf(navigator),
binding.parameterlessActionsFor(binding.newDocument("grounds:test")).map { it.key },
)
}

private fun document(
source: SceneDocument,
catalogs: SceneCatalogReferences = source.catalogs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package gg.grounds.scene.editor.mutation

import gg.grounds.scene.editor.catalog.SceneCatalogBinding
import gg.grounds.scene.format.ActionCatalog
import gg.grounds.scene.format.ActionDefinition
import gg.grounds.scene.format.ActionKey
import gg.grounds.scene.format.ApplicationAction
import gg.grounds.scene.format.AssetCatalog
Expand All @@ -12,13 +13,16 @@ import gg.grounds.scene.format.CatalogId
import gg.grounds.scene.format.CatalogReference
import gg.grounds.scene.format.CatalogVersionRange
import gg.grounds.scene.format.EulerRotation
import gg.grounds.scene.format.HandCondition
import gg.grounds.scene.format.LocalBounds
import gg.grounds.scene.format.LocalId
import gg.grounds.scene.format.LookBehavior
import gg.grounds.scene.format.Npc
import gg.grounds.scene.format.Prop
import gg.grounds.scene.format.SceneDocument
import gg.grounds.scene.format.SceneHand
import gg.grounds.scene.format.SceneTrigger
import gg.grounds.scene.format.SendMessageAction
import gg.grounds.scene.format.Transform
import gg.grounds.scene.format.TriggerBinding
import gg.grounds.scene.format.Vec3
Expand Down Expand Up @@ -82,6 +86,81 @@ class SceneMutationsTest {
assertEquals(LocalBounds(Vec3(0.0, 0.9, 0.0), Vec3(0.6, 1.8, 0.6)), npc.interactionBounds)
}

@Test
fun `sets a catalogued parameterless npc action without disturbing conditional bindings`() {
val action = ActionKey("grounds:lobby/open_navigator")
val catalogs =
SceneCatalogBinding(
testAssets,
ActionCatalog(
CatalogId("grounds:actions"),
"1",
mapOf(action to ActionDefinition(action, "Navigator", "Open it", emptyMap())),
),
)
val preserved =
TriggerBinding(
SceneTrigger.RIGHT_CLICK,
listOf(HandCondition(SceneHand.MAIN)),
250,
50,
listOf(SendMessageAction(Component.text("Existing action"))),
)
val replaceable =
TriggerBinding(
SceneTrigger.RIGHT_CLICK,
emptyList(),
0,
0,
listOf(ApplicationAction(action, emptyMap())),
)
val original =
catalogs.newDocument(
"grounds:test",
elements =
listOf(
Npc(
LocalId("guide"),
null,
Transform(
placement.position,
EulerRotation(0.0, 0.0, 0.0),
Vec3(1.0, 1.0, 1.0),
),
body = AssetKey("grounds:editor/guide"),
label = null,
labelOffset = Vec3(0.0, 2.25, 0.0),
look = LookBehavior.Fixed,
initialAnimation = null,
interactionBounds =
LocalBounds(Vec3(0.0, 0.9, 0.0), Vec3(0.6, 1.8, 0.6)),
proximity = null,
bindings = listOf(preserved, replaceable),
)
),
)

val edited =
SceneMutations.setApplicationAction(
actor,
LocalId("guide"),
SceneTrigger.RIGHT_CLICK,
action,
)
.apply(original, catalogs)
.documentOrThrow()
val npc = edited.elements.single() as Npc

assertEquals(preserved, npc.bindings.first())
assertEquals(2, npc.bindings.size)
val actionBinding = npc.bindings.last()
assertEquals(SceneTrigger.RIGHT_CLICK, actionBinding.trigger)
assertTrue(actionBinding.conditions.isEmpty())
assertEquals(0, actionBinding.cooldownMillis)
assertEquals(0, actionBinding.debounceMillis)
assertEquals(ApplicationAction(action, emptyMap()), actionBinding.actions.single())
}

@Test
fun `creation rejects wrong asset kind missing npc bounds and duplicate ids without changing original`() {
val document =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ class SceneCommand(
val actions = applicationActions(session.document).ifEmpty { "none" }
feedback.info(
player,
"Scene ${session.document.id.value}: ${session.document.elements.size} elements; preserved application actions (read-only): $actions; dirty=${sessions.hasUnsavedChanges(worldId)}.",
"Scene ${session.document.id.value}: ${session.document.elements.size} elements; application actions: $actions (parameterless catalog actions can be set on NPC triggers); dirty=${sessions.hasUnsavedChanges(worldId)}.",
)
}

Expand Down Expand Up @@ -399,6 +399,17 @@ class SceneCommand(
Component.text(args.drop(4).joinToString(" ")),
)
else null
"action" ->
if (kind == "npc" && args.getOrNull(3)?.equals("set", true) == true)
SceneMutations.setApplicationAction(
player.uniqueId,
id,
gg.grounds.scene.format.SceneTrigger.valueOf(
args.getOrNull(4)?.uppercase(Locale.ROOT) ?: return null
),
gg.grounds.scene.format.ActionKey(args.getOrNull(5) ?: return null),
)
else null
"position" -> transformPosition(player, id, args)
"rotation" -> transformRotation(player, id, args)
else -> null
Expand Down Expand Up @@ -698,6 +709,13 @@ class SceneCommand(
.map { it.key.value }
.sorted()

internal fun catalogActions(sender: CommandSender?): List<String> {
val player = sender as? Player ?: return emptyList()
val worldId = resolver.resolve(player)?.worldId ?: return emptyList()
val document = sessions.session(worldId)?.document ?: return emptyList()
return catalogs.parameterlessActionsFor(document).map { it.key.value }.sorted()
}

internal fun elementIds(sender: CommandSender?, npc: Boolean): List<String> {
val player = sender as? Player ?: return emptyList()
val worldId = resolver.resolve(player)?.worldId ?: return emptyList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class SceneCommandAuthorizer(private val hasPermission: (String) -> Boolean) {
val operation = if (path.getOrNull(1) == "list") "list" else path.getOrNull(2)
val allowed =
setOf("list", "create", "select", "position", "rotation", "scale", "clone", "remove") +
if (kind == "npc") setOf("label") else emptySet()
if (kind == "npc") setOf("label", "action") else emptySet()
return operation
?.takeIf { it in allowed }
?.let { allowedOperation ->
Expand All @@ -67,7 +67,8 @@ class SceneCommandAuthorizer(private val hasPermission: (String) -> Boolean) {
path.getOrNull(3)?.let(::listOf) ?: listOf("set", "here", "add")
"rotation" -> path.getOrNull(3)?.let(::listOf) ?: listOf("set", "add")
"scale",
"label" -> path.getOrNull(3)?.let(::listOf) ?: listOf("set")
"label",
"action" -> path.getOrNull(3)?.let(::listOf) ?: listOf("set")
else -> listOf<String?>(null)
}
modes.map { mode ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ object SceneCommandDispatcher {
kind == "npc" &&
args.getOrNull(3)?.lowercase(Locale.ROOT) == "set" &&
args.size >= 5
"action" ->
kind == "npc" &&
args.getOrNull(3)?.lowercase(Locale.ROOT) == "set" &&
args.size == 6
else -> false
}
return if (valid) Route.Element(kind, operation) else Route.Invalid
Expand Down
Loading