From b77a24c57b7ce2dc9737c99eff7ddac2cdf84d98 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Sat, 18 Apr 2026 18:01:25 +0100 Subject: [PATCH] implements PLN-0055 --- .../java/p/studio/utilities/i18n/I18n.java | 7 + .../assets/details/AssetDetailsControl.java | 119 +++++++- .../assets/details/AssetDetailsUiSupport.java | 11 + .../summary/AssetDetailsSummaryControl.java | 3 + .../messages/AssetWorkspaceAssetDetails.java | 2 + .../AssetWorkspaceSceneBankStatus.java | 9 + .../AssetWorkspaceSceneBankValidation.java | 27 ++ .../assets/scene/SceneBankWorkflowResult.java | 20 ++ .../scene/SceneBankWorkflowService.java | 279 ++++++++++++++++++ .../main/resources/i18n/messages.properties | 7 + ...DetailsBankCompositionCoordinatorTest.java | 2 + ...ailsPaletteOverhaulingCoordinatorTest.java | 1 + .../scene/SceneBankWorkflowServiceTest.java | 193 ++++++++++++ .../TiledAssetGenerationServiceTest.java | 2 + 14 files changed, 666 insertions(+), 16 deletions(-) create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index afa38129..37691d84 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -156,6 +156,8 @@ public enum I18n { ASSETS_ACTION_ANALYSE("assets.action.analyse"), ASSETS_ACTION_GENERATE_TSX("assets.action.generateTsx"), ASSETS_ACTION_GENERATE_TMX("assets.action.generateTmx"), + ASSETS_ACTION_VALIDATE_SCENE_BANK("assets.action.validateSceneBank"), + ASSETS_ACTION_ACCEPT_SCENE_BANK("assets.action.acceptSceneBank"), ASSETS_ACTION_DELETE("assets.action.delete"), ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), @@ -193,6 +195,7 @@ public enum I18n { ASSETS_LABEL_ASSET_ID("assets.label.assetId"), ASSETS_LABEL_TYPE("assets.label.type"), ASSETS_LABEL_STUDIO_ROLE("assets.label.studioRole"), + ASSETS_LABEL_SCENE_STATUS("assets.label.sceneStatus"), ASSETS_LABEL_SCENE_LAYERS("assets.label.sceneLayers"), ASSETS_LABEL_TILEMAPS("assets.label.tilemaps"), ASSETS_LABEL_SUPPORT_FILE("assets.label.supportFile"), @@ -205,6 +208,10 @@ public enum I18n { ASSETS_SPECIALIZATION_TILESET("assets.specialization.tileset"), ASSETS_SPECIALIZATION_SPRITES("assets.specialization.sprites"), ASSETS_SPECIALIZATION_UI("assets.specialization.ui"), + ASSETS_SCENE_STATUS_PENDING_VALIDATION("assets.sceneStatus.pendingValidation"), + ASSETS_SCENE_STATUS_VALIDATED_PENDING_ACCEPTANCE("assets.sceneStatus.validatedPendingAcceptance"), + ASSETS_SCENE_STATUS_READY("assets.sceneStatus.ready"), + ASSETS_SCENE_STATUS_VALIDATION_FAILED("assets.sceneStatus.validationFailed"), ASSETS_LABEL_LOCATION("assets.label.location"), ASSETS_LABEL_BANK("assets.label.bank"), ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java index 4b9cdc76..6650adcb 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java @@ -30,12 +30,16 @@ import p.studio.workspaces.assets.messages.AssetWorkspaceAssetAction; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile; import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; +import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; +import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; +import p.studio.workspaces.assets.scene.SceneBankWorkflowResult; +import p.studio.workspaces.assets.scene.SceneBankWorkflowService; import p.studio.workspaces.assets.tiled.TiledAssetGenerationResult; import p.studio.workspaces.assets.tiled.TiledAssetGenerationService; import p.studio.workspaces.assets.wizards.DeleteAssetDialog; @@ -43,6 +47,7 @@ import p.studio.workspaces.assets.wizards.MoveAssetWizard; import p.studio.workspaces.framework.StudioEventAware; import p.studio.workspaces.framework.StudioEventBindings; +import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicLong; @@ -60,6 +65,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware private final AssetDetailsPaletteOverhaulingControl paletteOverhaulingControl; private final AssetStudioMetadataService studioMetadataService = new AssetStudioMetadataService(); private final TiledAssetGenerationService tiledGenerationService = new TiledAssetGenerationService(); + private final SceneBankWorkflowService sceneBankWorkflowService = new SceneBankWorkflowService(); private final VBox actionsContent = new VBox(10); private final ScrollPane actionsScroll = new ScrollPane(); private final VBox actionsSection; @@ -343,6 +349,17 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware generateTmxButton.setOnAction(ignored -> generateTmx()); nodes.add(generateTmxButton); } + if (canValidateSceneBank()) { + final Button validateSceneButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_VALIDATE_SCENE_BANK)); + validateSceneButton.setDisable(actionRunning); + validateSceneButton.setOnAction(ignored -> validateSceneBank()); + nodes.add(validateSceneButton); + + final Button acceptSceneButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_ACCEPT_SCENE_BANK)); + acceptSceneButton.setDisable(actionRunning || !viewState.selectedAssetDetails().sceneBankValidation().canAccept()); + acceptSceneButton.setOnAction(ignored -> acceptSceneBank()); + nodes.add(acceptSceneButton); + } final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel()); AssetDetailsUiSupport.applyActionTone( @@ -479,6 +496,11 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware && viewState.selectedAssetDetails().summary().assetFamily() == p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK; } + private boolean canValidateSceneBank() { + return viewState.selectedAssetDetails() != null + && viewState.selectedAssetDetails().summary().assetFamily() == p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK; + } + private void generateTsx() { if (actionRunning || viewState.selectedAssetDetails() == null) { return; @@ -510,6 +532,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware private void applyTiledGenerationResult(TiledAssetGenerationResult result) { actionRunning = false; actionFeedbackMessage = result.message(); + workspaceBus.publish(new StudioAssetLogEvent("scene-bank", result.message())); renderActions(); if (result.success() && viewState.selectedAssetReference() != null) { workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference())); @@ -517,6 +540,45 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware } } + private void validateSceneBank() { + if (actionRunning || viewState.selectedAssetDetails() == null) { + return; + } + actionRunning = true; + actionFeedbackMessage = null; + renderActions(); + final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); + Container.backgroundTasks().submit(() -> { + final SceneBankWorkflowResult result = sceneBankWorkflowService.validate(projectReference, details); + Platform.runLater(() -> applySceneBankWorkflowResult("scene-bank-validate", result)); + }); + } + + private void acceptSceneBank() { + if (actionRunning || viewState.selectedAssetDetails() == null) { + return; + } + actionRunning = true; + actionFeedbackMessage = null; + renderActions(); + final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); + Container.backgroundTasks().submit(() -> { + final SceneBankWorkflowResult result = sceneBankWorkflowService.accept(projectReference, details); + Platform.runLater(() -> applySceneBankWorkflowResult("scene-bank-accept", result)); + }); + } + + private void applySceneBankWorkflowResult(String source, SceneBankWorkflowResult result) { + actionRunning = false; + actionFeedbackMessage = result.message(); + workspaceBus.publish(new StudioAssetLogEvent(source, result.message())); + renderActions(); + if (viewState.selectedAssetReference() != null) { + workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference())); + workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true)); + } + } + private void applyBuildParticipationResult(UpdateAssetBuildParticipationResult result) { actionRunning = false; if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) { @@ -606,13 +668,14 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware final AssetStudioMetadataSnapshot studioMetadata = studioMetadataService.read( baseSummary.assetRoot(), baseSummary.assetFamily()); - final java.util.List mergedDiagnostics = new java.util.ArrayList<>(details.diagnostics()); - for (PackerDiagnosticDTO diagnostic : diagnostics) { - if (!mergedDiagnostics.contains(diagnostic)) { - mergedDiagnostics.add(diagnostic); - } - } - return new AssetWorkspaceAssetDetails( + final java.util.List mappedActions = actions.stream() + .map(action -> new AssetWorkspaceAssetAction( + action.action(), + action.enabled(), + action.visible(), + action.reason())) + .toList(); + final p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary mappedSummary = new p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary( baseSummary.assetReference(), baseSummary.assetName(), @@ -623,23 +686,47 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware studioMetadata.glyphSpecialization(), baseSummary.assetRoot(), baseSummary.preload(), - baseSummary.hasDiagnostics()), - actions.stream() - .map(action -> new AssetWorkspaceAssetAction( - action.action(), - action.enabled(), - action.visible(), - action.reason())) - .toList(), + baseSummary.hasDiagnostics()); + final AssetWorkspaceBankCompositionDetails bankComposition = mapBankComposition(details.bankComposition()); + final AssetWorkspaceAssetDetails draftDetails = new AssetWorkspaceAssetDetails( + mappedSummary, + mappedActions, details.outputFormat(), details.outputCodec(), details.availableOutputCodecs(), details.codecConfigurationFieldsByCodec(), details.metadataFields(), details.outputPipeline(), - mapBankComposition(details.bankComposition()), + bankComposition, details.pipelinePalettes(), studioMetadata.sceneBankMetadata(), + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, + List.of()); + final SceneBankWorkflowResult sceneBankWorkflow = sceneBankWorkflowService.inspect(projectReference, draftDetails); + final java.util.List mergedDiagnostics = new java.util.ArrayList<>(details.diagnostics()); + for (PackerDiagnosticDTO diagnostic : diagnostics) { + if (!mergedDiagnostics.contains(diagnostic)) { + mergedDiagnostics.add(diagnostic); + } + } + for (PackerDiagnosticDTO diagnostic : sceneBankWorkflow.diagnostics()) { + if (!mergedDiagnostics.contains(diagnostic)) { + mergedDiagnostics.add(diagnostic); + } + } + return new AssetWorkspaceAssetDetails( + mappedSummary, + mappedActions, + details.outputFormat(), + details.outputCodec(), + details.availableOutputCodecs(), + details.codecConfigurationFieldsByCodec(), + details.metadataFields(), + details.outputPipeline(), + bankComposition, + details.pipelinePalettes(), + studioMetadata.sceneBankMetadata(), + sceneBankWorkflow.validation(), mergedDiagnostics); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java index 0d01bc2b..19db6ba3 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java @@ -17,6 +17,7 @@ import p.studio.controls.forms.StudioSection; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; +import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankStatus; import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState; import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; @@ -139,6 +140,16 @@ public final class AssetDetailsUiSupport { }; } + public static String sceneBankStatusLabel(AssetWorkspaceSceneBankStatus status) { + return switch (status) { + case NOT_APPLICABLE -> ""; + case PENDING_VALIDATION -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_PENDING_VALIDATION); + case VALIDATED_PENDING_ACCEPTANCE -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_VALIDATED_PENDING_ACCEPTANCE); + case READY -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_READY); + case VALIDATION_FAILED -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_VALIDATION_FAILED); + }; + } + public static String actionLabel(AssetAction action) { return switch (action) { case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java index 52128a88..fc9c0731 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java @@ -78,6 +78,9 @@ public final class AssetDetailsSummaryControl extends VBox implements StudioCont AssetDetailsUiSupport.specializationLabel(summary.glyphSpecialization()))); } if (sceneBankMetadata != null) { + content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_SCENE_STATUS), + AssetDetailsUiSupport.sceneBankStatusLabel(viewState.selectedAssetDetails().sceneBankValidation().status()))); content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow( Container.i18n().text(I18n.ASSETS_LABEL_SCENE_LAYERS), String.valueOf(sceneBankMetadata.layerCount()))); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java index 7a1d42c0..2d9efd37 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java @@ -22,6 +22,7 @@ public record AssetWorkspaceAssetDetails( AssetWorkspaceBankCompositionDetails bankComposition, List> pipelinePalettes, AssetStudioSceneBankMetadata sceneBankMetadata, + AssetWorkspaceSceneBankValidation sceneBankValidation, List diagnostics) { public AssetWorkspaceAssetDetails { @@ -35,6 +36,7 @@ public record AssetWorkspaceAssetDetails( outputPipeline = Map.copyOf(Objects.requireNonNull(outputPipeline, "outputPipeline")); bankComposition = Objects.requireNonNull(bankComposition, "bankComposition"); pipelinePalettes = List.copyOf(Objects.requireNonNull(pipelinePalettes, "pipelinePalettes")); + sceneBankValidation = Objects.requireNonNullElse(sceneBankValidation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE); diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java new file mode 100644 index 00000000..72090756 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java @@ -0,0 +1,9 @@ +package p.studio.workspaces.assets.messages; + +public enum AssetWorkspaceSceneBankStatus { + NOT_APPLICABLE, + PENDING_VALIDATION, + VALIDATED_PENDING_ACCEPTANCE, + READY, + VALIDATION_FAILED +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java new file mode 100644 index 00000000..df80e247 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java @@ -0,0 +1,27 @@ +package p.studio.workspaces.assets.messages; + +import java.util.Objects; + +public record AssetWorkspaceSceneBankValidation( + AssetWorkspaceSceneBankStatus status, + boolean pendingExternalChanges, + boolean canAccept, + String currentFingerprint, + String validatedFingerprint, + String acceptedFingerprint) { + + public static final AssetWorkspaceSceneBankValidation NOT_APPLICABLE = new AssetWorkspaceSceneBankValidation( + AssetWorkspaceSceneBankStatus.NOT_APPLICABLE, + false, + false, + "", + "", + ""); + + public AssetWorkspaceSceneBankValidation { + status = Objects.requireNonNullElse(status, AssetWorkspaceSceneBankStatus.NOT_APPLICABLE); + currentFingerprint = Objects.requireNonNullElse(currentFingerprint, ""); + validatedFingerprint = Objects.requireNonNullElse(validatedFingerprint, ""); + acceptedFingerprint = Objects.requireNonNullElse(acceptedFingerprint, ""); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java new file mode 100644 index 00000000..9fd6d718 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java @@ -0,0 +1,20 @@ +package p.studio.workspaces.assets.scene; + +import p.packer.dtos.PackerDiagnosticDTO; +import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation; + +import java.util.List; +import java.util.Objects; + +public record SceneBankWorkflowResult( + boolean success, + String message, + AssetWorkspaceSceneBankValidation validation, + List diagnostics) { + + public SceneBankWorkflowResult { + message = Objects.requireNonNullElse(message, ""); + validation = Objects.requireNonNullElse(validation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE); + diagnostics = List.copyOf(Objects.requireNonNullElse(diagnostics, List.of())); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java new file mode 100644 index 00000000..13d490ec --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java @@ -0,0 +1,279 @@ +package p.studio.workspaces.assets.scene; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import p.packer.dtos.PackerDiagnosticDTO; +import p.packer.messages.diagnostics.PackerDiagnosticCategory; +import p.packer.messages.diagnostics.PackerDiagnosticSeverity; +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata; +import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; +import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankStatus; +import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation; +import p.studio.workspaces.assets.tiled.TiledMapDocument; +import p.studio.workspaces.assets.tiled.TiledTilesetDocument; +import p.studio.workspaces.assets.tiled.TiledUnsupportedFeatureException; +import p.studio.workspaces.assets.tiled.TiledXmlCodec; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +public final class SceneBankWorkflowService { + private static final String VALIDATION_FILE = "scene-bank.validation.json"; + private static final String ACCEPTANCE_FILE = "scene-bank.acceptance.json"; + + private final ObjectMapper mapper = new ObjectMapper(); + private final TiledXmlCodec codec = new TiledXmlCodec(); + + public SceneBankWorkflowResult inspect(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + if (details == null || details.summary().assetFamily() != p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK) { + return new SceneBankWorkflowResult(true, "", AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); + } + final List diagnostics = validateDiagnostics(projectReference, details); + final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking) + ? "" + : computeFingerprint(projectReference, details); + final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE)); + final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE)); + final AssetWorkspaceSceneBankValidation validation = validationState( + diagnostics, + currentFingerprint, + validatedFingerprint, + acceptedFingerprint); + return new SceneBankWorkflowResult( + diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking), + messageFor(validation), + validation, + diagnostics); + } + + public SceneBankWorkflowResult validate(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + final SceneBankWorkflowResult inspection = inspect(projectReference, details); + final Path validationFile = details.summary().assetRoot().resolve(VALIDATION_FILE); + try { + if (inspection.success() && !inspection.validation().currentFingerprint().isBlank()) { + writeFingerprint(validationFile, inspection.validation().currentFingerprint()); + } else { + Files.deleteIfExists(validationFile); + } + } catch (IOException exception) { + return new SceneBankWorkflowResult( + false, + "Unable to persist validation state: " + exception.getMessage(), + inspection.validation(), + inspection.diagnostics()); + } + return inspect(projectReference, details); + } + + public SceneBankWorkflowResult accept(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + final SceneBankWorkflowResult inspection = inspect(projectReference, details); + if (!inspection.validation().canAccept()) { + return new SceneBankWorkflowResult( + false, + "Scene Bank must validate successfully before acceptance.", + inspection.validation(), + inspection.diagnostics()); + } + try { + writeFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE), inspection.validation().currentFingerprint()); + writeFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE), inspection.validation().currentFingerprint()); + } catch (IOException exception) { + return new SceneBankWorkflowResult( + false, + "Unable to persist acceptance state: " + exception.getMessage(), + inspection.validation(), + inspection.diagnostics()); + } + return inspect(projectReference, details); + } + + private List validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + final List diagnostics = new ArrayList<>(); + final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); + if (metadata == null) { + diagnostics.add(error("Scene Bank support metadata is missing or invalid.", details.summary().assetRoot().resolve("scene-bank.studio.json"))); + return diagnostics; + } + for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) { + if (binding.tilesetAssetRoot().isBlank()) { + diagnostics.add(error("Layer " + binding.index() + " is missing tileset_asset_root.", metadata.supportFile())); + continue; + } + final Path tmxPath = details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize(); + final Path tsxPath = projectReference.rootPath() + .resolve("assets") + .resolve(binding.tilesetAssetRoot()) + .resolve("tileset.tsx") + .toAbsolutePath() + .normalize(); + if (!Files.isRegularFile(tmxPath)) { + diagnostics.add(error("TMX file is missing for layer " + binding.index() + ": " + binding.tilemap(), tmxPath)); + continue; + } + if (!Files.isRegularFile(tsxPath)) { + diagnostics.add(error("Referenced TSX file is missing for layer " + binding.index() + ": " + binding.tilesetAssetRoot(), tsxPath)); + continue; + } + try { + final TiledTilesetDocument tileset = codec.readTileset(tsxPath); + final TiledMapDocument map = codec.readMap(tmxPath); + final String expectedSource = details.summary().assetRoot() + .toAbsolutePath() + .normalize() + .relativize(tsxPath) + .toString() + .replace('\\', '/'); + if (map.tilesets().size() != 1) { + diagnostics.add(error("TMX must reference exactly one TSX in wave 1: " + binding.tilemap(), tmxPath)); + } else if (!expectedSource.equals(map.tilesets().getFirst().source())) { + diagnostics.add(error("TMX tileset reference does not match support metadata: " + binding.tilemap(), tmxPath)); + } + if (map.width() != metadata.mapWidth() || map.height() != metadata.mapHeight()) { + diagnostics.add(error("TMX dimensions do not match scene-bank support metadata: " + binding.tilemap(), tmxPath)); + } + if (map.tileWidth() != tileset.tileWidth() || map.tileHeight() != tileset.tileHeight()) { + diagnostics.add(error("TMX tile size does not match referenced TSX: " + binding.tilemap(), tmxPath)); + } + if (map.tileLayers().size() != 1 || !binding.layerName().equals(map.tileLayers().getFirst().name())) { + diagnostics.add(error("TMX layer mapping does not match scene-bank support metadata: " + binding.tilemap(), tmxPath)); + } + } catch (TiledUnsupportedFeatureException exception) { + diagnostics.add(error(exception.getMessage(), tmxPath)); + } catch (IOException exception) { + diagnostics.add(error("Unable to validate Scene Bank XML: " + exception.getMessage(), tmxPath)); + } + } + return List.copyOf(diagnostics); + } + + private String computeFingerprint(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); + if (metadata == null) { + return ""; + } + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final List files = new ArrayList<>(); + files.add(metadata.supportFile()); + for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) { + files.add(details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize()); + if (!binding.tilesetAssetRoot().isBlank()) { + files.add(projectReference.rootPath().resolve("assets").resolve(binding.tilesetAssetRoot()).resolve("tileset.tsx").toAbsolutePath().normalize()); + } + } + files.stream() + .distinct() + .sorted(Comparator.comparing(Path::toString)) + .forEach(path -> updateDigest(digest, path)); + return HexFormat.of().formatHex(digest.digest()); + } catch (Exception exception) { + return ""; + } + } + + private void updateDigest(MessageDigest digest, Path path) { + try { + if (!Files.isRegularFile(path)) { + return; + } + digest.update(path.toString().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(Files.readAllBytes(path)); + digest.update((byte) 0); + } catch (IOException ignored) { + } + } + + private AssetWorkspaceSceneBankValidation validationState( + List diagnostics, + String currentFingerprint, + String validatedFingerprint, + String acceptedFingerprint) { + if (diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)) { + return new AssetWorkspaceSceneBankValidation( + AssetWorkspaceSceneBankStatus.VALIDATION_FAILED, + true, + false, + currentFingerprint, + validatedFingerprint, + acceptedFingerprint); + } + final boolean validated = !currentFingerprint.isBlank() && currentFingerprint.equals(validatedFingerprint); + final boolean accepted = validated && currentFingerprint.equals(acceptedFingerprint); + if (accepted) { + return new AssetWorkspaceSceneBankValidation( + AssetWorkspaceSceneBankStatus.READY, + false, + false, + currentFingerprint, + validatedFingerprint, + acceptedFingerprint); + } + if (validated) { + return new AssetWorkspaceSceneBankValidation( + AssetWorkspaceSceneBankStatus.VALIDATED_PENDING_ACCEPTANCE, + true, + true, + currentFingerprint, + validatedFingerprint, + acceptedFingerprint); + } + return new AssetWorkspaceSceneBankValidation( + AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, + true, + false, + currentFingerprint, + validatedFingerprint, + acceptedFingerprint); + } + + private String messageFor(AssetWorkspaceSceneBankValidation validation) { + return switch (validation.status()) { + case NOT_APPLICABLE -> ""; + case PENDING_VALIDATION -> "Scene Bank has pending external changes and requires validation."; + case VALIDATED_PENDING_ACCEPTANCE -> "Scene Bank validation succeeded and is waiting for explicit acceptance."; + case READY -> "Scene Bank is ready."; + case VALIDATION_FAILED -> "Scene Bank validation failed."; + }; + } + + private String readFingerprint(Path path) { + if (!Files.isRegularFile(path)) { + return ""; + } + try { + final JsonNode root = mapper.readTree(path.toFile()); + return root.path("fingerprint").asText("").trim(); + } catch (IOException ignored) { + return ""; + } + } + + private void writeFingerprint(Path path, String fingerprint) throws IOException { + final var root = mapper.createObjectNode(); + root.put("schema_version", 1); + root.put("fingerprint", Objects.requireNonNullElse(fingerprint, "")); + root.put("recorded_at", Instant.now().toString()); + mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), root); + } + + private PackerDiagnosticDTO error(String message, Path path) { + return new PackerDiagnosticDTO( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + message, + path, + true); + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 9678c314..2fd5070b 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -146,6 +146,8 @@ assets.action.register=Register assets.action.analyse=Analyse assets.action.generateTsx=Generate TSX assets.action.generateTmx=Generate TMX +assets.action.validateSceneBank=Validate Scene Bank +assets.action.acceptSceneBank=Accept Scene Bank assets.action.delete=Delete assets.deleteDialog.title=Delete Asset assets.deleteDialog.description=Type the asset name below to confirm deletion of {0}. @@ -184,6 +186,7 @@ assets.label.buildParticipation=Build Participation assets.label.assetId=Asset ID assets.label.type=Type assets.label.studioRole=Studio Role +assets.label.sceneStatus=Scene Status assets.label.sceneLayers=Scene Layers assets.label.tilemaps=Tilemaps assets.label.supportFile=Support File @@ -196,6 +199,10 @@ assets.specialization.none=None assets.specialization.tileset=Tileset assets.specialization.sprites=Sprites assets.specialization.ui=UI +assets.sceneStatus.pendingValidation=Pending Validation +assets.sceneStatus.validatedPendingAcceptance=Validated / Pending Acceptance +assets.sceneStatus.ready=Ready +assets.sceneStatus.validationFailed=Validation Failed assets.label.location=Location assets.label.bank=Bank assets.label.targetLocation=Target Location diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java index 1b0c470f..164cfd57 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java @@ -116,6 +116,7 @@ final class AssetDetailsBankCompositionCoordinatorTest { 0L), List.of(), null, + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); } @@ -139,6 +140,7 @@ final class AssetDetailsBankCompositionCoordinatorTest { 0L), List.of(), null, + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java index 90328eb8..3592e950 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java @@ -96,6 +96,7 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest { new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L), selectedFiles.stream().map(file -> (Map) file.metadata().get("palette")).toList(), null, + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java new file mode 100644 index 00000000..160fedfc --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java @@ -0,0 +1,193 @@ +package p.studio.workspaces.assets.scene; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.dtos.PackerCodecConfigurationFieldDTO; +import p.packer.messages.AssetReference; +import p.packer.messages.assets.AssetFamilyCatalog; +import p.packer.messages.assets.OutputCodecCatalog; +import p.packer.messages.assets.OutputFormatCatalog; +import p.packer.messages.assets.PackerCodecConfigurationFieldType; +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; +import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata; +import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; +import p.studio.workspaces.assets.messages.*; +import p.studio.workspaces.assets.tiled.TiledAssetGenerationService; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +final class SceneBankWorkflowServiceTest { + private final TiledAssetGenerationService generationService = new TiledAssetGenerationService(); + private final SceneBankWorkflowService workflowService = new SceneBankWorkflowService(); + + @TempDir + Path tempDir; + + @Test + void validateThenAcceptPromotesSceneBankToReadyUntilFilesChange() throws Exception { + final ProjectReference project = projectReference(); + final AssetWorkspaceAssetDetails sceneDetails = createValidScene(project); + + final SceneBankWorkflowResult initial = workflowService.inspect(project, sceneDetails); + assertEquals(AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, initial.validation().status()); + assertFalse(initial.validation().canAccept()); + + final SceneBankWorkflowResult validated = workflowService.validate(project, sceneDetails); + assertEquals(AssetWorkspaceSceneBankStatus.VALIDATED_PENDING_ACCEPTANCE, validated.validation().status()); + assertTrue(validated.validation().canAccept()); + + final SceneBankWorkflowResult accepted = workflowService.accept(project, sceneDetails); + assertEquals(AssetWorkspaceSceneBankStatus.READY, accepted.validation().status()); + assertFalse(accepted.validation().pendingExternalChanges()); + + final Path tmxPath = sceneDetails.summary().assetRoot().resolve("ground.tmx"); + Files.writeString(tmxPath, Files.readString(tmxPath).replaceFirst("0,0,0", "1,0,0")); + + final SceneBankWorkflowResult changed = workflowService.inspect(project, sceneDetails); + assertEquals(AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, changed.validation().status()); + assertTrue(changed.validation().pendingExternalChanges()); + } + + @Test + void validationFailsWhenReferencedTsxIsMissing() throws Exception { + final ProjectReference project = projectReference(); + final Path sceneRoot = project.rootPath().resolve("assets/scenes/broken"); + Files.createDirectories(sceneRoot); + final AssetWorkspaceAssetDetails brokenScene = new AssetWorkspaceAssetDetails( + new AssetWorkspaceAssetSummary( + AssetReference.forAssetId(2), + "broken_scene", + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, + 2, + AssetFamilyCatalog.SCENE_BANK, + AssetStudioGlyphSpecialization.NONE, + sceneRoot, + false, + false), + List.of(), + OutputFormatCatalog.SCENE_TILED_V1, + OutputCodecCatalog.NONE, + List.of(OutputCodecCatalog.NONE), + Map.of(OutputCodecCatalog.NONE, List.of()), + List.of(), + Map.of(), + new AssetWorkspaceBankCompositionDetails(List.of(), List.of(), 0L), + List.of(), + new AssetStudioSceneBankMetadata( + 8, + 8, + 1, + List.of(new AssetStudioSceneLayerBinding(1, "Ground", "ground.tmx", "tilesets/missing")), + sceneRoot.resolve("scene-bank.studio.json")), + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, + List.of()); + + Files.writeString(sceneRoot.resolve("ground.tmx"), """ + + + + + 0 + + + """); + + final SceneBankWorkflowResult result = workflowService.inspect(project, brokenScene); + + assertEquals(AssetWorkspaceSceneBankStatus.VALIDATION_FAILED, result.validation().status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Referenced TSX file is missing"))); + } + + private ProjectReference projectReference() { + return new ProjectReference("main", "1", "pbs", 1, tempDir.resolve("project")); + } + + private AssetWorkspaceAssetDetails createValidScene(ProjectReference project) throws Exception { + final Path tilesetRoot = project.rootPath().resolve("assets/tilesets/overworld"); + Files.createDirectories(tilesetRoot); + Files.writeString(tilesetRoot.resolve("a.png"), "fixture"); + assertTrue(generationService.generateTilesetTsx(tilesetDetails(tilesetRoot)).success()); + + final Path sceneRoot = project.rootPath().resolve("assets/scenes/overworld"); + Files.createDirectories(sceneRoot); + final AssetWorkspaceAssetDetails scene = sceneDetails(sceneRoot); + assertTrue(generationService.generateSceneBankTilemaps(project, scene).success()); + return scene; + } + + private AssetWorkspaceAssetDetails tilesetDetails(Path assetRoot) { + return new AssetWorkspaceAssetDetails( + new AssetWorkspaceAssetSummary( + AssetReference.forAssetId(1), + "overworld_tileset", + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, + 1, + AssetFamilyCatalog.GLYPH_BANK, + AssetStudioGlyphSpecialization.TILESET, + assetRoot, + false, + false), + List.of(), + OutputFormatCatalog.GLYPH_INDEXED_V1, + OutputCodecCatalog.NONE, + List.of(OutputCodecCatalog.NONE), + Map.of(OutputCodecCatalog.NONE, List.of()), + List.of(new PackerCodecConfigurationFieldDTO( + "tile_size", + "Tile Size", + PackerCodecConfigurationFieldType.ENUM, + "16x16", + true, + List.of("8x8", "16x16", "32x32"))), + Map.of(), + new AssetWorkspaceBankCompositionDetails( + List.of(), + List.of(new AssetWorkspaceBankCompositionFile("a.png", "a.png", 1L, 1L, null, Map.of())), + 0L), + List.of(), + null, + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, + List.of()); + } + + private AssetWorkspaceAssetDetails sceneDetails(Path assetRoot) { + return new AssetWorkspaceAssetDetails( + new AssetWorkspaceAssetSummary( + AssetReference.forAssetId(2), + "overworld_scene", + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, + 2, + AssetFamilyCatalog.SCENE_BANK, + AssetStudioGlyphSpecialization.NONE, + assetRoot, + false, + false), + List.of(), + OutputFormatCatalog.SCENE_TILED_V1, + OutputCodecCatalog.NONE, + List.of(OutputCodecCatalog.NONE), + Map.of(OutputCodecCatalog.NONE, List.of()), + List.of(), + Map.of(), + new AssetWorkspaceBankCompositionDetails(List.of(), List.of(), 0L), + List.of(), + new AssetStudioSceneBankMetadata( + 16, + 12, + 1, + List.of(new AssetStudioSceneLayerBinding(1, "Ground", "ground.tmx", "tilesets/overworld")), + assetRoot.resolve("scene-bank.studio.json")), + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, + List.of()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java index dfa3cec7..8462118f 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java @@ -105,6 +105,7 @@ final class TiledAssetGenerationServiceTest { 0L), List.of(), null, + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); } @@ -136,6 +137,7 @@ final class TiledAssetGenerationServiceTest { 1, List.of(new AssetStudioSceneLayerBinding(1, "Ground", "ground.tmx", "tilesets/overworld")), assetRoot.resolve("scene-bank.studio.json")), + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); } }