diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java index bfb6ec55..0b231b1f 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java @@ -1,5 +1,6 @@ package p.packer.repositories; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -16,6 +17,8 @@ import java.util.Optional; import java.util.Set; public class PackerAssetWalker { + private static final int TILE_BANK_EMITTED_SHEET_SIZE = 256; + private static final int TILE_BANK_MAX_PALETTES = 64; private static final Set TILE_BANK_SUPPORTED_FORMATS = Set.of( OutputFormatCatalog.TILES_INDEXED_V1 ); @@ -65,8 +68,11 @@ public class PackerAssetWalker { true)); return new PackerWalkResult(List.of(), diagnostics); } + diagnostics.addAll(tileBankDeclarationDiagnostics(assetRoot, declaration, requirementBuildResult.requirements)); final var walkResult = tileBankWalker.walk(assetRoot, requirementBuildResult.requirements, priorAssetCache); diagnostics.addAll(walkResult.diagnostics()); + diagnostics.addAll(tileBankSelectedArtifactDiagnostics(assetRoot, declaration, walkResult)); + diagnostics.addAll(tileBankFragileIndexDiagnostics(declaration, walkResult)); return new PackerWalkResult(walkResult.probeResults(), diagnostics); } case SOUND_BANK -> { @@ -135,6 +141,157 @@ public class PackerAssetWalker { return RequirementBuildResult.success(new PackerTileBankRequirements(tileSize)); } + private List tileBankDeclarationDiagnostics( + final Path assetRoot, + final PackerAssetDeclaration declaration, + final PackerTileBankRequirements requirements) { + final List diagnostics = new ArrayList<>(); + final List artifacts = declaration.artifacts(); + final Set seenIndices = new java.util.HashSet<>(); + for (int expectedIndex = 0; expectedIndex < artifacts.size(); expectedIndex += 1) { + final PackerAssetArtifactSelection artifact = artifacts.get(expectedIndex); + if (!seenIndices.add(artifact.index())) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Tile bank artifacts contain duplicate index " + artifact.index() + ".", + assetRoot, + true)); + } + if (artifact.index() != expectedIndex) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Tile bank artifacts must form a contiguous index range starting at 0.", + assetRoot, + true)); + break; + } + } + + final int tileCapacity = (TILE_BANK_EMITTED_SHEET_SIZE / requirements.tileSize()) + * (TILE_BANK_EMITTED_SHEET_SIZE / requirements.tileSize()); + if (artifacts.size() > tileCapacity) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Tile bank exceeds emitted sheet capacity for tile_size " + + requirements.tileSize() + "x" + requirements.tileSize() + + ": capacity is " + tileCapacity + " tiles.", + assetRoot, + true)); + } + + final List declaredPalettes = declaredPalettes(declaration); + if (!artifacts.isEmpty() && declaredPalettes.isEmpty()) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Tile bank must declare at least one palette in output.pipeline.palettes.", + assetRoot, + true)); + } + if (declaredPalettes.size() > TILE_BANK_MAX_PALETTES) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Tile bank cannot declare more than " + TILE_BANK_MAX_PALETTES + " palettes.", + assetRoot, + true)); + } + return diagnostics; + } + + private List tileBankSelectedArtifactDiagnostics( + final Path assetRoot, + final PackerAssetDeclaration declaration, + final PackerWalkResult walkResult) { + final List diagnostics = new ArrayList<>(); + final Set discoveredPaths = walkResult.probeResults().stream() + .map(result -> assetRoot.toAbsolutePath().normalize() + .relativize(result.fileProbe().path().toAbsolutePath().normalize()) + .toString() + .replace('\\', '/')) + .collect(java.util.stream.Collectors.toSet()); + declaration.artifacts().stream() + .map(PackerAssetArtifactSelection::file) + .filter(path -> !discoveredPaths.contains(path)) + .forEach(path -> diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Selected tile artifact is missing or unsupported: " + path, + assetRoot.resolve(path), + true))); + return diagnostics; + } + + private List tileBankFragileIndexDiagnostics( + final PackerAssetDeclaration declaration, + final PackerWalkResult walkResult) { + final List declaredPalettes = declaredPalettes(declaration); + if (declaredPalettes.isEmpty()) { + return List.of(); + } + + final List diagnostics = new ArrayList<>(); + for (PackerProbeResult probeResult : walkResult.probeResults()) { + if (probeResult.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) { + continue; + } + final Object tileValue = probeResult.metadata().get("tile"); + if (!(tileValue instanceof PackerTileIndexedV1 tile)) { + continue; + } + int maxUsedIndex = 0; + for (byte value : tile.paletteIndices()) { + maxUsedIndex = Math.max(maxUsedIndex, Byte.toUnsignedInt(value)); + } + if (maxUsedIndex == 0) { + continue; + } + final int requiredColorCount = maxUsedIndex; + final Optional fragilePalette = declaredPalettes.stream() + .filter(palette -> palette.colorCount() < requiredColorCount) + .findFirst(); + if (fragilePalette.isEmpty()) { + continue; + } + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.WARNING, + PackerDiagnosticCategory.HYGIENE, + "Tile uses fragile palette indices up to " + requiredColorCount + + ", but declared palette " + fragilePalette.get().index() + + " only covers " + fragilePalette.get().colorCount() + " colors.", + probeResult.fileProbe().path(), + false)); + } + return diagnostics; + } + + private List declaredPalettes(final PackerAssetDeclaration declaration) { + final JsonNode palettesNode = declaration.outputPipelineMetadata().get("palettes"); + if (!(palettesNode instanceof com.fasterxml.jackson.databind.node.ArrayNode palettesArray)) { + return List.of(); + } + final List palettes = new ArrayList<>(); + for (JsonNode paletteDeclaration : palettesArray) { + final JsonNode indexNode = paletteDeclaration.path("index"); + final JsonNode paletteNode = paletteDeclaration.path("palette"); + if (!indexNode.isInt() || !paletteNode.isObject()) { + continue; + } + final int originalCount = paletteNode.path("originalArgb8888").isArray() + ? paletteNode.path("originalArgb8888").size() + : 0; + final int convertedCount = paletteNode.path("convertedRgb565").isArray() + ? paletteNode.path("convertedRgb565").size() + : 0; + palettes.add(new DeclaredPalette(indexNode.intValue(), Math.min(originalCount, convertedCount))); + } + palettes.sort(java.util.Comparator.comparingInt(DeclaredPalette::index)); + return List.copyOf(palettes); + } + private RequirementBuildResult buildSoundBankRequirements( final PackerAssetDeclaration declaration, final Map metadata) { @@ -169,4 +326,9 @@ public class PackerAssetWalker { return StringUtils.isNotBlank(errorMessage); } } + + private record DeclaredPalette( + int index, + int colorCount) { + } } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java index aec2f1f9..4e08d46d 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java @@ -703,6 +703,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe final List blockingDiagnostics = Stream.of( parsed.diagnostics(), runtimeAsset.walkDiagnostics(), + collectWalkFileDiagnostics(runtimeAsset), identityMismatchDiagnostics(registryEntry, parsed, runtimeAsset.manifestPath())) .flatMap(Collection::stream) .filter(PackerDiagnostic::blocking) @@ -715,6 +716,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe PackerReadMessageMapper.toDiagnosticDTOs(blockingDiagnostics)); } + private List collectWalkFileDiagnostics(PackerRuntimeAsset runtimeAsset) { + final List diagnostics = new ArrayList<>(); + for (PackerRuntimeWalkFile file : runtimeAsset.walkProjection().buildCandidateFiles()) { + diagnostics.addAll(file.diagnostics()); + } + return diagnostics; + } + private void saveRuntimeCache(PackerProjectContext project, PackerRuntimeSnapshot snapshot) { cacheRepository.save(project, snapshot.cacheState()); } diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java index 9afff42a..4f6d11f0 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java @@ -198,6 +198,91 @@ final class FileSystemPackerWorkspaceServiceTest { assertTrue(result.assets().isEmpty()); } + @Test + void packValidationBlocksTileBanksWithoutDeclaredPalettes() throws Exception { + final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-no-palettes")); + final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); + final Path manifestPath = assetRoot.resolve("asset.json"); + writeTilePng(assetRoot.resolve("confirm.png"), 16); + + final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile()); + manifest.putObject("output").put("format", "TILES/indexed_v1").put("codec", "NONE"); + final var artifacts = manifest.putArray("artifacts"); + artifacts.addObject().put("file", "confirm.png").put("index", 0); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest); + + final FileSystemPackerWorkspaceService service = service(); + final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.PARTIAL, result.status()); + assertFalse(result.canPack()); + assertTrue(result.assets().getFirst().diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette"))); + } + + @Test + void packValidationIncludesBlockingFileScopedTileDiagnostics() throws Exception { + final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-file-diagnostics")); + final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); + final Path manifestPath = assetRoot.resolve("asset.json"); + writeTilePng(assetRoot.resolve("oversized.png"), 32); + + final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile()); + final ObjectNode output = (ObjectNode) manifest.path("output"); + output.putObject("metadata").put("tile_size", "16x16"); + final ObjectNode pipeline = output.putObject("pipeline"); + final var palettes = pipeline.putArray("palettes"); + final ObjectNode palette = palettes.addObject(); + palette.put("index", 0); + palette.putObject("palette") + .putArray("originalArgb8888").add(0xFFFF0000); + ((ObjectNode) palette.path("palette")) + .putArray("convertedRgb565").add(0xF800); + final var artifacts = manifest.putArray("artifacts"); + artifacts.addObject().put("file", "oversized.png").put("index", 0); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest); + + final FileSystemPackerWorkspaceService service = service(); + final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.PARTIAL, result.status()); + assertFalse(result.canPack()); + assertTrue(result.assets().getFirst().diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.message().contains("Invalid tile dimensions for oversized.png"))); + } + + @Test + void fragileTileIndicesRemainWarningOnlyForValidation() throws Exception { + final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-fragile-indices")); + final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); + final Path manifestPath = assetRoot.resolve("asset.json"); + writeTwoColorTilePng(assetRoot.resolve("checker.png"), 16); + + final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile()); + final ObjectNode output = (ObjectNode) manifest.path("output"); + output.putObject("metadata").put("tile_size", "16x16"); + final ObjectNode pipeline = output.putObject("pipeline"); + final var palettes = pipeline.putArray("palettes"); + final ObjectNode palette = palettes.addObject(); + palette.put("index", 0); + palette.putObject("palette") + .putArray("originalArgb8888").add(0xFFFF0000); + ((ObjectNode) palette.path("palette")) + .putArray("convertedRgb565").add(0xF800); + final var artifacts = manifest.putArray("artifacts"); + artifacts.addObject().put("file", "checker.png").put("index", 0); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest); + + final FileSystemPackerWorkspaceService service = service(); + final var details = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forAssetId(1))); + final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.SUCCESS, validation.status()); + assertTrue(validation.canPack()); + assertTrue(details.diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.message().contains("fragile palette indices"))); + } + @Test void createsRegisteredAssetAndWritesManifest() throws Exception { final Path projectRoot = tempDir.resolve("created"); @@ -478,7 +563,9 @@ final class FileSystemPackerWorkspaceServiceTest { final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest( project(projectRoot), AssetReference.forAssetId(1))); - assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status()); + assertEquals(PackerOperationStatus.PARTIAL, detailsResult.status()); + assertTrue(detailsResult.diagnostics().stream() + .anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette"))); assertEquals( List.of("cancel.png", "confirm.png"), detailsResult.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList()); @@ -1074,4 +1161,15 @@ final class FileSystemPackerWorkspaceServiceTest { } ImageIO.write(image, "png", path.toFile()); } + + private void writeTwoColorTilePng(Path path, int tileSize) throws Exception { + Files.createDirectories(path.getParent()); + final BufferedImage image = new BufferedImage(tileSize, tileSize, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < tileSize; y += 1) { + for (int x = 0; x < tileSize; x += 1) { + image.setRGB(x, y, (x + y) % 2 == 0 ? 0xFFFF0000 : 0xFF00FF00); + } + } + ImageIO.write(image, "png", path.toFile()); + } }