dev/studio-tiled-parser-assets-scene-asset-type #4

Open
bquarkz wants to merge 6 commits from dev/studio-tiled-parser-assets-scene-asset-type into master
14 changed files with 666 additions and 16 deletions
Showing only changes of commit 9e128988e1 - Show all commits

View File

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

View File

@ -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<PackerDiagnosticDTO> 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<AssetWorkspaceAssetAction> 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<PackerDiagnosticDTO> 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);
}

View File

@ -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);

View File

@ -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())));

View File

@ -22,6 +22,7 @@ public record AssetWorkspaceAssetDetails(
AssetWorkspaceBankCompositionDetails bankComposition,
List<Map<String, Object>> pipelinePalettes,
AssetStudioSceneBankMetadata sceneBankMetadata,
AssetWorkspaceSceneBankValidation sceneBankValidation,
List<PackerDiagnosticDTO> 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"));
}
}

View File

@ -0,0 +1,9 @@
package p.studio.workspaces.assets.messages;
public enum AssetWorkspaceSceneBankStatus {
NOT_APPLICABLE,
PENDING_VALIDATION,
VALIDATED_PENDING_ACCEPTANCE,
READY,
VALIDATION_FAILED
}

View File

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

View File

@ -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<PackerDiagnosticDTO> diagnostics) {
public SceneBankWorkflowResult {
message = Objects.requireNonNullElse(message, "");
validation = Objects.requireNonNullElse(validation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE);
diagnostics = List.copyOf(Objects.requireNonNullElse(diagnostics, List.of()));
}
}

View File

@ -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<PackerDiagnosticDTO> 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<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final List<PackerDiagnosticDTO> 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<Path> 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<PackerDiagnosticDTO> 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);
}
}

View File

@ -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

View File

@ -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());
}

View File

@ -96,6 +96,7 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L),
selectedFiles.stream().map(file -> (Map<String, Object>) file.metadata().get("palette")).toList(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}

View File

@ -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"), """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down"
width="8" height="8" tilewidth="16" tileheight="16" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/missing/tileset.tsx"/>
<layer id="1" name="Ground" width="8" height="8">
<data encoding="csv">0</data>
</layer>
</map>
""");
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());
}
}

View File

@ -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());
}
}