diff --git a/README.md b/README.md index fc1d41b..6d74526 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,7 @@ catalog pin and are validated against that exact supported lobby catalog revisio NPC bounce distance is an approximate travel distance in blocks. Use `/scene npc collision distance ` with a value from 0 through 20; the resulting travel can vary with the runtime collision environment. + +Scene-editor feedback uses the general system-message category because it describes editor +operations rather than an in-game story. Info, success, warning and error remain independent +levels that control visual emphasis without replacing the category label. diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index 1c61eb9..fcd2cfd 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -14,6 +14,7 @@ repositories { maven("https://repo.papermc.io/repository/maven-public/") } dependencies { implementation(project(":common")) + implementation("gg.grounds:library-i18n:0.4.0") compileOnly("de.eintosti:buildsystem-api:4.0.0") testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:4.115.0") diff --git a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedback.kt b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedback.kt index 216b565..bed8343 100644 --- a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedback.kt +++ b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedback.kt @@ -1,13 +1,24 @@ package gg.grounds.scene.editor.paper +import gg.grounds.i18n.Translations import gg.grounds.scene.editor.paper.command.SceneCommandFeedback -import net.kyori.adventure.text.Component import org.bukkit.command.CommandSender class AdventureSceneFeedback : SceneCommandFeedback { - override fun info(sender: CommandSender, message: String) = - sender.sendMessage(Component.text(message)) + private val messages = + Translations.forBundle("gg.grounds.scene.editor.messages", javaClass.classLoader) - override fun error(sender: CommandSender, message: String) = - sender.sendMessage(Component.text(message)) + override fun send( + sender: CommandSender, + message: SceneMessage, + vararg args: Pair, + ) { + val localizedArgs = + args + .map { (key, value) -> + key to if (value is SceneMessage) messages.render(value, sender) else value + } + .toTypedArray() + messages.send(sender, message, *localizedArgs) + } } diff --git a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/PaperWorldLifecycleListener.kt b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/PaperWorldLifecycleListener.kt index a024aa7..2d3ee11 100644 --- a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/PaperWorldLifecycleListener.kt +++ b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/PaperWorldLifecycleListener.kt @@ -4,7 +4,6 @@ import gg.grounds.scene.editor.paper.preview.PaperPreviewAdapter import gg.grounds.scene.editor.session.EditorSessionService import gg.grounds.scene.editor.session.SessionCloseResult import gg.grounds.scene.editor.session.SessionCloseSnapshot -import net.kyori.adventure.text.Component import org.bukkit.Server import org.bukkit.World import org.bukkit.event.EventHandler @@ -18,6 +17,7 @@ class PaperWorldLifecycleListener( private val sessions: EditorSessionService, private val previews: PaperPreviewAdapter, private val beforeWorldClose: (World) -> Unit = {}, + private val feedback: AdventureSceneFeedback = AdventureSceneFeedback(), ) : Listener { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) fun onWorldUnload(event: WorldUnloadEvent) { @@ -38,9 +38,7 @@ class PaperWorldLifecycleListener( "Closing dirty scene editor session for world ${snapshot.worldId} at generation ${snapshot.generation}" ) snapshot.editorPlayers.forEach { playerId -> - server - .getPlayer(playerId) - ?.sendMessage(Component.text("Scene editor closed with unsaved changes.")) + server.getPlayer(playerId)?.let { feedback.send(it, SceneMessage.DIRTY_CLOSE) } } } } diff --git a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/SceneMessage.kt b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/SceneMessage.kt new file mode 100644 index 0000000..7fdaf57 --- /dev/null +++ b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/SceneMessage.kt @@ -0,0 +1,86 @@ +package gg.grounds.scene.editor.paper + +import gg.grounds.i18n.SystemLevel +import gg.grounds.i18n.SystemMessageKey + +enum class SceneMessage( + override val id: String, + override val level: SystemLevel, +) : SystemMessageKey { + IO_FAILURE("error.io-failure", SystemLevel.ERROR), + NO_ACTIONS("scene.no-actions", SystemLevel.INFO), + UNKNOWN_INCOMPLETE("command.unknown-incomplete", SystemLevel.ERROR), + NO_PERMISSION("command.no-permission", SystemLevel.ERROR), + CATALOG_STATUS("command.catalog-status", SystemLevel.INFO), + PLAYER_WORLD_REQUIRED("command.player-world-required", SystemLevel.ERROR), + BUILD_WORLD_REQUIRED("command.build-world-required", SystemLevel.ERROR), + HELP("command.help", SystemLevel.INFO), + UNKNOWN("command.unknown", SystemLevel.ERROR), + TOOL_UNAVAILABLE("tool.unavailable", SystemLevel.ERROR), + TOOL_NAME("tool.name", SystemLevel.INFO), + TOOL_ADDED("tool.added", SystemLevel.SUCCESS), + TOOL_DROPPED("tool.dropped", SystemLevel.WARNING), + NO_SOURCE("scene.no-source", SystemLevel.INFO), + CANONICAL_OPEN_FAILED("scene.canonical-open-failed", SystemLevel.ERROR), + INVALID_SOURCE("scene.invalid-source", SystemLevel.ERROR), + REJECTED_SOURCE("scene.rejected-source", SystemLevel.ERROR), + OPEN_FAILED("scene.open-failed", SystemLevel.ERROR), + OPENING("scene.opening", SystemLevel.INFO), + INVALID_SCENE_ID("scene.invalid-id", SystemLevel.ERROR), + WORLD_UNAVAILABLE("scene.world-unavailable", SystemLevel.ERROR), + CREATE_FAILED("scene.create-failed", SystemLevel.ERROR), + READY("scene.ready", SystemLevel.SUCCESS), + ALREADY_OPEN("scene.already-open", SystemLevel.ERROR), + CANONICAL_CREATE_FAILED("scene.canonical-create-failed", SystemLevel.ERROR), + MEMORY_CREATE_FAILED("scene.memory-create-failed", SystemLevel.ERROR), + NO_SCENE_CREATE("scene.none-create", SystemLevel.INFO), + INFO("scene.info", SystemLevel.INFO), + NO_SCENE("scene.none", SystemLevel.INFO), + VALIDATION_PASSED("scene.validation-passed", SystemLevel.SUCCESS), + VALIDATION_PROBLEM("scene.validation-problem", SystemLevel.ERROR), + SAVE_FAILED("scene.save-failed", SystemLevel.ERROR), + SAVED("scene.saved", SystemLevel.SUCCESS), + SAVING("scene.saving", SystemLevel.INFO), + HISTORY("history.status", SystemLevel.INFO), + POSITIVE_STEPS("history.positive-steps", SystemLevel.ERROR), + HISTORY_APPLIED("history.applied", SystemLevel.SUCCESS), + HISTORY_NONE("history.none", SystemLevel.INFO), + ELEMENT_PAGE("element.page", SystemLevel.INFO), + NO_ELEMENTS("element.none", SystemLevel.INFO), + SELECTED("element.selected", SystemLevel.SUCCESS), + LEASED_BY("element.leased-by", SystemLevel.ERROR), + ELEMENT_NOT_FOUND("element.not-found", SystemLevel.ERROR), + INVALID_VALUE("element.invalid-value", SystemLevel.ERROR), + MUTATION_APPLIED("element.mutation-applied", SystemLevel.SUCCESS), + EDIT_REJECTED("element.edit-rejected", SystemLevel.ERROR), + LEASE_RELEASE_FORBIDDEN("lease.release-forbidden", SystemLevel.ERROR), + LEASE_RELEASED("lease.released", SystemLevel.SUCCESS), + NO_ACTIVE_LEASE("lease.none", SystemLevel.INFO), + LEASE_STATUS("lease.status", SystemLevel.INFO), + LEASE_AVAILABLE("lease.available", SystemLevel.INFO), + ELEMENT_UNAVAILABLE("lease.element-unavailable", SystemLevel.ERROR), + LEASE_OVERRIDDEN("lease.overridden", SystemLevel.SUCCESS), + SAVE_IN_PROGRESS("reload.save-in-progress", SystemLevel.WARNING), + RELOAD_FAILED("reload.failed", SystemLevel.ERROR), + RELOADED("reload.done", SystemLevel.SUCCESS), + DISCARDED_RELOADED("reload.discarded", SystemLevel.SUCCESS), + DISCARD_CONFIRMATION("reload.confirmation", SystemLevel.WARNING), + STALE_RELOAD("reload.stale", SystemLevel.ERROR), + LOADING("reload.loading", SystemLevel.INFO), + EXPORT_FAILED("recovery.export-failed", SystemLevel.ERROR), + EXPORT_WRITTEN("recovery.export-written", SystemLevel.SUCCESS), + LITERAL_CONFIRMATION("recovery.literal-confirmation", SystemLevel.WARNING), + NO_INVALID_SOURCE("recovery.no-invalid-source", SystemLevel.ERROR), + RECOVERY_FAILED("recovery.failed", SystemLevel.ERROR), + RECOVERED("recovery.done", SystemLevel.SUCCESS), + RECOVERING("recovery.in-progress", SystemLevel.INFO), + MISSING_ELEMENT_ID("element.missing-id", SystemLevel.ERROR), + INVALID_ELEMENT_ID("element.invalid-id", SystemLevel.ERROR), + DIRTY_CLOSE("lifecycle.dirty-close", SystemLevel.WARNING), + USAGE_CREATE("usage.create", SystemLevel.WARNING), + USAGE_ELEMENT("usage.element", SystemLevel.WARNING), + USAGE_INVALID_ELEMENT("usage.invalid-element", SystemLevel.WARNING), + USAGE_LEASE("usage.lease", SystemLevel.WARNING), + USAGE_RECOVERY("usage.recovery", SystemLevel.WARNING), + USAGE_RECOVER_CREATE("usage.recover-create", SystemLevel.WARNING), +} diff --git a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/command/SceneCommand.kt b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/command/SceneCommand.kt index d2a3179..6fc8e0d 100644 --- a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/command/SceneCommand.kt +++ b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/command/SceneCommand.kt @@ -3,6 +3,7 @@ package gg.grounds.scene.editor.paper.command import gg.grounds.scene.editor.catalog.SceneCatalogBinding import gg.grounds.scene.editor.mutation.PlayerPlacement import gg.grounds.scene.editor.mutation.SceneMutations +import gg.grounds.scene.editor.paper.SceneMessage import gg.grounds.scene.editor.paper.tool.EditorTool import gg.grounds.scene.editor.recovery.RecoveryService import gg.grounds.scene.editor.repository.WorldSceneRepository @@ -50,41 +51,39 @@ class SceneCommand( val path = args.map { it.lowercase(Locale.ROOT) } val route = SceneCommandDispatcher.route(args.toList()) if (args.isNotEmpty() && route is SceneCommandDispatcher.Route.Invalid) { - feedback.error(sender, "Unknown or incomplete scene command.") + feedback.send(sender, SceneMessage.UNKNOWN_INCOMPLETE) return true } val authorizer = SceneCommandAuthorizer(sender::hasPermission) if (!authorizer.isAllowed(path)) { - feedback.error(sender, "You do not have permission for this scene command.") + feedback.send(sender, SceneMessage.NO_PERMISSION) return true } if (route is SceneCommandDispatcher.Route.CatalogStatus) { - feedback.info( + feedback.send( sender, - "Asset catalog ${catalogs.assets.id}@${catalogs.assets.version}; action catalog ${catalogs.actions.id}@${catalogs.actions.version}.", + SceneMessage.CATALOG_STATUS, + "assets-id" to catalogs.assets.id, + "assets-version" to catalogs.assets.version, + "actions-id" to catalogs.actions.id, + "actions-version" to catalogs.actions.version, ) return true } val player = sender as? Player ?: run { - feedback.error( - sender, - "This scene command requires a player in a loaded build world.", - ) + feedback.send(sender, SceneMessage.PLAYER_WORLD_REQUIRED) return true } val target = resolver.resolve(player) ?: run { - feedback.error(sender, "You must be in a loaded BuildSystem build world.") + feedback.send(sender, SceneMessage.BUILD_WORLD_REQUIRED) return true } if (args.isEmpty()) { - feedback.info( - sender, - "Use /scene create, info, validate, save, prop, npc, lease, recovery, or catalogs status. NPC collision: /scene npc collision set none|solid|bounce, collision strength <0..20>, or collision distance <0..20>.", - ) + feedback.send(sender, SceneMessage.HELP) return true } if ( @@ -100,7 +99,7 @@ class SceneCommand( SceneCommandDispatcher.Route.Info -> info(player, target.worldId) SceneCommandDispatcher.Route.Validate -> validate(player, target.worldId) SceneCommandDispatcher.Route.Save -> save(player, target.worldId, target.worldFolder) - SceneCommandDispatcher.Route.History -> feedback.info(player, history(target.worldId)) + SceneCommandDispatcher.Route.History -> history(player, target.worldId) SceneCommandDispatcher.Route.Undo, SceneCommandDispatcher.Route.Redo -> historyChange(player, target.worldId, path.first(), args.getOrNull(1)) @@ -116,21 +115,19 @@ class SceneCommand( SceneCommandDispatcher.Route.RecoveryExport, is SceneCommandDispatcher.Route.RecoveryDiscard -> recovery(player, target.worldId, target.worldFolder, args) - else -> feedback.error(player, "Unknown scene command. Use /scene for available paths.") + else -> feedback.send(player, SceneMessage.UNKNOWN) } return true } private fun giveTool(player: Player) { - val available = - editorTool ?: return feedback.error(player, "The editor tool is unavailable.") - val item = available.createItem() + val available = editorTool ?: return feedback.send(player, SceneMessage.TOOL_UNAVAILABLE) + val item = available.createItem(player) val leftovers = player.inventory.addItem(item) leftovers.values.forEach { player.world.dropItemNaturally(player.location, it) } - feedback.info( + feedback.send( player, - if (leftovers.isEmpty()) "Scene editor tool added to your inventory." - else "Inventory full; scene editor tool dropped at your position.", + if (leftovers.isEmpty()) SceneMessage.TOOL_ADDED else SceneMessage.TOOL_DROPPED, ) } @@ -150,12 +147,9 @@ class SceneCommand( is SessionBootstrapResult.Opened, SessionBootstrapResult.AlreadyOpen -> onCommand(player, command, label, args) SessionBootstrapResult.AbsentSource -> - feedback.info( - player, - "No scene.json exists. Use /scene create .", - ) + feedback.send(player, SceneMessage.NO_SOURCE) SessionBootstrapResult.EncodingFailure -> - feedback.error(player, "Could not open a canonical scene document.") + feedback.send(player, SceneMessage.CANONICAL_OPEN_FAILED) is SessionBootstrapResult.InvalidSource -> { invalidSources[worldId] = result.load if ( @@ -163,136 +157,164 @@ class SceneCommand( args.getOrNull(1)?.equals("backup-and-create", true) == true ) recoverInvalid(player, worldId, root, args) - else - feedback.error( - player, - "scene.json is invalid and was not changed. Use /scene recovery backup-and-create .", - ) + else feedback.send(player, SceneMessage.INVALID_SOURCE) } is SessionBootstrapResult.RejectedSource -> - feedback.error( + feedback.send( player, - "scene.json could not be safely read: ${result.load.reason}.", + SceneMessage.REJECTED_SOURCE, + "reason" to result.load.reason, ) } }, failure = { error -> if (player.isOnline) - feedback.error( + feedback.send( player, - "Could not open scene: ${error.message ?: "I/O failure"}.", + SceneMessage.OPEN_FAILED, + "reason" to (error.message ?: SceneMessage.IO_FAILURE), ) }, ) - feedback.info(player, "Opening scene…") + feedback.send(player, SceneMessage.OPENING) } private fun create(player: Player, worldId: java.util.UUID, args: Array) { val sceneId = args.getOrNull(1) ?: run { - usage(player, "/scene create ") + usage(player, SceneMessage.USAGE_CREATE) return } val display = args.drop(2).joinToString(" ").ifBlank { - usage(player, "/scene create ") + usage(player, SceneMessage.USAGE_CREATE) return } val document = try { catalogs.newDocument(sceneId, SceneMetadata(sceneId, display, emptySet())) } catch (_: IllegalArgumentException) { - feedback.error(player, "Invalid scene id.") + feedback.send(player, SceneMessage.INVALID_SCENE_ID) return } val root = resolver.resolve(player)?.worldFolder - ?: return feedback.error(player, "Build world is no longer available.") + ?: return feedback.send(player, SceneMessage.WORLD_UNAVAILABLE) scheduler.asyncThenMain( task = { sessions.openFromRepository(worldId, WorldSceneRepository(root), document) }, failure = { error -> if (player.isOnline) - feedback.error( + feedback.send( player, - "Could not create scene: ${error.message ?: "I/O failure"}.", + SceneMessage.CREATE_FAILED, + "reason" to (error.message ?: SceneMessage.IO_FAILURE), ) }, ) { result -> if (!player.isOnline) return@asyncThenMain when (result) { is SessionBootstrapResult.Opened -> - feedback.info( + feedback.send( player, - "Scene '${result.session.document.id.value}' is ready for editing.", + SceneMessage.READY, + "scene-id" to result.session.document.id.value, ) SessionBootstrapResult.AlreadyOpen -> - feedback.error(player, "A scene is already open for this world.") + feedback.send(player, SceneMessage.ALREADY_OPEN) SessionBootstrapResult.EncodingFailure -> - feedback.error(player, "Could not create a canonical scene document.") + feedback.send(player, SceneMessage.CANONICAL_CREATE_FAILED) SessionBootstrapResult.AbsentSource -> - feedback.error(player, "Could not initialize a new in-memory scene.") + feedback.send(player, SceneMessage.MEMORY_CREATE_FAILED) is SessionBootstrapResult.InvalidSource -> { invalidSources[worldId] = result.load - feedback.error( - player, - "scene.json is invalid and was not changed. Use /scene recovery backup-and-create .", - ) + feedback.send(player, SceneMessage.INVALID_SOURCE) } is SessionBootstrapResult.RejectedSource -> - feedback.error( + feedback.send( player, - "scene.json could not be safely read: ${result.load.reason}.", + SceneMessage.REJECTED_SOURCE, + "reason" to result.load.reason, ) } } - feedback.info(player, "Opening scene…") + feedback.send(player, SceneMessage.OPENING) } private fun info(player: Player, worldId: java.util.UUID) { val session = - sessions.session(worldId) - ?: return feedback.info(player, "No scene is open. Use /scene create first.") - val actions = applicationActions(session.document).ifEmpty { "none" } - feedback.info( + sessions.session(worldId) ?: return feedback.send(player, SceneMessage.NO_SCENE_CREATE) + val actions = + applicationActions(session.document).takeIf { it.isNotEmpty() } + ?: SceneMessage.NO_ACTIONS + feedback.send( player, - "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)}.", + SceneMessage.INFO, + "scene-id" to session.document.id.value, + "count" to session.document.elements.size, + "actions" to actions, + "dirty" to sessions.hasUnsavedChanges(worldId), ) } private fun validate(player: Player, worldId: java.util.UUID) { - val session = sessions.session(worldId) ?: return feedback.info(player, "No scene is open.") + val session = + sessions.session(worldId) ?: return feedback.send(player, SceneMessage.NO_SCENE) val state = - sessions.validation(worldId) ?: return feedback.info(player, "No scene is open.") + sessions.validation(worldId) ?: return feedback.send(player, SceneMessage.NO_SCENE) val problems = (state.intrinsic.problems + state.catalogs.validation.problems).sortedWith( compareBy({ it.path }, { it.code }) ) - if (problems.isEmpty()) feedback.info(player, "Scene validation passed.") - else problems.take(10).forEach { feedback.error(player, "${it.path}: ${it.code}") } + if (problems.isEmpty()) feedback.send(player, SceneMessage.VALIDATION_PASSED) + else + problems.take(10).forEach { + feedback.send( + player, + SceneMessage.VALIDATION_PROBLEM, + "path" to it.path, + "code" to it.code, + ) + } } private fun save(player: Player, worldId: java.util.UUID, root: java.nio.file.Path) { - if (sessions.session(worldId) == null) return feedback.info(player, "No scene is open.") + if (sessions.session(worldId) == null) return feedback.send(player, SceneMessage.NO_SCENE) scheduler.asyncThenMain( task = { sessions.save(worldId, WorldSceneRepository(root)) }, failure = { error -> if (player.isOnline) - feedback.error(player, "Scene save failed: ${error.message ?: "I/O failure"}.") + feedback.send( + player, + SceneMessage.SAVE_FAILED, + "reason" to (error.message ?: SceneMessage.IO_FAILURE), + ) }, ) { result -> if (!player.isOnline) return@asyncThenMain if (result is gg.grounds.scene.editor.repository.SceneSaveResult.Saved) - feedback.info(player, "Scene saved locally to scene.json.") - else feedback.error(player, "Scene save failed: ${result::class.simpleName}.") + feedback.send(player, SceneMessage.SAVED) + else + feedback.send( + player, + SceneMessage.SAVE_FAILED, + "reason" to result::class.simpleName.orEmpty(), + ) } - feedback.info(player, "Saving scene locally…") + feedback.send(player, SceneMessage.SAVING) } - private fun history(worldId: java.util.UUID): String = - sessions.session(worldId)?.history?.let { - "History: ${it.undoSize} undo, ${it.redoSize} redo." - } ?: "No scene is open." + private fun history(player: Player, worldId: java.util.UUID) { + val history = sessions.session(worldId)?.history + if (history == null) feedback.send(player, SceneMessage.NO_SCENE) + else + feedback.send( + player, + SceneMessage.HISTORY, + "undo" to history.undoSize, + "redo" to history.redoSize, + ) + } private fun historyChange( player: Player, @@ -301,11 +323,15 @@ class SceneCommand( rawSteps: String?, ) { val steps = rawSteps?.toIntOrNull() ?: 1 - if (steps <= 0) return feedback.error(player, "Steps must be a positive integer.") + if (steps <= 0) return feedback.send(player, SceneMessage.POSITIVE_STEPS) val changed = if (operation == "undo") sessions.undo(worldId, steps) else sessions.redo(worldId, steps) - feedback.info(player, if (changed) "$operation applied." else "Nothing to $operation.") + feedback.send( + player, + if (changed) SceneMessage.HISTORY_APPLIED else SceneMessage.HISTORY_NONE, + "operation" to operation, + ) } private fun element( @@ -321,29 +347,33 @@ class SceneCommand( (kind == "npc" && it is gg.grounds.scene.format.Npc) } val page = SceneCommandParser.page(elements, args.getOrNull(2)?.toIntOrNull() ?: 1) - return feedback.info( - player, - page.entries - .joinToString(prefix = "$kind page ${page.page}/${page.pages}: ") { - it.id.value - } - .ifBlank { "No $kind elements." }, - ) + return if (page.entries.isEmpty()) { + feedback.send(player, SceneMessage.NO_ELEMENTS, "kind" to kind) + } else { + feedback.send( + player, + SceneMessage.ELEMENT_PAGE, + "kind" to kind, + "page" to page.page, + "pages" to page.pages, + "elements" to page.entries.joinToString { it.id.value }, + ) + } } val id = localId(player, args.getOrNull(1)) ?: return val operation = args.getOrNull(2)?.lowercase(Locale.ROOT) ?: run { - usage(player, "/scene $kind <$kind-id> ") + usage(player, SceneMessage.USAGE_ELEMENT, "kind" to kind) return } if (operation == "select") { when (val selected = sessions.select(worldId, player.uniqueId, id)) { is gg.grounds.scene.editor.session.SelectionResult.Selected -> - feedback.info(player, "Selected ${id.value}.") + feedback.send(player, SceneMessage.SELECTED, "element-id" to id.value) is gg.grounds.scene.editor.session.SelectionResult.Refused -> - feedback.error(player, "Element is leased by ${selected.owner}.") - else -> feedback.error(player, "Element was not found.") + feedback.send(player, SceneMessage.LEASED_BY, "owner" to selected.owner) + else -> feedback.send(player, SceneMessage.ELEMENT_NOT_FOUND) } return } @@ -351,19 +381,24 @@ class SceneCommand( try { mutation(player, kind, id, operation, args) } catch (_: IllegalArgumentException) { - feedback.error(player, "Invalid finite numeric value or identifier.") + feedback.send(player, SceneMessage.INVALID_VALUE) return } ?: run { - usage(player, "Invalid /scene $kind command.") + usage(player, SceneMessage.USAGE_INVALID_ELEMENT, "kind" to kind) return } val outcome = sessions.mutate(worldId, mutation) - feedback.info( + feedback.send( player, - if (outcome.accepted) "${mutation.name} applied." + if (outcome.accepted) SceneMessage.MUTATION_APPLIED else SceneMessage.EDIT_REJECTED, + if (outcome.accepted) "mutation" to mutation.name else - "Edit rejected: ${outcome.result?.let { (it as? gg.grounds.scene.editor.mutation.SceneMutationResult.Rejected)?.reason } ?: "no session"}.", + "reason" to + (outcome.result?.let { + (it as? gg.grounds.scene.editor.mutation.SceneMutationResult.Rejected) + ?.reason + } ?: "no session"), ) } @@ -488,33 +523,61 @@ class SceneCommand( is gg.grounds.scene.editor.session.LeaseStatusResult.Held if (status.lease.owner != player.uniqueId && !player.hasPermission("grounds.scene.lease.override")) - -> - feedback.error( - player, - "Only the lease owner or an override administrator may release it.", - ) + -> feedback.send(player, SceneMessage.LEASE_RELEASE_FORBIDDEN) else -> when (val result = sessions.releaseLease(worldId, id)) { is gg.grounds.scene.editor.session.LeaseReleaseResult.Released -> - feedback.info(player, "Lease for ${id.value} released.") - else -> feedback.info(player, "No active lease for ${id.value}.") + feedback.send( + player, + SceneMessage.LEASE_RELEASED, + "element-id" to id.value, + ) + else -> + feedback.send( + player, + SceneMessage.NO_ACTIVE_LEASE, + "element-id" to id.value, + ) } } "status" -> when (val result = sessions.leaseStatus(worldId, id)) { is gg.grounds.scene.editor.session.LeaseStatusResult.Held -> - feedback.info(player, "Lease for ${id.value}: ${result.lease.owner}.") + feedback.send( + player, + SceneMessage.LEASE_STATUS, + "element-id" to id.value, + "owner" to result.lease.owner, + ) gg.grounds.scene.editor.session.LeaseStatusResult.Available -> - feedback.info(player, "Lease for ${id.value} is available.") - else -> feedback.info(player, "Element ${id.value} is unavailable.") + feedback.send( + player, + SceneMessage.LEASE_AVAILABLE, + "element-id" to id.value, + ) + else -> + feedback.send( + player, + SceneMessage.ELEMENT_UNAVAILABLE, + "element-id" to id.value, + ) } "override" -> when (val result = sessions.overrideSelection(worldId, player.uniqueId, id)) { is gg.grounds.scene.editor.session.SelectionResult.Selected -> - feedback.info(player, "Lease for ${id.value} overridden and selected.") - else -> feedback.error(player, "Element ${id.value} is unavailable.") + feedback.send( + player, + SceneMessage.LEASE_OVERRIDDEN, + "element-id" to id.value, + ) + else -> + feedback.send( + player, + SceneMessage.ELEMENT_UNAVAILABLE, + "element-id" to id.value, + ) } - else -> usage(player, "/scene lease status|release ") + else -> usage(player, SceneMessage.USAGE_LEASE) } } @@ -528,23 +591,21 @@ class SceneCommand( when (val result = sessions.prepareReload(worldId)) { is ReloadPreparationResult.Prepared -> result ReloadPreparationResult.NoSession -> - return feedback.info(player, "No scene is open.") + return feedback.send(player, SceneMessage.NO_SCENE) ReloadPreparationResult.SaveInProgress -> - return feedback.error( - player, - "Scene save is in progress; reload is unavailable.", - ) + return feedback.send(player, SceneMessage.SAVE_IN_PROGRESS) } val discardedSceneId = sessions.session(worldId)?.document?.id?.value - ?: return feedback.info(player, "No scene is open.") + ?: return feedback.send(player, SceneMessage.NO_SCENE) scheduler.asyncThenMain( task = { WorldSceneRepository(root).load() }, failure = { error -> if (player.isOnline) - feedback.error( + feedback.send( player, - "Could not reload scene: ${error.message ?: "I/O failure"}.", + SceneMessage.RELOAD_FAILED, + "reason" to (error.message ?: SceneMessage.IO_FAILURE), ) }, ) { load -> @@ -553,27 +614,27 @@ class SceneCommand( when (result) { is gg.grounds.scene.editor.session.SessionReloadResult.Reloaded -> { val audit = result.discardAudit - feedback.info( + feedback.send( player, - if (audit == null) "Scene reloaded from disk." - else - "Discarded scene $discardedSceneId at ${audit.discardedFingerprint} and reloaded from disk.", + if (audit == null) SceneMessage.RELOADED + else SceneMessage.DISCARDED_RELOADED, + "scene-id" to discardedSceneId, + "fingerprint" to (audit?.discardedFingerprint ?: ""), ) } gg.grounds.scene.editor.session.SessionReloadResult.DiscardConfirmationRequired -> - feedback.error( - player, - "Unsaved edits remain. Use /scene recovery discard-and-reload confirm.", - ) + feedback.send(player, SceneMessage.DISCARD_CONFIRMATION) gg.grounds.scene.editor.session.SessionReloadResult.StaleSession -> - feedback.error( + feedback.send(player, SceneMessage.STALE_RELOAD) + else -> + feedback.send( player, - "Scene changed while reloading; no edits were discarded.", + SceneMessage.RELOAD_FAILED, + "reason" to result::class.simpleName.orEmpty(), ) - else -> feedback.error(player, "Reload failed: ${result::class.simpleName}.") } } - feedback.info(player, "Loading scene from disk…") + feedback.send(player, SceneMessage.LOADING) } private fun recovery( @@ -585,10 +646,7 @@ class SceneCommand( val action = args.getOrNull(1)?.lowercase(Locale.ROOT) ?: run { - usage( - player, - "/scene recovery backup-and-create|export|discard-and-reload confirm", - ) + usage(player, SceneMessage.USAGE_RECOVERY) return } when (action) { @@ -597,7 +655,7 @@ class SceneCommand( run { val session = sessions.session(worldId) - ?: return feedback.info(player, "No scene is open.") + ?: return feedback.send(player, SceneMessage.NO_SCENE) val document = session.document scheduler.asyncThenMain( task = { @@ -606,9 +664,10 @@ class SceneCommand( }, failure = { error -> if (player.isOnline) - feedback.error( + feedback.send( player, - "Recovery export failed: ${error.message ?: "I/O failure"}.", + SceneMessage.EXPORT_FAILED, + "reason" to (error.message ?: SceneMessage.IO_FAILURE), ) }, ) { result -> @@ -616,27 +675,25 @@ class SceneCommand( if ( result is gg.grounds.scene.editor.recovery.RecoveryExportResult.Exported ) - feedback.info( + feedback.send( player, - "Diagnostic export written to ${result.export.path.fileName}.", + SceneMessage.EXPORT_WRITTEN, + "file" to result.export.path.fileName, ) else - feedback.error( + feedback.send( player, - "Recovery export failed: ${result::class.simpleName}.", + SceneMessage.EXPORT_FAILED, + "reason" to result::class.simpleName.orEmpty(), ) } } "discard-and-reload" -> { if (args.getOrNull(2) != "confirm") - return feedback.error( - player, - "Literal confirmation required: /scene recovery discard-and-reload confirm", - ) + return feedback.send(player, SceneMessage.LITERAL_CONFIRMATION) reload(player, worldId, root, ReloadPolicy.CONFIRMED_DISCARD) } - else -> - usage(player, "/scene recovery backup-and-create|export|discard-and-reload confirm") + else -> usage(player, SceneMessage.USAGE_RECOVERY) } } @@ -647,23 +704,17 @@ class SceneCommand( args: Array, ) { val invalid = - invalidSources[worldId] - ?: return feedback.error(player, "No invalid scene source is awaiting recovery.") - val sceneId = - args.getOrNull(2) - ?: return usage( - player, - "/scene recovery backup-and-create ", - ) + invalidSources[worldId] ?: return feedback.send(player, SceneMessage.NO_INVALID_SOURCE) + val sceneId = args.getOrNull(2) ?: return usage(player, SceneMessage.USAGE_RECOVER_CREATE) val display = args.drop(3).joinToString(" ").ifBlank { - return usage(player, "/scene recovery backup-and-create ") + return usage(player, SceneMessage.USAGE_RECOVER_CREATE) } val document = try { catalogs.newDocument(sceneId, SceneMetadata(sceneId, display, emptySet())) } catch (_: IllegalArgumentException) { - return feedback.error(player, "Invalid scene id.") + return feedback.send(player, SceneMessage.INVALID_SCENE_ID) } scheduler.asyncThenMain( task = { @@ -676,23 +727,30 @@ class SceneCommand( }, failure = { error -> if (player.isOnline) - feedback.error( + feedback.send( player, - "Invalid-file recovery failed: ${error.message ?: "I/O failure"}.", + SceneMessage.RECOVERY_FAILED, + "reason" to (error.message ?: SceneMessage.IO_FAILURE), ) }, ) { result -> if (result is gg.grounds.scene.editor.session.InvalidRecoveryOpenResult.Opened) { invalidSources.remove(worldId) if (!player.isOnline) return@asyncThenMain - feedback.info( + feedback.send( player, - "Invalid scene backed up as ${result.backup.fileName}; scene '${result.session.document.id.value}' created locally.", + SceneMessage.RECOVERED, + "backup" to result.backup.fileName, + "scene-id" to result.session.document.id.value, ) } else if (player.isOnline) - feedback.error(player, "Invalid-file recovery failed: ${result::class.simpleName}.") + feedback.send( + player, + SceneMessage.RECOVERY_FAILED, + "reason" to result::class.simpleName.orEmpty(), + ) } - feedback.info(player, "Backing up invalid scene and creating a new scene…") + feedback.send(player, SceneMessage.RECOVERING) } private fun localId(player: Player, value: String?): LocalId? = @@ -700,12 +758,12 @@ class SceneCommand( LocalId( value ?: run { - usage(player, "Missing element id.") + usage(player, SceneMessage.MISSING_ELEMENT_ID) return null } ) } catch (_: IllegalArgumentException) { - feedback.error(player, "Invalid element id.") + feedback.send(player, SceneMessage.INVALID_ELEMENT_ID) null } @@ -723,9 +781,11 @@ class SceneCommand( SceneCommandParser.bounceDistance(value) ?: throw IllegalArgumentException("bounce distance required") - private fun usage(sender: CommandSender, message: String) { - feedback.error(sender, message) - } + private fun usage( + sender: CommandSender, + message: SceneMessage, + vararg args: Pair, + ) = feedback.send(sender, message, *args) override fun onTabComplete( sender: CommandSender, @@ -794,8 +854,6 @@ interface SceneCommandScheduler { ): CompletableFuture } -interface SceneCommandFeedback { - fun info(sender: CommandSender, message: String) - - fun error(sender: CommandSender, message: String) +fun interface SceneCommandFeedback { + fun send(sender: CommandSender, message: SceneMessage, vararg args: Pair) } diff --git a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/tool/EditorTool.kt b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/tool/EditorTool.kt index ac49eaa..91a9549 100644 --- a/paper/src/main/kotlin/gg/grounds/scene/editor/paper/tool/EditorTool.kt +++ b/paper/src/main/kotlin/gg/grounds/scene/editor/paper/tool/EditorTool.kt @@ -1,6 +1,9 @@ package gg.grounds.scene.editor.paper.tool -import net.kyori.adventure.text.Component +import gg.grounds.i18n.Translations +import gg.grounds.scene.editor.paper.SceneMessage +import java.util.Locale +import net.kyori.adventure.audience.Audience import org.bukkit.Material import org.bukkit.NamespacedKey import org.bukkit.inventory.ItemStack @@ -10,11 +13,16 @@ import org.bukkit.plugin.java.JavaPlugin /** Exact PDC identity for the future editor input listener. */ class EditorTool(plugin: JavaPlugin) { private val key = NamespacedKey(plugin, "editor_tool") + private val messages = + Translations.forBundle("gg.grounds.scene.editor.messages", javaClass.classLoader) - fun createItem(): ItemStack = + fun createItem(audience: Audience? = null): ItemStack = ItemStack(Material.BLAZE_ROD).also { item -> item.editMeta { meta -> - meta.displayName(Component.text("Scene Editor Tool")) + meta.displayName( + if (audience == null) messages.render(SceneMessage.TOOL_NAME, Locale.ENGLISH) + else messages.render(SceneMessage.TOOL_NAME, audience) + ) meta.persistentDataContainer.set(key, PersistentDataType.BYTE, 1.toByte()) } } diff --git a/paper/src/main/resources/gg/grounds/scene/editor/messages.properties b/paper/src/main/resources/gg/grounds/scene/editor/messages.properties new file mode 100644 index 0000000..61163f7 --- /dev/null +++ b/paper/src/main/resources/gg/grounds/scene/editor/messages.properties @@ -0,0 +1,77 @@ +command.unknown-incomplete=Unknown or incomplete scene command. +command.no-permission=You do not have permission for this scene command. +command.catalog-status=Asset catalog @; action catalog @. +command.player-world-required=This scene command requires a player in a loaded build world. +command.build-world-required=You must be in a loaded BuildSystem build world. +command.help=Use /scene create, info, validate, save, prop, npc, lease, recovery, or catalogs status. NPC collision: /scene npc \ collision set none|solid|bounce, collision strength \<0..20>, or collision distance \<0..20>. +command.unknown=Unknown scene command. Use /scene for available paths. +tool.unavailable=The editor tool is unavailable. +tool.name=Scene Editor Tool +tool.added=Scene editor tool added to your inventory. +tool.dropped=Inventory full; scene editor tool dropped at your position. +scene.no-source=No scene.json exists. Use /scene create \ \. +scene.canonical-open-failed=Could not open a canonical scene document. +scene.invalid-source=scene.json is invalid and was not changed. Use /scene recovery backup-and-create \ \. +scene.rejected-source=scene.json could not be safely read: . +scene.open-failed=Could not open scene: . +scene.opening=Opening scene… +scene.invalid-id=Invalid scene id. +scene.world-unavailable=Build world is no longer available. +scene.create-failed=Could not create scene: . +scene.ready=Scene '' is ready for editing. +scene.already-open=A scene is already open for this world. +scene.canonical-create-failed=Could not create a canonical scene document. +scene.memory-create-failed=Could not initialize a new in-memory scene. +scene.none-create=No scene is open. Use /scene create first. +scene.info=Scene : elements; application actions: (parameterless catalog actions can be set on NPC triggers); dirty=. +scene.none=No scene is open. +scene.validation-passed=Scene validation passed. +scene.validation-problem=: +scene.save-failed=Scene save failed: . +scene.saved=Scene saved locally to scene.json. +scene.saving=Saving scene locally… +history.status=History: undo, redo. +history.positive-steps=Steps must be a positive integer. +history.applied= applied. +history.none=Nothing to . +element.page= page /: +element.none=No elements. +element.selected=Selected . +element.leased-by=Element is leased by . +element.not-found=Element was not found. +element.invalid-value=Invalid finite numeric value or identifier. +element.mutation-applied= applied. +element.edit-rejected=Edit rejected: . +lease.release-forbidden=Only the lease owner or an override administrator may release it. +lease.released=Lease for released. +lease.none=No active lease for . +lease.status=Lease for : . +lease.available=Lease for is available. +lease.element-unavailable=Element is unavailable. +lease.overridden=Lease for overridden and selected. +reload.save-in-progress=Scene save is in progress; reload is unavailable. +reload.failed=Could not reload scene: . +reload.done=Scene reloaded from disk. +reload.discarded=Discarded scene at and reloaded from disk. +reload.confirmation=Unsaved edits remain. Use /scene recovery discard-and-reload confirm. +reload.stale=Scene changed while reloading; no edits were discarded. +reload.loading=Loading scene from disk… +recovery.export-failed=Recovery export failed: . +recovery.export-written=Diagnostic export written to . +recovery.literal-confirmation=Literal confirmation required: /scene recovery discard-and-reload confirm +recovery.no-invalid-source=No invalid scene source is awaiting recovery. +recovery.failed=Invalid-file recovery failed: . +recovery.done=Invalid scene backed up as ; scene '' created locally. +recovery.in-progress=Backing up invalid scene and creating a new scene… +element.missing-id=Missing element id. +element.invalid-id=Invalid element id. +lifecycle.dirty-close=Scene editor closed with unsaved changes. +usage.create=/scene create \ \ +usage.element=/scene \<-id> \ +usage.invalid-element=Invalid /scene command. +usage.lease=/scene lease status|release \ +usage.recovery=/scene recovery backup-and-create|export|discard-and-reload confirm +usage.recover-create=/scene recovery backup-and-create \ \ + +error.io-failure=I/O failure +scene.no-actions=none diff --git a/paper/src/main/resources/gg/grounds/scene/editor/messages_de.properties b/paper/src/main/resources/gg/grounds/scene/editor/messages_de.properties new file mode 100644 index 0000000..f5f65e2 --- /dev/null +++ b/paper/src/main/resources/gg/grounds/scene/editor/messages_de.properties @@ -0,0 +1,78 @@ +command.unknown-incomplete=Unbekannter oder unvollständiger Szenenbefehl. +command.no-permission=Du hast keine Berechtigung für diesen Szenenbefehl. +command.catalog-status=Asset-Katalog @; Aktionskatalog @. +command.player-world-required=Dieser Szenenbefehl erfordert einen Spieler in einer geladenen Build-Welt. +command.build-world-required=Du musst dich in einer geladenen BuildSystem-Build-Welt befinden. +command.help=Verwende /scene create, info, validate, save, prop, npc, lease, recovery oder catalogs status. NPC-Kollision: /scene npc \ collision set none|solid|bounce, collision strength \<0..20> oder collision distance \<0..20>. +command.unknown=Unbekannter Szenenbefehl. Verwende /scene für verfügbare Pfade. +tool.unavailable=Das Editor-Werkzeug ist nicht verfügbar. +tool.name=Szeneneditor-Werkzeug +tool.added=Szeneneditor-Werkzeug wurde deinem Inventar hinzugefügt. +tool.dropped=Inventar voll; das Szeneneditor-Werkzeug wurde an deiner Position abgelegt. +scene.no-source=Es gibt keine scene.json. Verwende /scene create \ \. +scene.canonical-open-failed=Ein kanonisches Szenendokument konnte nicht geöffnet werden. +scene.invalid-source=scene.json ist ungültig und wurde nicht verändert. Verwende /scene recovery backup-and-create \ \. +scene.rejected-source=scene.json konnte nicht sicher gelesen werden: . +scene.open-failed=Szene konnte nicht geöffnet werden: . +scene.opening=Szene wird geöffnet… +scene.invalid-id=Ungültige Szenen-ID. +scene.world-unavailable=Die Build-Welt ist nicht mehr verfügbar. +scene.create-failed=Szene konnte nicht erstellt werden: . +scene.ready=Szene „“ kann jetzt bearbeitet werden. +scene.already-open=Für diese Welt ist bereits eine Szene geöffnet. +scene.canonical-create-failed=Ein kanonisches Szenendokument konnte nicht erstellt werden. +scene.memory-create-failed=Eine neue Szene konnte nicht im Arbeitsspeicher initialisiert werden. +scene.none-create=Keine Szene ist geöffnet. Verwende zuerst /scene create. +scene.info=Szene : Elemente; Anwendungsaktionen: (parameterlose Katalogaktionen können für NPC-Auslöser festgelegt werden); geändert=. +scene.none=Keine Szene ist geöffnet. +scene.validation-passed=Die Szenenprüfung war erfolgreich. +scene.validation-problem=: +scene.save-failed=Speichern der Szene fehlgeschlagen: . +scene.saved=Szene wurde lokal in scene.json gespeichert. +scene.saving=Szene wird lokal gespeichert… +history.status=Verlauf: rückgängig, wiederholen. +history.positive-steps=Die Schrittzahl muss eine positive ganze Zahl sein. +history.applied= angewendet. +history.none=Nichts für vorhanden. +element.page=-Seite /: +element.none=Keine -Elemente. +element.selected= ausgewählt. +element.leased-by=Das Element ist von gesperrt. +element.not-found=Das Element wurde nicht gefunden. +element.invalid-value=Ungültiger endlicher Zahlenwert oder Bezeichner. +element.mutation-applied= angewendet. +element.edit-rejected=Bearbeitung abgelehnt: . +lease.release-forbidden=Nur der Besitzer der Sperre oder ein Administrator darf sie aufheben. +lease.released=Sperre für aufgehoben. +lease.none=Keine aktive Sperre für . +lease.status=Sperre für : . +lease.available=Sperre für ist verfügbar. +lease.element-unavailable=Element ist nicht verfügbar. +lease.overridden=Sperre für überschrieben und Element ausgewählt. +reload.save-in-progress=Die Szene wird gerade gespeichert; Neuladen ist nicht verfügbar. +reload.failed=Szene konnte nicht neu geladen werden: . +reload.done=Szene wurde von der Festplatte neu geladen. +reload.discarded=Szene bei verworfen und von der Festplatte neu geladen. +reload.confirmation=Es gibt ungespeicherte Änderungen. Verwende /scene recovery discard-and-reload confirm. +reload.stale=Die Szene wurde während des Neuladens geändert; keine Änderungen wurden verworfen. +reload.loading=Szene wird von der Festplatte geladen… +recovery.export-failed=Wiederherstellungsexport fehlgeschlagen: . +recovery.export-written=Diagnoseexport wurde nach geschrieben. +recovery.literal-confirmation=Wörtliche Bestätigung erforderlich: /scene recovery discard-and-reload confirm +recovery.no-invalid-source=Keine ungültige Szenenquelle wartet auf Wiederherstellung. +recovery.failed=Wiederherstellung der ungültigen Datei fehlgeschlagen: . +recovery.done=Ungültige Szene als gesichert; Szene „“ lokal erstellt. +recovery.in-progress=Ungültige Szene wird gesichert und eine neue Szene erstellt… +element.missing-id=Element-ID fehlt. +element.invalid-id=Ungültige Element-ID. +lifecycle.dirty-close=Szeneneditor mit ungespeicherten Änderungen geschlossen. +usage.create=/scene create \ \ +usage.element=/scene \<-id> \ +usage.invalid-element=Ungültiger /scene--Befehl. +usage.lease=/scene lease status|release \ +usage.recovery=/scene recovery backup-and-create|export|discard-and-reload confirm +usage.recover-create=/scene recovery backup-and-create \ \ + + +error.io-failure=Ein-/Ausgabefehler +scene.no-actions=keine diff --git a/paper/src/test/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedbackTest.kt b/paper/src/test/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedbackTest.kt new file mode 100644 index 0000000..5df2e05 --- /dev/null +++ b/paper/src/test/kotlin/gg/grounds/scene/editor/paper/AdventureSceneFeedbackTest.kt @@ -0,0 +1,44 @@ +package gg.grounds.scene.editor.paper + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.bukkit.command.CommandSender +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify + +class AdventureSceneFeedbackTest { + @Test + fun `formats informational feedback as system chat`() { + val sender = mock(CommandSender::class.java) + + AdventureSceneFeedback().send(sender, SceneMessage.SAVED) + + assertEquals( + "[Grounds] General › Scene saved locally to scene.json.", + sentPlainText(sender), + ) + } + + @Test + fun `formats error feedback with the error category`() { + val sender = mock(CommandSender::class.java) + + AdventureSceneFeedback() + .send( + sender, + SceneMessage.SAVE_FAILED, + "reason" to "I/O failure", + ) + + assertEquals("[Grounds] General › Scene save failed: I/O failure.", sentPlainText(sender)) + } + + private fun sentPlainText(sender: CommandSender): String { + val message = ArgumentCaptor.forClass(Component::class.java) + verify(sender).sendMessage(message.capture()) + return PlainTextComponentSerializer.plainText().serialize(message.value) + } +} diff --git a/paper/src/test/kotlin/gg/grounds/scene/editor/paper/SceneMessagesTest.kt b/paper/src/test/kotlin/gg/grounds/scene/editor/paper/SceneMessagesTest.kt new file mode 100644 index 0000000..2ca5d43 --- /dev/null +++ b/paper/src/test/kotlin/gg/grounds/scene/editor/paper/SceneMessagesTest.kt @@ -0,0 +1,38 @@ +package gg.grounds.scene.editor.paper + +import gg.grounds.i18n.BundleCheck +import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemLevel +import java.util.Locale +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SceneMessagesTest { + @Test + fun `message bundles are complete`() { + assertEquals( + emptyList(), + BundleCheck.problems( + "gg.grounds.scene.editor.messages", + javaClass.classLoader, + SceneMessage.entries, + translations = listOf(Locale.GERMAN), + ), + ) + } + + @Test + fun `editor messages use general context with independent outcome levels`() { + val outcomes = + listOf( + SceneMessage.SAVED to SystemLevel.SUCCESS, + SceneMessage.SAVE_FAILED to SystemLevel.ERROR, + SceneMessage.DIRTY_CLOSE to SystemLevel.WARNING, + SceneMessage.SAVING to SystemLevel.INFO, + ) + outcomes.forEach { (message, expectedLevel) -> + assertEquals(SystemCategory.GENERAL, message.category) + assertEquals(expectedLevel, message.level) + } + } +} diff --git a/paper/src/test/kotlin/gg/grounds/scene/editor/paper/command/SceneCommandAdapterTest.kt b/paper/src/test/kotlin/gg/grounds/scene/editor/paper/command/SceneCommandAdapterTest.kt index 36a4444..5541f0d 100644 --- a/paper/src/test/kotlin/gg/grounds/scene/editor/paper/command/SceneCommandAdapterTest.kt +++ b/paper/src/test/kotlin/gg/grounds/scene/editor/paper/command/SceneCommandAdapterTest.kt @@ -1,7 +1,10 @@ package gg.grounds.scene.editor.paper.command +import gg.grounds.i18n.SystemLevel +import gg.grounds.i18n.Translations import gg.grounds.scene.editor.catalog.SceneCatalogBinding import gg.grounds.scene.editor.paper.BuildWorldTarget +import gg.grounds.scene.editor.paper.SceneMessage import gg.grounds.scene.editor.paper.tool.EditorTool import gg.grounds.scene.editor.repository.WorldSceneRepository import gg.grounds.scene.editor.session.EditorSessionService @@ -31,6 +34,7 @@ import gg.grounds.scene.format.TriggerBinding import gg.grounds.scene.format.Vec3 import java.nio.file.Files import java.nio.file.Path +import java.util.Locale import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.atomic.AtomicBoolean @@ -577,13 +581,23 @@ class SceneCommandAdapterTest { private class Feedback : SceneCommandFeedback { val infos = mutableListOf() val errors = mutableListOf() + private val messages = + Translations.forBundle( + "gg.grounds.scene.editor.messages", + javaClass.classLoader, + ) - override fun info(sender: CommandSender, message: String) { - infos += message - } - - override fun error(sender: CommandSender, message: String) { - errors += message + override fun send( + sender: CommandSender, + message: SceneMessage, + vararg args: Pair, + ) { + val rendered = + PlainTextComponentSerializer.plainText() + .serialize(messages.render(message, Locale.ENGLISH, *args)) + if (message.level == SystemLevel.ERROR || message.level == SystemLevel.WARNING) + errors += rendered + else infos += rendered } }