diff --git a/.editorconfig b/.editorconfig index b044983..dd3ccd8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ insert_final_newline = true charset = utf-8 indent_size = 4 trim_trailing_whitespace = true -indent_style = tab +indent_style = space [*.{json,json5,mcmeta}] indent_size = 2 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4fef386..6e1a308 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -17,7 +17,7 @@ }, { "name": "Validate Gradle Wrapper", - "uses": "gradle/actions/wrapper-validation@v3" + "uses": "gradle/actions/wrapper-validation@v4" }, { "with": { @@ -34,7 +34,7 @@ "gradle-home-cache-cleanup": true }, "name": "Setup Gradle", - "uses": "gradle/actions/setup-gradle@v3" + "uses": "gradle/actions/setup-gradle@v4" }, { "name": "JMH", diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index 7d73a7f..26f789e 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -17,7 +17,7 @@ }, { "name": "Validate Gradle Wrapper", - "uses": "gradle/actions/wrapper-validation@v3" + "uses": "gradle/actions/wrapper-validation@v4" }, { "with": { @@ -34,7 +34,7 @@ "gradle-home-cache-cleanup": true }, "name": "Setup Gradle", - "uses": "gradle/actions/setup-gradle@v3" + "uses": "gradle/actions/setup-gradle@v4" }, { "name": "Build", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be1e132..24d8eca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ }, { "name": "Validate Gradle Wrapper", - "uses": "gradle/actions/wrapper-validation@v3" + "uses": "gradle/actions/wrapper-validation@v4" }, { "with": { @@ -38,7 +38,7 @@ "gradle-home-cache-cleanup": true }, "name": "Setup Gradle", - "uses": "gradle/actions/setup-gradle@v3" + "uses": "gradle/actions/setup-gradle@v4" }, { "uses": "fregante/setup-git-user@v2" @@ -80,6 +80,15 @@ "name": "Capture Recorded Version", "run": "echo version=$(cat build/recordVersion.txt) >> \"$GITHUB_OUTPUT\"", "id": "record_version_capture_version" + }, + { + "name": "Submit Dependencies", + "uses": "gradle/actions/dependency-submission@v4", + "env": { + "BUILD_CACHE_PASSWORD": "${{ secrets.BUILD_CACHE_PASSWORD }}", + "BUILD_CACHE_USER": "${{ secrets.BUILD_CACHE_USER }}", + "BUILD_CACHE_URL": "${{ secrets.BUILD_CACHE_URL }}" + } } ] }, @@ -104,7 +113,7 @@ }, { "name": "Validate Gradle Wrapper", - "uses": "gradle/actions/wrapper-validation@v3" + "uses": "gradle/actions/wrapper-validation@v4" }, { "with": { @@ -121,19 +130,23 @@ "gradle-home-cache-cleanup": true }, "name": "Setup Gradle", - "uses": "gradle/actions/setup-gradle@v3" + "uses": "gradle/actions/setup-gradle@v4" }, { "name": "Publish", - "run": "./gradlew publish", + "run": "./gradlew publish closeAndReleaseSonatypeStagingRepository", "id": "publish", "env": { "BUILD_CACHE_PASSWORD": "${{ secrets.BUILD_CACHE_PASSWORD }}", "BUILD_CACHE_USER": "${{ secrets.BUILD_CACHE_USER }}", "BUILD_CACHE_URL": "${{ secrets.BUILD_CACHE_URL }}", - "RELEASE_MAVEN_PASSWORD": "${{ secrets.RELEASE_MAVEN_PASSWORD }}", - "RELEASE_MAVEN_USER": "github", - "RELEASE_MAVEN_URL": "https://maven.lukebemish.dev/releases/" + "GPG_KEY": "${{ secrets.GPG_KEY }}", + "GPG_PASSWORD": "${{ secrets.GPG_PASSWORD }}", + "SONATYPE_PASSWORD": "${{ secrets.SONATYPE_PASSWORD }}", + "SONATYPE_USER": "${{ secrets.SONATYPE_USER }}", + "STAGING_MAVEN_PASSWORD": "${{ secrets.STAGING_MAVEN_PASSWORD }}", + "STAGING_MAVEN_USER": "github", + "STAGING_MAVEN_URL": "https://maven.lukebemish.dev/staging/" } } ] diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 863f3ca..5bd718a 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -17,7 +17,7 @@ }, { "name": "Validate Gradle Wrapper", - "uses": "gradle/actions/wrapper-validation@v3" + "uses": "gradle/actions/wrapper-validation@v4" }, { "with": { @@ -33,7 +33,7 @@ "gradle-home-cache-cleanup": true }, "name": "Setup Gradle", - "uses": "gradle/actions/setup-gradle@v3" + "uses": "gradle/actions/setup-gradle@v4" }, { "name": "Build", diff --git a/.gitignore b/.gitignore index 43654d1..e23bd4c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ build # other eclipse +run +runs diff --git a/build.gradle b/build.gradle index a1e5a72..848594f 100644 --- a/build.gradle +++ b/build.gradle @@ -8,87 +8,90 @@ plugins { group = 'dev.lukebemish' managedVersioning { - versionFile.set project.file('version.properties') - versionPRs() - versionSnapshots() - - gitHubActions { - snapshot { - prettyName.set 'Snapshot' - workflowDispatch.set(true) - onBranches.add 'main' - gradleJob { - buildCache() - cacheReadOnly.set false - javaVersion.set '21' - name.set 'build' - gradlew 'Build', 'build' - gradlew 'Publish', 'publish' - mavenSnapshot('github') - } - } - benchmark { - prettyName.set 'Benchmark' - workflowDispatch.set(true) - gradleJob { - buildCache() - javaVersion.set '21' - name.set 'build' - gradlew 'JMH', 'jmhResults' - step { - name.set 'Record JMH Output' - run.set 'cat build/reports/jmh/results.md >> $GITHUB_STEP_SUMMARY' - } - } - } - release { - prettyName.set 'Release' - workflowDispatch.set(true) - gradleJob { - buildCache() - javaVersion.set '21' - name.set 'build' - step { - setupGitUser() - } - readOnly.set false - gradlew 'Tag Release', 'tagRelease' - gradlew 'Build', 'build' - step { - run.set 'git push && git push --tags' - } - recordVersion 'Record Version', 'version' - } - gradleJob { - buildCache() - javaVersion.set '21' - name.set 'publish' - needs.add('build') - tag.set('${{needs.build.outputs.version}}') - gradlew 'Publish', 'publish' - mavenRelease('github') - } - } - build_pr { - prettyName.set 'Build PR' - pullRequest.set(true) - gradleJob { - javaVersion.set '21' - name.set 'build' - gradlew 'Build', 'build' - gradlew 'Publish', 'publish' - pullRequestArtifact() - } - } - publish_pr { - prettyName.set 'Publish PR' - publishPullRequestAction( - 'github', - "${project.group.replace('.', '/')}/${project.name}", - 'Build PR' - ) - } - } + versionFile.set project.file('version.properties') + versionPRs() + versionSnapshots() + + gitHubActions { + snapshot { + prettyName.set 'Snapshot' + workflowDispatch.set(true) + onBranches.add 'main' + gradleJob { + buildCache() + cacheReadOnly.set false + javaVersion.set '21' + name.set 'build' + gradlew 'Build', 'build' + gradlew 'Publish', 'publish' + mavenSnapshot('github') + } + } + benchmark { + prettyName.set 'Benchmark' + workflowDispatch.set(true) + gradleJob { + buildCache() + javaVersion.set '21' + name.set 'build' + gradlew 'JMH', 'jmhResults' + step { + name.set 'Record JMH Output' + run.set 'cat build/reports/jmh/results.md >> $GITHUB_STEP_SUMMARY' + } + } + } + release { + prettyName.set 'Release' + workflowDispatch.set(true) + gradleJob { + buildCache() + javaVersion.set '21' + name.set 'build' + step { + setupGitUser() + } + readOnly.set false + gradlew 'Tag Release', 'tagRelease' + gradlew 'Build', 'build' + step { + run.set 'git push && git push --tags' + } + recordVersion 'Record Version', 'version' + dependencySubmission() + } + gradleJob { + buildCache() + javaVersion.set '21' + name.set 'publish' + needs.add('build') + tag.set('${{needs.build.outputs.version}}') + gradlew 'Publish', 'publish', 'closeAndReleaseSonatypeStagingRepository' + sign() + mavenCentral() + mavenStaging('github') + } + } + build_pr { + prettyName.set 'Build PR' + pullRequest.set(true) + gradleJob { + javaVersion.set '21' + name.set 'build' + gradlew 'Build', 'build' + gradlew 'Publish', 'publish' + pullRequestArtifact() + } + } + publish_pr { + prettyName.set 'Publish PR' + publishPullRequestAction( + 'github', + "${project.group.replace('.', '/')}/${project.name}", + 'Build PR' + ) + } + } } managedVersioning.apply() @@ -96,165 +99,239 @@ managedVersioning.apply() println "Building: $version" sourceSets { - stream {} - streamIntermediary {} - jmh {} + minecraft {} + minecraftFabric {} + jmh {} } -java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - withSourcesJar() - withJavadocJar() - registerFeature("stream") { - usingSourceSet sourceSets.stream - withSourcesJar() - withJavadocJar() - } - registerFeature("streamIntermediary") { - usingSourceSet sourceSets.streamIntermediary - capability(project.group as String, "$project.name-stream", project.version as String) - capability(project.group as String, "$project.name-stream-intermediary", project.version as String) - } -} - -repositories { - mavenCentral() - maven { - name = 'Minecraft Libraries' - url = 'https://libraries.minecraft.net/' - } -} - -dependencies { - api 'com.mojang:datafixerupper:7.0.14' - api 'org.slf4j:slf4j-api:2.0.1' - - jmhCompileOnly cLibs.bundles.compileonly - jmhImplementation project(':') - jmhImplementation 'org.openjdk.jmh:jmh-core:1.37' - jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' - - jmhRuntimeOnly 'org.ow2.asm:asm:9.5' - - testCompileOnly cLibs.bundles.compileonly - - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-params:5.9.2' - - compileOnly 'com.electronwill.night-config:core:3.6.4' - compileOnly 'com.electronwill.night-config:toml:3.6.4' - compileOnly 'blue.endless:jankson:1.2.2' - compileOnly 'org.ow2.asm:asm:9.5' +configurations { + testNeoforgeRuntimeClasspath.extendsFrom minecraftRuntimeClasspath + testFabricRuntimeClasspath.extendsFrom minecraftFabricRuntimeClasspath + testFabricToRemapRuntimeClasspath.extendsFrom minecraftFabricToRemapRuntimeClasspath - testImplementation 'com.electronwill.night-config:core:3.6.4' - testImplementation 'com.electronwill.night-config:toml:3.6.4' - testImplementation 'blue.endless:jankson:1.2.2' - testImplementation 'org.ow2.asm:asm:9.5' + testNeoforgeCompileClasspath.extendsFrom minecraftCompileClasspath + testFabricCompileClasspath.extendsFrom minecraftFabricCompileClasspath + testFabricToRemapCompileClasspath.extendsFrom minecraftFabricToRemapCompileClasspath - annotationProcessor 'dev.lukebemish.autoextension:autoextension:0.1.1' - compileOnly 'dev.lukebemish.autoextension:autoextension:0.1.1' - - streamApi project(':') - streamCompileOnly cLibs.bundles.compileonly - streamAnnotationProcessor cLibs.bundles.annotationprocessor - streamIntermediaryApi project(':') - testImplementation sourceSets.stream.output + runtimeModClasses { + canBeConsumed = true + canBeResolved = false + } } -['streamJar', 'streamIntermediaryJar', 'jar'].each { - tasks.named(it, Jar) { - manifest { - attributes( - 'Specification-Version' : project.version, - 'Implementation-Version' : project.version, - 'Implementation-Commit-Time': managedVersioning.timestamp.get(), - 'Implementation-Commit' : managedVersioning.hash.get() - ) - } - } +artifacts { + sourceSets.main.output.classesDirs.each { file -> + add(configurations.runtimeModClasses.name, file) { + builtBy tasks.classes + } + } + add(configurations.runtimeModClasses.name, sourceSets.main.output.resourcesDir) { + builtBy tasks.processResources + } } -tasks.register('jmh', JavaExec) { - group = 'benchmark' - dependsOn sourceSets.jmh.output - mainClass = 'org.openjdk.jmh.Main' - systemProperty 'jmh.executor', 'VIRTUAL' - systemProperty 'jmh.blackhole.mode', 'COMPILER' - args '-rf', 'json', '-rff', 'build/reports/jmh/results.json' - classpath = sourceSets.jmh.runtimeClasspath - doFirst { - mkdir('build/reports/jmh') - } - outputs.file('build/reports/jmh/results.json') +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + withSourcesJar() + withJavadocJar() + registerFeature("minecraft") { + usingSourceSet sourceSets.minecraft + withSourcesJar() + withJavadocJar() + capability(project.group as String, "$project.name-minecraft", project.version as String) + capability(project.group as String, "$project.name-minecraft-common", project.version as String) + // Old name + capability(project.group as String, "$project.name-stream", project.version as String) + + withJavadocJar() + withSourcesJar() + } + registerFeature("minecraftFabric") { + usingSourceSet sourceSets.minecraftFabric + capability(project.group as String, "$project.name-minecraft", project.version as String) + capability(project.group as String, "$project.name-minecraft-fabric", project.version as String) + // Old name + capability(project.group as String, "$project.name-stream", project.version as String) + capability(project.group as String, "$project.name-stream-intermediary", project.version as String) + + withJavadocJar() + withSourcesJar() + } + registerFeature("minecraftNeoforge") { + usingSourceSet sourceSets.minecraftNeoforge + capability(project.group as String, "$project.name-minecraft", project.version as String) + capability(project.group as String, "$project.name-minecraft-neoforge", project.version as String) + // Old name + capability(project.group as String, "$project.name-stream", project.version as String) + + withJavadocJar() + withSourcesJar() + } } -tasks.register('jmhResults', FormatJmhOutput) { - group = 'benchmark' - dependsOn tasks.jmh - jmhResults.set project.file('build/reports/jmh/results.json') - formattedResults.set project.file('build/reports/jmh/results.md') +repositories { + mavenCentral() + maven { + name = 'Minecraft Libraries' + url = 'https://libraries.minecraft.net/' + } } -streamJar { - manifest.attributes('FMLModType': 'GAMELIBRARY') +dependencies { + api 'com.mojang:datafixerupper:8.0.16' + api 'org.slf4j:slf4j-api:2.0.1' + + jmhCompileOnly cLibs.bundles.compileonly + jmhImplementation project(':') + jmhImplementation 'org.openjdk.jmh:jmh-core:1.37' + jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' + + jmhRuntimeOnly 'org.ow2.asm:asm:9.5' + + testCompileOnly cLibs.bundles.compileonly + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-params:5.9.2' + + compileOnly 'com.electronwill.night-config:core:3.6.4' + compileOnly 'com.electronwill.night-config:toml:3.6.4' + compileOnly 'blue.endless:jankson:1.2.2' + compileOnly 'org.ow2.asm:asm:9.5' + + testImplementation 'com.electronwill.night-config:core:3.6.4' + testImplementation 'com.electronwill.night-config:toml:3.6.4' + testImplementation 'blue.endless:jankson:1.2.2' + testImplementation 'org.ow2.asm:asm:9.5' + + annotationProcessor 'dev.lukebemish.autoextension:autoextension:0.1.1' + compileOnly 'dev.lukebemish.autoextension:autoextension:0.1.1' + + minecraftApi project(':') + minecraftCompileOnly cLibs.bundles.compileonly + minecraftAnnotationProcessor cLibs.bundles.annotationprocessor + minecraftFabricCompileOnly cLibs.bundles.compileonly + minecraftFabricAnnotationProcessor cLibs.bundles.annotationprocessor + minecraftNeoforgeCompileOnly cLibs.bundles.compileonly + minecraftNeoforgeAnnotationProcessor cLibs.bundles.annotationprocessor + minecraftFabricApi project(':') + minecraftNeoforgeApi project(':') + + testNeoforgeCompileOnly sourceSets.minecraftNeoforge.output + testNeoforgeCompileOnly sourceSets.minecraft.output + testFabricCompileOnly sourceSets.minecraftFabric.output + testFabricCompileOnly sourceSets.minecraft.output + testCommonCompileOnly(project(':')) { + capabilities { + requireCapability 'dev.lukebemish:codecextras-minecraft-common' + } + } + + modTestFabricImplementation libs.fabric.loader + modTestFabricLocalImplementation libs.fabric.api + modTestFabricLocalImplementation libs.modmenu + + modMinecraftFabricImplementation libs.fabric.loader + modMinecraftFabricImplementation libs.fabric.api + modMinecraftFabricLocalImplementation libs.modmenu } -streamIntermediaryJar { - manifest.attributes('FMLModType': 'GAMELIBRARY') +['minecraftJar', 'minecraftFabricJar', 'minecraftNeoforgeJar', 'jar'].each { + tasks.named(it, Jar) { + manifest { + attributes( + 'Specification-Version' : project.version, + 'Implementation-Version' : project.version, + 'Implementation-Commit-Time': managedVersioning.timestamp.get(), + 'Implementation-Commit' : managedVersioning.hash.get() + ) + } + } } -tasks.named('remapStreamIntermediaryJar', dev.lukebemish.multisource.CopyArchiveFileTask) { task -> - task.archiveFile.set project.layout.buildDirectory.file("libs/${project.name}-${project.version}-stream-intermediary.jar") +tasks.named('jar', Jar) { + manifest { + attributes( + 'Automatic-Module-Name': project.group + '.' + project.name, + 'FMLModType' : 'LIBRARY' + ) + } } -jar { - manifest.attributes('FMLModType': 'LIBRARY') +['minecraftJar', 'minecraftFabricJar', 'minecraftNeoforgeJar'].each { + tasks.named(it, Jar) { + manifest { + attributes( + 'Automatic-Module-Name' : project.group + '.' + project.name + '.minecraft', + 'Implementation-Minecraft-Version' : libs.versions.minecraft.get() + ) + } + } } -tasks.compileJava { - options.compilerArgs += [ - '-Aautoextension.name=CodecExtras', - "-Aautoextension.version=${version}".toString() - ] - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(17) - } +tasks.register('jmh', JavaExec) { + group = 'benchmark' + dependsOn sourceSets.jmh.output + mainClass = 'org.openjdk.jmh.Main' + systemProperty 'jmh.executor', 'VIRTUAL' + systemProperty 'jmh.blackhole.mode', 'COMPILER' + args '-rf', 'json', '-rff', 'build/reports/jmh/results.json' + classpath = sourceSets.jmh.runtimeClasspath + doFirst { + mkdir('build/reports/jmh') + } + outputs.file('build/reports/jmh/results.json') } -processStreamIntermediaryResources { - inputs.property "version", project.version.toString() - - filesMatching("fabric.mod.json") { - expand "version": project.version.toString() - } +tasks.register('jmhResults', FormatJmhOutput) { + group = 'benchmark' + dependsOn tasks.jmh + jmhResults.set project.file('build/reports/jmh/results.json') + formattedResults.set project.file('build/reports/jmh/results.md') } -processResources { - inputs.property "version", project.version.toString() +tasks.compileJava { + options.compilerArgs += [ + '-Aautoextension.name=CodecExtras', + "-Aautoextension.version=${version}".toString() + ] +} - filesMatching("fabric.mod.json") { - expand "version": project.version.toString() - } +['processResources', 'processMinecraftResources', 'processMinecraftFabricResources', 'processMinecraftNeoforgeResources'].each { + tasks.named(it, ProcessResources) { + var version = project.version.toString() + var minecraftVersion = libs.versions.minecraft.get() + + inputs.property "version", version + inputs.property "minecraft_version", minecraftVersion + + filesMatching(["fabric.mod.json", "META-INF/neoforge.mods.toml"]) { + expand([ + "version": version, + "minecraft_version": minecraftVersion + ]) + } + } } test { - useJUnitPlatform() - testLogging { - showStandardStreams = true - exceptionFormat = 'full' - events = ['passed', 'failed', 'skipped'] - } + useJUnitPlatform() + testLogging { + showStandardStreams = true + exceptionFormat = 'full' + events = ['passed', 'failed', 'skipped'] + } } publishing { - publications { - mavenJava(MavenPublication) { - from components.java - } - } + publications { + mavenJava(MavenPublication) { + from components.java + } + } } -managedVersioning.publishing.mavenRelease(publishing) +managedVersioning.publishing.mavenStaging(publishing) +managedVersioning.publishing.mavenCentral() managedVersioning.publishing.mavenPullRequest(publishing) managedVersioning.publishing.mavenSnapshot(publishing) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 3ac72e1..a43f332 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,3 +1,3 @@ plugins { - id 'groovy' + id 'groovy' } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e2b5641 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,15 @@ +[versions] + +minecraft = "1.21.3" +neoforge = "21.3.31-beta" +fabric_loader = "0.16.9" +fabric_api = "0.108.0+1.21.3" +modmenu = "12.0.0-beta.1" + +[libraries] + +minecraft = { group = "com.mojang", name = "minecraft", version.ref = "minecraft" } +neoforge = { group = "net.neoforged", name = "neoforge", version.ref = "neoforge" } +fabric_loader = { group = "net.fabricmc", name = "fabric-loader", version.ref = "fabric_loader" } +fabric_api = { group = "net.fabricmc.fabric-api", name = "fabric-api", version.ref = "fabric_api" } +modmenu = { group = "com.terraformersmc", name = "modmenu", version.ref = "modmenu" } diff --git a/minecraftFabric/build.gradle b/minecraftFabric/build.gradle new file mode 100644 index 0000000..92bc0a5 --- /dev/null +++ b/minecraftFabric/build.gradle @@ -0,0 +1,5 @@ +loom { + mixin { + useLegacyMixinAp = false + } +} diff --git a/settings.gradle b/settings.gradle index 9e05e5b..daafd2f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,42 +1,71 @@ pluginManagement { - repositories { - maven { - name = "Luke's Maven" - url = 'https://maven.lukebemish.dev/releases' - } - maven { - name = 'Fabric' - url = 'https://maven.fabricmc.net/' - } - maven { - name = 'NeoForged' - url = 'https://maven.neoforged.net/' - } - maven { - name = 'Architectury' - url "https://maven.architectury.dev/" - } - mavenCentral() - gradlePluginPortal() - } + repositories { + maven { + name = "Luke's Maven" + url = 'https://maven.lukebemish.dev/releases' + } + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + maven { + name = 'NeoForged' + url = 'https://maven.neoforged.net/' + } + maven { + name = 'Architectury' + url "https://maven.architectury.dev/" + } + mavenCentral() + gradlePluginPortal() + } } plugins { - id 'dev.lukebemish.managedversioning' version '1.2.25' apply false - id 'dev.lukebemish.conventions' version '0.1.11' - id 'dev.lukebemish.multisource' version '0.1.8' + id 'org.gradlex.extra-java-module-info' version '1.8' apply false + id 'dev.lukebemish.managedversioning' version '1.2.26' apply false + // TODO: switch this over to crochet as soon as possible + id 'dev.architectury.loom' version '1.7.414' apply false + id 'dev.lukebemish.conventions' version '0.1.11' + id 'dev.lukebemish.multisource' version '0.2.2' +} + +gradle.beforeProject { + it.plugins.apply('org.gradlex.extra-java-module-info') + it.extraJavaModuleInfo { + failOnMissingModuleInfo.set(false) + automaticModule('dev.lukebemish.autoextension:autoextension', 'autoextension') + automaticModule('com.mojang:datafixerupper', 'datafixerupper') + } } multisource.of(':') { - configureEach { - minecraft.add 'com.mojang:minecraft:1.20.6' - mappings.add loom.officialMojangMappings() - } - common('stream', []) {} - fabric('streamIntermediary', ['stream']) {} - repositories { - it.removeIf { it.name == 'Forge' } - } + repositories { + maven { + name = "Terraformers" + url = "https://maven.terraformersmc.com/" + content { + includeModule 'com.terraformersmc', 'modmenu' + } + } + } + configureEach { + minecraft.add project.libs.minecraft + mappings.add loom.officialMojangMappings() + } + common('minecraft', []) {} + fabric('minecraftFabric', ['minecraft']) {} + neoforge('minecraftNeoforge', ['minecraft']) { + neoForge.add project.libs.neoforge + } + common('testCommon', []) {} + neoforge('testNeoforge', ['testCommon']) { + neoForge.add project.libs.neoforge + } + fabric('testFabric', ['testCommon']) {} + repositories { + it.removeIf { it.name == 'Forge' } + } } rootProject.name = 'codecextras' diff --git a/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsDecode.java b/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsDecode.java index f045a99..56cedc6 100644 --- a/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsDecode.java +++ b/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsDecode.java @@ -39,9 +39,9 @@ public void keyedRecordCodecBuilder(Blackhole blackhole) { } @Benchmark - public void extendedRecordCodecBuilder(Blackhole blackhole) { + public void curriedRecordCodecBuilder(Blackhole blackhole) { JsonElement json = TestRecord.makeData(counter++); - var result = TestRecord.ERCB.decode(JsonOps.INSTANCE, json); + var result = TestRecord.CRCB.decode(JsonOps.INSTANCE, json); blackhole.consume(result.result().orElseThrow()); } @@ -65,7 +65,7 @@ public void setup() { json = TestRecord.makeData(0); TestRecord.RCB.decode(JsonOps.INSTANCE, json); TestRecord.KRCB.decode(JsonOps.INSTANCE, json); - TestRecord.ERCB.decode(JsonOps.INSTANCE, json); + TestRecord.CRCB.decode(JsonOps.INSTANCE, json); } @Benchmark @@ -81,8 +81,8 @@ public void keyedRecordCodecBuilder(Blackhole blackhole) { } @Benchmark - public void extendedRecordCodecBuilder(Blackhole blackhole) { - var result = TestRecord.ERCB.decode(JsonOps.INSTANCE, json); + public void curriedRecordCodecBuilder(Blackhole blackhole) { + var result = TestRecord.CRCB.decode(JsonOps.INSTANCE, json); blackhole.consume(result.result().orElseThrow()); } diff --git a/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsEncode.java b/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsEncode.java index 618292c..0113c05 100644 --- a/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsEncode.java +++ b/src/jmh/java/dev/lukebemish/codecextras/jmh/LargeRecordsEncode.java @@ -38,9 +38,9 @@ public void keyedRecordCodecBuilder(Blackhole blackhole) { } @Benchmark - public void extendedRecordCodecBuilder(Blackhole blackhole) { + public void curriedRecordCodecBuilder(Blackhole blackhole) { TestRecord record = TestRecord.makeRecord(counter++); - var result = TestRecord.ERCB.encodeStart(JsonOps.INSTANCE, record); + var result = TestRecord.CRCB.encodeStart(JsonOps.INSTANCE, record); blackhole.consume(result.result().orElseThrow()); } @@ -64,7 +64,7 @@ public void setup() { record = TestRecord.makeRecord(0); TestRecord.RCB.encodeStart(JsonOps.INSTANCE, record); TestRecord.KRCB.encodeStart(JsonOps.INSTANCE, record); - TestRecord.ERCB.encodeStart(JsonOps.INSTANCE, record); + TestRecord.CRCB.encodeStart(JsonOps.INSTANCE, record); } @Benchmark @@ -80,8 +80,8 @@ public void keyedRecordCodecBuilder(Blackhole blackhole) { } @Benchmark - public void extendedRecordCodecBuilder(Blackhole blackhole) { - var result = TestRecord.ERCB.encodeStart(JsonOps.INSTANCE, record); + public void curriedRecordCodecBuilder(Blackhole blackhole) { + var result = TestRecord.CRCB.encodeStart(JsonOps.INSTANCE, record); blackhole.consume(result.result().orElseThrow()); } diff --git a/src/jmh/java/dev/lukebemish/codecextras/jmh/TestRecord.java b/src/jmh/java/dev/lukebemish/codecextras/jmh/TestRecord.java index ebf1ac8..b932aba 100644 --- a/src/jmh/java/dev/lukebemish/codecextras/jmh/TestRecord.java +++ b/src/jmh/java/dev/lukebemish/codecextras/jmh/TestRecord.java @@ -3,7 +3,7 @@ import com.google.gson.JsonObject; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.lukebemish.codecextras.ExtendedRecordCodecBuilder; +import dev.lukebemish.codecextras.record.CurriedRecordCodecBuilder; import dev.lukebemish.codecextras.record.KeyedRecordCodecBuilder; import dev.lukebemish.codecextras.record.MethodHandleRecordCodecBuilder; import java.lang.invoke.MethodHandles; @@ -33,7 +33,7 @@ record TestRecord( Codec.INT.fieldOf("p").forGetter(TestRecord::p) ).apply(i, TestRecord::new)); - public static final Codec ERCB = ExtendedRecordCodecBuilder + public static final Codec CRCB = CurriedRecordCodecBuilder .start(Codec.INT.fieldOf("a"), TestRecord::a) .field(Codec.INT.fieldOf("b"), TestRecord::b) .field(Codec.INT.fieldOf("c"), TestRecord::c) diff --git a/src/main/java/dev/lukebemish/codecextras/PartialDispatchedMapCodec.java b/src/main/java/dev/lukebemish/codecextras/PartialDispatchedMapCodec.java new file mode 100644 index 0000000..5d8b6b0 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/PartialDispatchedMapCodec.java @@ -0,0 +1,87 @@ +package dev.lukebemish.codecextras; + +import com.google.common.collect.ImmutableMap; +import com.mojang.datafixers.util.Pair; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.Lifecycle; +import com.mojang.serialization.RecordBuilder; +import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +/* +This class adapted from DispatchedMapCodec.java from DFU (https://github.com/Mojang/DataFixerUpper), under the MIT license: + +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the Software), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +public record PartialDispatchedMapCodec( + Codec keyCodec, + Function>> valueCodecFunction +) implements Codec> { + @Override + public DataResult encode(final Map input, final DynamicOps ops, final T prefix) { + final RecordBuilder mapBuilder = ops.mapBuilder(); + for (final Map.Entry entry : input.entrySet()) { + mapBuilder.add(keyCodec.encodeStart(ops, entry.getKey()), valueCodecFunction.apply(entry.getKey()).flatMap(codec -> encodeValue(codec, entry.getValue(), ops))); + } + return mapBuilder.build(prefix); + } + + @SuppressWarnings("unchecked") + private DataResult encodeValue(final Codec codec, final V input, final DynamicOps ops) { + return codec.encodeStart(ops, (V2) input); + } + + @Override + public DataResult, T>> decode(final DynamicOps ops, final T input) { + return ops.getMap(input).flatMap(map -> { + final Map entries = new Object2ObjectArrayMap<>(); + final Stream.Builder> failed = Stream.builder(); + + final DataResult finalResult = map.entries().reduce( + DataResult.success(Unit.INSTANCE, Lifecycle.stable()), + (result, entry) -> parseEntry(result, ops, entry, entries, failed), + (r1, r2) -> r1.apply2stable((u1, u2) -> u1, r2) + ); + + final Pair, T> pair = Pair.of(ImmutableMap.copyOf(entries), input); + final T errors = ops.createMap(failed.build()); + + return finalResult.map(ignored -> pair).setPartial(pair).mapError(error -> error + " missed input: " + errors); + }); + } + + private DataResult parseEntry(final DataResult result, final DynamicOps ops, final Pair input, final Map entries, final Stream.Builder> failed) { + final DataResult keyResult = keyCodec.parse(ops, input.getFirst()); + final DataResult valueResult = keyResult.flatMap(valueCodecFunction).flatMap(valueCodec -> valueCodec.parse(ops, input.getSecond()).map(Function.identity())); + final DataResult> entryResult = keyResult.apply2stable(Pair::of, valueResult); + + final Optional> entry = entryResult.resultOrPartial(); + if (entry.isPresent()) { + final K key = entry.get().getFirst(); + final V value = entry.get().getSecond(); + if (entries.putIfAbsent(key, value) != null) { + failed.add(input); + return result.apply2stable((u, p) -> u, DataResult.error(() -> "Duplicate entry for key: '" + key + "'")); + } + } + if (entryResult.isError()) { + failed.add(input); + } + + return result.apply2stable((u, p) -> u, entryResult); + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/StringRepresentation.java b/src/main/java/dev/lukebemish/codecextras/StringRepresentation.java new file mode 100644 index 0000000..ccf42eb --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/StringRepresentation.java @@ -0,0 +1,74 @@ +package dev.lukebemish.codecextras; + +import com.google.common.base.Suppliers; +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; + +/** + * A representation of a type with finite possible values as strings. Values of the type should be comparable by identity + * @param values provides the possible (ordered) values of the type + * @param representation converts a value to a string + * @param the type of the values + */ +public record StringRepresentation(Supplier> values, Function representation, Function inverse, boolean identity) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public StringRepresentation(Supplier> values, Function representation) { + this(values, representation, memoizeInverse(representation, values), false); + } + + private static Function memoizeInverse(Function representation, Supplier> values) { + Supplier> mapSupplier = Suppliers.memoize(() -> { + var map = new HashMap(); + for (var value : values.get()) { + map.put(representation.apply(value), value); + } + return map; + }); + return t -> mapSupplier.get().get(t); + } + + public static StringRepresentation ofArray(Supplier values, Function representation) { + Supplier> listSupplier = () -> List.of(values.get()); + return new StringRepresentation<>(listSupplier, representation); + } + + public static StringRepresentation unbox(App box) { + return (StringRepresentation) box; + } + + public Codec codec() { + return Codec.lazyInitialized(() -> { + var values = this.values().get(); + Function toString; + if (values.size() > 16) { + toString = this.representation(); + } else { + Map representationMap = identity() ? new IdentityHashMap<>() : new HashMap<>(); + for (var value : values) { + representationMap.put(value, this.representation().apply(value)); + } + toString = representationMap::get; + } + return Codec.STRING.comapFlatMap(string -> { + T value = inverse.apply(string); + if (value == null) { + return DataResult.error(() -> "Unknown string representation value: " + string); + } + return DataResult.success(value); + }, toString); + }); + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/XorMapCodec.java b/src/main/java/dev/lukebemish/codecextras/XorMapCodec.java new file mode 100644 index 0000000..debfdbd --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/XorMapCodec.java @@ -0,0 +1,67 @@ +package dev.lukebemish.codecextras; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.MapLike; +import com.mojang.serialization.RecordBuilder; +import java.util.Objects; +import java.util.stream.Stream; + +public final class XorMapCodec extends MapCodec> { + private final MapCodec first; + private final MapCodec second; + + private XorMapCodec(MapCodec first, MapCodec second) { + this.first = first; + this.second = second; + } + + public static XorMapCodec of(MapCodec first, MapCodec second) { + return new XorMapCodec<>(first, second); + } + + @Override + public Stream keys(DynamicOps dynamicOps) { + return Stream.concat(this.first.keys(dynamicOps), this.second.keys(dynamicOps)); + } + + @Override + public DataResult> decode(DynamicOps dynamicOps, MapLike mapLike) { + var firstResult = this.first.decode(dynamicOps, mapLike); + if (firstResult.isError()) { + return this.second.decode(dynamicOps, mapLike).map(Either::right); + } + var secondResult = this.second.decode(dynamicOps, mapLike); + if (secondResult.isError()) { + return firstResult.map(Either::left); + } + return DataResult.error(() -> "Both alternatives read successfully, can not pick the correct one; first: " + firstResult.getOrThrow() + ", second: " + secondResult.getOrThrow()); + } + + @Override + public RecordBuilder encode(Either fsEither, DynamicOps dynamicOps, RecordBuilder recordBuilder) { + return fsEither.map( + f -> this.first.encode(f, dynamicOps, recordBuilder), + s -> this.second.encode(s, dynamicOps, recordBuilder) + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof XorMapCodec that)) return false; + return Objects.equals(first, that.first) && Objects.equals(second, that.second); + } + + @Override + public int hashCode() { + return Objects.hash(first, second); + } + + @Override + public String toString() { + return "XorMapCodec[" + this.first + ", " + this.second + "]"; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/comments/CommentFirstListCodec.java b/src/main/java/dev/lukebemish/codecextras/comments/CommentFirstListCodec.java index 7633d3d..ed76d9e 100644 --- a/src/main/java/dev/lukebemish/codecextras/comments/CommentFirstListCodec.java +++ b/src/main/java/dev/lukebemish/codecextras/comments/CommentFirstListCodec.java @@ -35,12 +35,7 @@ public DataResult, T>> decode(DynamicOps ops, T input) { @Override public DataResult encode(List input, DynamicOps ops, T prefix) { final ListBuilder builder = ops.listBuilder(); - DynamicOps rest; - if (ops instanceof AccompaniedOps) { - rest = DelegatingOps.without(CommentOps.TOKEN, ops); - } else { - rest = ops; - } + DynamicOps rest = AccompaniedOps.find(ops).map(o -> DelegatingOps.without(CommentOps.TOKEN, ops)).orElse(ops); boolean isFirst = true; for (A a : input) { if (isFirst) { diff --git a/src/main/java/dev/lukebemish/codecextras/comments/CommentMapCodec.java b/src/main/java/dev/lukebemish/codecextras/comments/CommentMapCodec.java index 77171fd..cc24d03 100644 --- a/src/main/java/dev/lukebemish/codecextras/comments/CommentMapCodec.java +++ b/src/main/java/dev/lukebemish/codecextras/comments/CommentMapCodec.java @@ -19,6 +19,11 @@ private CommentMapCodec(MapCodec delegate, Map comments) { } public static MapCodec of(MapCodec codec, Map comments) { + if (codec instanceof CommentMapCodec commentMapCodec) { + Map allComments = commentMapCodec.comments; + allComments.putAll(comments); + return new CommentMapCodec<>(commentMapCodec.delegate, allComments); + } return new CommentMapCodec<>(codec, comments); } @@ -26,7 +31,7 @@ public static MapCodec of(MapCodec codec, String comment) { Map map = codec.keys(JsonOps.INSTANCE) .filter(json -> json.isJsonPrimitive() && json.getAsJsonPrimitive().isString()) .map(json -> json.getAsJsonPrimitive().getAsString()) - .collect(Collectors.toMap(Function.identity(), s -> comment)); + .collect(Collectors.toMap(Function.identity(), s -> comment, (a, b) -> a)); return of(codec, map); } @@ -91,7 +96,7 @@ public RecordBuilder mapError(UnaryOperator onError) { @Override public DataResult build(T prefix) { DataResult built = builder.build(prefix); - if (this.ops() instanceof AccompaniedOps accompaniedOps) { + return AccompaniedOps.find(this.ops()).map(accompaniedOps -> { Optional> commentOps = accompaniedOps.getCompanion(CommentOps.TOKEN); if (commentOps.isPresent()) { return built.flatMap(t -> @@ -99,8 +104,8 @@ public DataResult build(T prefix) { ops.createString(e.getKey()), e -> ops.createString(e.getValue())))) ); } - } - return built; + return built; + }).orElse(built); } }; } diff --git a/src/main/java/dev/lukebemish/codecextras/companion/AccompaniedOps.java b/src/main/java/dev/lukebemish/codecextras/companion/AccompaniedOps.java index ba28aab..fccffed 100644 --- a/src/main/java/dev/lukebemish/codecextras/companion/AccompaniedOps.java +++ b/src/main/java/dev/lukebemish/codecextras/companion/AccompaniedOps.java @@ -7,4 +7,14 @@ public interface AccompaniedOps extends DynamicOps { default > Optional getCompanion(O token) { return Optional.empty(); } + + static Optional> find(DynamicOps ops) { + for (var retriever : DelegatingOps.forOps(ops)) { + var companion = retriever.locateCompanionDelegate(ops); + if (companion.isPresent()) { + return companion; + } + } + return Optional.empty(); + } } diff --git a/src/main/java/dev/lukebemish/codecextras/companion/AlternateCompanionRetriever.java b/src/main/java/dev/lukebemish/codecextras/companion/AlternateCompanionRetriever.java new file mode 100644 index 0000000..8db9223 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/companion/AlternateCompanionRetriever.java @@ -0,0 +1,10 @@ +package dev.lukebemish.codecextras.companion; + +import com.mojang.serialization.DynamicOps; +import java.util.Optional; + +public interface AlternateCompanionRetriever { + Optional> locateCompanionDelegate(DynamicOps ops); + + DynamicOps delegate(DynamicOps ops, AccompaniedOps delegate); +} diff --git a/src/main/java/dev/lukebemish/codecextras/companion/DelegatingOps.java b/src/main/java/dev/lukebemish/codecextras/companion/DelegatingOps.java index e34c952..c02594d 100644 --- a/src/main/java/dev/lukebemish/codecextras/companion/DelegatingOps.java +++ b/src/main/java/dev/lukebemish/codecextras/companion/DelegatingOps.java @@ -1,5 +1,6 @@ package dev.lukebemish.codecextras.companion; +import com.google.common.collect.MapMaker; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.DataResult; import com.mojang.serialization.Decoder; @@ -9,10 +10,12 @@ import com.mojang.serialization.MapLike; import com.mojang.serialization.RecordBuilder; import java.nio.ByteBuffer; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.ServiceLoader; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -26,31 +29,98 @@ public abstract class DelegatingOps implements AccompaniedOps { @Nullable protected final AccompaniedOps accompanied; public DelegatingOps(DynamicOps delegate) { this.delegate = delegate; - if (delegate instanceof AccompaniedOps accompaniedOps) { - this.accompanied = accompaniedOps; + this.accompanied = AccompaniedOps.find(delegate).orElse(null); + } + + public static DynamicOps of(Q token, Companion companion, DynamicOps delegate) { + var possibleParent = retrieveMapOps(delegate); + if (possibleParent != null) { + if (possibleParent.getSecond() instanceof MapDelegatingOps mapOps) { + Map>> map = new HashMap<>(mapOps.companions); + map.put(token, Optional.of(companion)); + return possibleParent.getFirst().delegate(delegate, new MapDelegatingOps<>(delegate, map)); + } + return possibleParent.getFirst().delegate(delegate, new MapDelegatingOps<>(delegate, Map.of(token, Optional.of(companion)))); } else { - this.accompanied = null; + return new MapDelegatingOps<>(delegate, Map.of(token, Optional.of(companion))); } } - public static AccompaniedOps of(Q token, Companion companion, DynamicOps delegate) { - if (delegate instanceof MapDelegatingOps mapOps) { - Map>> map = new HashMap<>(mapOps.companions); - map.put(token, Optional.of(companion)); - return new MapDelegatingOps<>(delegate, map); + public static DynamicOps without(Q token, DynamicOps delegate) { + var possibleParent = retrieveMapOps(delegate); + if (possibleParent != null) { + if (possibleParent.getSecond() instanceof MapDelegatingOps mapOps) { + Map>> map = new HashMap<>(mapOps.companions); + map.put(token, Optional.empty()); + return possibleParent.getFirst().delegate(delegate, new MapDelegatingOps<>(delegate, map)); + } + return possibleParent.getFirst().delegate(delegate, new MapDelegatingOps<>(delegate, Map.of(token, Optional.empty()))); } else { - return new MapDelegatingOps<>(delegate, Map.of(token, Optional.of(companion))); + return new MapDelegatingOps<>(delegate, Map.of(token, Optional.empty())); } } - public static AccompaniedOps without(Q token, DynamicOps delegate) { - if (delegate instanceof MapDelegatingOps mapOps) { - Map>> map = new HashMap<>(mapOps.companions); - map.put(token, Optional.empty()); - return new MapDelegatingOps<>(delegate, map); - } else { - return new MapDelegatingOps<>(delegate, Map.of(token, Optional.empty())); + private static final List ALTERNATE_COMPANION_RETRIEVERS; + private static final Map> RETRIEVERS = new MapMaker().weakKeys().weakValues().makeMap(); + + static { + List retrievers = new ArrayList<>(); + retrievers.add(new AlternateCompanionRetriever() { + @Override + public Optional> locateCompanionDelegate(DynamicOps ops) { + if (ops instanceof MapDelegatingOps mapOps) { + return Optional.of(mapOps); + } + return Optional.empty(); + } + + @Override + public AccompaniedOps delegate(DynamicOps ops, AccompaniedOps delegate) { + return delegate; + } + }); + retrievers.addAll(ServiceLoader.load(AlternateCompanionRetriever.class).stream().map(ServiceLoader.Provider::get).toList()); + ALTERNATE_COMPANION_RETRIEVERS = List.copyOf(retrievers); + } + + static List forOps(DynamicOps ops) { + var clazz = ops.getClass(); + var layer = clazz.getModule().getLayer(); + if (layer == null) { + return ALTERNATE_COMPANION_RETRIEVERS; + } + return RETRIEVERS.computeIfAbsent(layer, k -> { + List retrievers = new ArrayList<>(); + retrievers.add(new AlternateCompanionRetriever() { + @Override + public Optional> locateCompanionDelegate(DynamicOps ops) { + if (ops instanceof MapDelegatingOps mapOps) { + return Optional.of(mapOps); + } + return Optional.empty(); + } + + @Override + public AccompaniedOps delegate(DynamicOps ops, AccompaniedOps delegate) { + return delegate; + } + }); + retrievers.addAll(ServiceLoader.load(layer, AlternateCompanionRetriever.class).stream().map(ServiceLoader.Provider::get).toList()); + if (layer != DelegatingOps.class.getModule().getLayer()) { + retrievers.addAll(ServiceLoader.load(AlternateCompanionRetriever.class).stream().map(ServiceLoader.Provider::get).toList()); + } + return List.copyOf(retrievers); + }); + } + + private static @Nullable Pair> retrieveMapOps(DynamicOps ops) { + for (AlternateCompanionRetriever retriever : forOps(ops)) { + Optional> companionDelegate = retriever.locateCompanionDelegate(ops); + if (companionDelegate.isPresent()) { + return Pair.of(retriever, companionDelegate.get()); + } } + return null; } @Override diff --git a/src/main/java/dev/lukebemish/codecextras/config/ConfigType.java b/src/main/java/dev/lukebemish/codecextras/config/ConfigType.java index 260ea6c..3eee0e4 100644 --- a/src/main/java/dev/lukebemish/codecextras/config/ConfigType.java +++ b/src/main/java/dev/lukebemish/codecextras/config/ConfigType.java @@ -14,6 +14,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -44,7 +45,7 @@ public ConfigType() { if (!touched[0]) { return null; } - return dataFixerBuilder.buildUnoptimized(); + return dataFixerBuilder.build().fixer(); }); } @@ -72,13 +73,24 @@ public ConfigHandle handle(Path location, OpsIo opsIo, Logger logger) } } return new ConfigHandle<>() { + private volatile @Nullable O loaded; + + @Override + public synchronized O load() { + var value = ConfigType.this.load(location, withLogging, logger); + this.loaded = value; + return value; + } + @Override - public O load() { - return ConfigType.this.load(location, withLogging, logger); + public O get() { + var value = this.loaded; + return Objects.requireNonNullElseGet(value, this::load); } @Override - public void save(O config) { + public synchronized void save(O config) { + this.loaded = config; ConfigType.this.save(location, withLogging, logger, config); } }; @@ -86,6 +98,7 @@ public void save(O config) { public interface ConfigHandle { O load(); + O get(); void save(O config); } @@ -141,6 +154,15 @@ public O load(Path location, OpsIo opsIo, Logger logger) { try (var is = Files.newInputStream(location)) { var out = decode(location.toString(), opsIo.ops(), opsIo.read(is), logger); if (out.error().isPresent()) { + if (out.hasResultOrPartial()) { + var orPartial = out.resultOrPartial().orElseThrow(); + var reEncoded = codec().encodeStart(opsIo.ops(), orPartial).flatMap(t -> codec().decode(opsIo.ops(), t)); + if (reEncoded.isSuccess()) { + logger.warn("Could not load config {}; attempting to fix by writing partial config. Error was {}", location, out.error().get().message()); + save(location, opsIo, logger, out.resultOrPartial().orElseThrow()); + return orPartial; + } + } logger.error("Could not load config {}; attempting to fix by writing default config. Error was {}", location, out.error().get().message()); save(location, opsIo, logger, defaultConfig()); return defaultConfig(); diff --git a/src/main/java/dev/lukebemish/codecextras/config/OpsIo.java b/src/main/java/dev/lukebemish/codecextras/config/OpsIo.java index 7fd919c..8aac5c2 100644 --- a/src/main/java/dev/lukebemish/codecextras/config/OpsIo.java +++ b/src/main/java/dev/lukebemish/codecextras/config/OpsIo.java @@ -15,6 +15,6 @@ public interface OpsIo { void write(T value, OutputStream output) throws IOException; default OpsIo accompanied(Q token, Companion companion) { - return new SpecializedOpsIo(this, DelegatingOps.of(token, companion, ops())); + return new SpecializedOpsIo<>(this, DelegatingOps.of(token, companion, ops())); } } diff --git a/src/main/java/dev/lukebemish/codecextras/ExtendedRecordCodecBuilder.java b/src/main/java/dev/lukebemish/codecextras/record/CurriedRecordCodecBuilder.java similarity index 76% rename from src/main/java/dev/lukebemish/codecextras/ExtendedRecordCodecBuilder.java rename to src/main/java/dev/lukebemish/codecextras/record/CurriedRecordCodecBuilder.java index 1a30500..256e56e 100644 --- a/src/main/java/dev/lukebemish/codecextras/ExtendedRecordCodecBuilder.java +++ b/src/main/java/dev/lukebemish/codecextras/record/CurriedRecordCodecBuilder.java @@ -1,4 +1,4 @@ -package dev.lukebemish.codecextras; +package dev.lukebemish.codecextras.record; import com.mojang.serialization.*; import com.mojang.serialization.codecs.RecordCodecBuilder; @@ -7,34 +7,33 @@ /** * An equivalent to {@link RecordCodecBuilder} that allows for any number of fields. - * Note: this will be moved to {@link dev.lukebemish.codecextras.record} and potentially renamed in a future version. * @param the type of the object being encoded/decoded * @param the type of the highest level field * @param the type of the final builder function used during decoding */ -public abstract sealed class ExtendedRecordCodecBuilder { +public abstract sealed class CurriedRecordCodecBuilder { /** - * Creates a new {@link ExtendedRecordCodecBuilder} with the given codec and getter as the bottom-most field. + * Creates a new {@link CurriedRecordCodecBuilder} with the given codec and getter as the bottom-most field. * @param codec the codec for the bottom-most field * @param getter the getter for the bottom-most field - * @return a new {@link ExtendedRecordCodecBuilder} + * @return a new {@link CurriedRecordCodecBuilder} * @param the type of the object being encoded/decoded * @param the type of the bottom-most field */ - public static ExtendedRecordCodecBuilder> start(MapCodec codec, Function getter) { + public static CurriedRecordCodecBuilder> start(MapCodec codec, Function getter) { return new Endpoint<>(codec, getter); } /** - * Creates a new {@link ExtendedRecordCodecBuilder} with the given codec and getter as the next field above the + * Creates a new {@link CurriedRecordCodecBuilder} with the given codec and getter as the next field above the * current one. * @param codec the codec for the next field * @param getter the getter for the next field - * @return a new {@link ExtendedRecordCodecBuilder} + * @return a new {@link CurriedRecordCodecBuilder} * @param the type of the next field */ - public ExtendedRecordCodecBuilder> field(MapCodec codec, Function getter) { + public CurriedRecordCodecBuilder> field(MapCodec codec, Function getter) { return new Delegating<>(codec, getter, this); } @@ -74,7 +73,7 @@ public Stream keys(DynamicOps ops) { @Override public String toString() { - return ExtendedRecordCodecBuilder.this.toString(); + return CurriedRecordCodecBuilder.this.toString(); } }; } @@ -92,7 +91,7 @@ public sealed interface AppFunction {} protected final MapCodec codec; protected final Function getter; - private ExtendedRecordCodecBuilder(MapCodec codec, Function getter) { + private CurriedRecordCodecBuilder(MapCodec codec, Function getter) { this.codec = codec; this.getter = getter; } @@ -101,7 +100,7 @@ private ExtendedRecordCodecBuilder(MapCodec codec, Function getter) { protected abstract DataResult decodePartial(DynamicOps ops, MapLike input, B b); protected abstract Stream keysPartial(DynamicOps ops); - private static final class Endpoint> extends ExtendedRecordCodecBuilder { + private static final class Endpoint> extends CurriedRecordCodecBuilder { private Endpoint(MapCodec codec, Function getter) { super(codec, getter); } @@ -130,9 +129,9 @@ public String toString() { } } - private static final class Delegating> extends ExtendedRecordCodecBuilder { - private final ExtendedRecordCodecBuilder delegate; - private Delegating(MapCodec codec, Function getter, ExtendedRecordCodecBuilder delegate) { + private static final class Delegating> extends CurriedRecordCodecBuilder { + private final CurriedRecordCodecBuilder delegate; + private Delegating(MapCodec codec, Function getter, CurriedRecordCodecBuilder delegate) { super(codec, getter); this.delegate = delegate; } @@ -161,7 +160,7 @@ protected Stream keysPartial(DynamicOps ops) { @Override public String toString() { - return "ExtendedRecordCodec[" + codec + "] -> " + delegate.toString(); + return "CurriedRecordCodec[" + codec + "] -> " + delegate.toString(); } } } diff --git a/src/main/java/dev/lukebemish/codecextras/record/KeyedRecordCodecBuilder.java b/src/main/java/dev/lukebemish/codecextras/record/KeyedRecordCodecBuilder.java index a696aae..7e1676c 100644 --- a/src/main/java/dev/lukebemish/codecextras/record/KeyedRecordCodecBuilder.java +++ b/src/main/java/dev/lukebemish/codecextras/record/KeyedRecordCodecBuilder.java @@ -7,7 +7,6 @@ import com.mojang.serialization.MapLike; import com.mojang.serialization.RecordBuilder; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.lukebemish.codecextras.ExtendedRecordCodecBuilder; import java.util.ArrayList; import java.util.List; import java.util.function.Function; @@ -16,8 +15,8 @@ import org.jetbrains.annotations.ApiStatus; /** - * Similar to {@link ExtendedRecordCodecBuilder}, an alternative to {@link RecordCodecBuilder} that allows for any - * number of fields. Unlike {@link ExtendedRecordCodecBuilder}, this does not require massively curried lambdas and so + * Similar to {@link CurriedRecordCodecBuilder}, an alternative to {@link RecordCodecBuilder} that allows for any + * number of fields. Unlike {@link CurriedRecordCodecBuilder}, this does not require massively curried lambdas and so * is less likely to make IDEs cry, and may be slightly faster in some scenarios. */ @ApiStatus.Experimental diff --git a/src/main/java/dev/lukebemish/codecextras/repair/FillMissingLogOps.java b/src/main/java/dev/lukebemish/codecextras/repair/FillMissingLogOps.java index 38e0ebc..b502d8b 100644 --- a/src/main/java/dev/lukebemish/codecextras/repair/FillMissingLogOps.java +++ b/src/main/java/dev/lukebemish/codecextras/repair/FillMissingLogOps.java @@ -1,14 +1,13 @@ package dev.lukebemish.codecextras.repair; import com.mojang.serialization.DynamicOps; -import dev.lukebemish.codecextras.companion.AccompaniedOps; import dev.lukebemish.codecextras.companion.Companion; import dev.lukebemish.codecextras.companion.DelegatingOps; public interface FillMissingLogOps extends Companion { FillMissingLogOps.RepairLogOpsToken TOKEN = new RepairLogOpsToken(); - static AccompaniedOps of(FillMissingLogOps logOps, DynamicOps delegate) { + static DynamicOps of(FillMissingLogOps logOps, DynamicOps delegate) { return DelegatingOps.of(TOKEN, logOps, delegate); } diff --git a/src/main/java/dev/lukebemish/codecextras/repair/FillMissingMapCodec.java b/src/main/java/dev/lukebemish/codecextras/repair/FillMissingMapCodec.java index 7800c1f..7a7c173 100644 --- a/src/main/java/dev/lukebemish/codecextras/repair/FillMissingMapCodec.java +++ b/src/main/java/dev/lukebemish/codecextras/repair/FillMissingMapCodec.java @@ -1,49 +1,66 @@ package dev.lukebemish.codecextras.repair; -import com.mojang.serialization.*; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.MapLike; +import com.mojang.serialization.RecordBuilder; import dev.lukebemish.codecextras.companion.AccompaniedOps; import java.util.Optional; +import java.util.function.Supplier; import java.util.stream.Stream; public final class FillMissingMapCodec extends MapCodec { private final MapCodec delegate; private final MapRepair fallback; + private final boolean lenient; - private FillMissingMapCodec(MapCodec delegate, MapRepair fallback) { + private FillMissingMapCodec(MapCodec delegate, MapRepair fallback, boolean lenient) { this.delegate = delegate; this.fallback = fallback; + this.lenient = lenient; } - public static MapCodec of(MapCodec codec, MapRepair fallback) { - return new FillMissingMapCodec<>(codec, fallback); + public static MapCodec fieldOf(MapCodec codec, MapRepair fallback, boolean lenient) { + return new FillMissingMapCodec<>(codec, fallback, lenient); + } + + public static MapCodec fieldOf(MapCodec codec, MapRepair fallback) { + return fieldOf(codec, fallback, true); + } + + public static MapCodec strictFieldOf(MapCodec codec, MapRepair fallback) { + return fieldOf(codec, fallback, false); } public static MapCodec fieldOf(Codec codec, String field, Repair fallback) { - return of(codec.fieldOf(field), new MapRepair<>() { - @Override - public A repair(DynamicOps ops, MapLike flawed) { - T value = flawed.get(field); - if (value == null) { - value = ops.empty(); - } - if (ops instanceof AccompaniedOps accompaniedOps) { - Optional> fillMissingLogOps = accompaniedOps.getCompanion(FillMissingLogOps.TOKEN); - if (fillMissingLogOps.isPresent()) { - fillMissingLogOps.get().logMissingField(field, value); - } - } - return fallback.repair(ops, value); - } - }); + return fieldOf(codec, field, fallback, true); + } + + public static MapCodec strictFieldOf(Codec codec, String field, Repair fallback) { + return fieldOf(codec, field, fallback, false); + } + + public static MapCodec fieldOf(Codec codec, String field, Repair fallback, boolean lenient) { + return fieldOf(codec.fieldOf(field), fallback.fieldOf(field), lenient); } public static MapCodec fieldOf(Codec codec, String field, A fallback) { + return fieldOf(codec, field, fallback, true); + } + + public static MapCodec strictFieldOf(Codec codec, String field, A fallback) { + return fieldOf(codec, field, fallback, false); + } + + public static MapCodec fieldOf(Codec codec, String field, A fallback, boolean lenient) { return fieldOf(codec, field, new Repair<>() { @Override public A repair(DynamicOps ops, T flawed) { return fallback; } - }); + }, lenient); } @Override @@ -53,8 +70,12 @@ public Stream keys(DynamicOps ops) { @Override public DataResult decode(DynamicOps ops, MapLike input) { + boolean allEmpty = delegate.keys(ops).allMatch(key -> input.get(key) == null); + if (allEmpty) { + return DataResult.success(fallback.repair(ops, input)); + } var original = delegate.decode(ops, input); - if (original.error().isPresent()) { + if (lenient && original.error().isPresent()) { return DataResult.success(fallback.repair(ops, input)); } return original; @@ -71,6 +92,33 @@ public interface MapRepair { public interface Repair { A repair(DynamicOps ops, T flawed); + + default MapRepair fieldOf(String field) { + return new MapRepair<>() { + @Override + public A repair(DynamicOps ops, MapLike flawed) { + T value = flawed.get(field); + if (value == null) { + value = ops.empty(); + } + T finalValue = value; + AccompaniedOps.find(ops).ifPresent(accompaniedOps -> { + Optional> fillMissingLogOps = accompaniedOps.getCompanion(FillMissingLogOps.TOKEN); + fillMissingLogOps.ifPresent(tFillMissingLogOps -> tFillMissingLogOps.logMissingField(field, finalValue)); + }); + return Repair.this.repair(ops, value); + } + }; + } + } + + public static Repair lazyRepair(Supplier supplier) { + return new Repair() { + @Override + public A repair(DynamicOps ops, T flawed) { + return supplier.get(); + } + }; } @Override diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Annotation.java b/src/main/java/dev/lukebemish/codecextras/structured/Annotation.java new file mode 100644 index 0000000..b82b9d4 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Annotation.java @@ -0,0 +1,54 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.util.Unit; +import dev.lukebemish.codecextras.types.Identity; +import java.util.Optional; + +/** + * Annotations are metadata that can be attached to parts of structures to provide additional information to interpreters. + * This class contains some annotation keys recognized by built-in interpreters in CodecExtras. + */ +public class Annotation { + /** + * A comment that a field in a structure should be serialized with. + */ + public static final Key COMMENT = Key.create("comment"); + /** + * A human-readable title for a part of a structure. + */ + public static final Key TITLE = Key.create("title"); + /** + * A human-readable description for a part of a structure; if missing, falls back to {@link #COMMENT}. + */ + public static final Key DESCRIPTION = Key.create("description"); + /** + * A regex pattern that a string field or key in a structure should match. + */ + public static final Key PATTERN = Key.create("pattern"); + /** + * If present, the attached structure should be lenient as an optional field -- that is, if present but erroring, it is considered to be missing + */ + public static final Key LENIENT = Key.create("lenient"); + + /** + * Retrieve an annotation value, if present, from a set of annotations. + * @param keys the annotations to search + * @param key the key of the annotation to retrieve + * @return the value of the annotation, if present + * @param the type of the annotation value + */ + public static Optional get(Keys keys, Key key) { + return keys.get(key).map(app -> Identity.unbox(app).value()); + } + + /** + * {@return an empty annotation set} + */ + public static Keys empty() { + return EMPTY; + } + + private static final Keys EMPTY = Keys.builder().build(); + + private Annotation() {} +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/CodecAndMapInterpreters.java b/src/main/java/dev/lukebemish/codecextras/structured/CodecAndMapInterpreters.java new file mode 100644 index 0000000..1b0f647 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/CodecAndMapInterpreters.java @@ -0,0 +1,64 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K1; + +class CodecAndMapInterpreters { + private final CodecInterpreter codecInterpreter; + private final MapCodecInterpreter mapCodecInterpreter; + + CodecAndMapInterpreters( + Keys codecKeys, + Keys mapCodecKeys, + Keys2, K1, K1> parametricCodecKeys, + Keys2, K1, K1> parametricMapCodecKeys + ) { + this.mapCodecInterpreter = new MapCodecInterpreter(mapCodecKeys, parametricMapCodecKeys) { + @Override + protected CodecInterpreter codecInterpreter() { + return CodecAndMapInterpreters.this.codecInterpreter; + } + }; + this.codecInterpreter = new CodecInterpreter(mapCodecKeys.map(new Keys.Converter<>() { + @Override + public App convert(App app) { + return new CodecInterpreter.Holder<>(MapCodecInterpreter.unbox(app).codec()); + } + }).join(codecKeys), parametricMapCodecKeys.map(new Keys2.Converter, ParametricKeyedValue.Mu, K1, K1>() { + @Override + public App2, A, B> convert(App2, A, B> input) { + var unboxed = ParametricKeyedValue.unbox(input); + return new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + var mapCodec = MapCodecInterpreter.unbox(unboxed.convert(parameter)); + return new CodecInterpreter.Holder<>(mapCodec.codec()); + } + }; + } + }).join(parametricCodecKeys)) { + @Override + protected MapCodecInterpreter mapCodecInterpreter() { + return CodecAndMapInterpreters.this.mapCodecInterpreter; + } + }; + } + + CodecAndMapInterpreters() { + this( + Keys.builder().build(), + Keys.builder().build(), + Keys2., K1, K1>builder().build(), + Keys2., K1, K1>builder().build() + ); + } + + public CodecInterpreter codecInterpreter() { + return codecInterpreter; + } + + public MapCodecInterpreter mapCodecInterpreter() { + return mapCodecInterpreter; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/CodecInterpreter.java b/src/main/java/dev/lukebemish/codecextras/structured/CodecInterpreter.java new file mode 100644 index 0000000..a603907 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/CodecInterpreter.java @@ -0,0 +1,209 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.Const; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; +import dev.lukebemish.codecextras.PartialDispatchedMapCodec; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.comments.CommentFirstListCodec; +import dev.lukebemish.codecextras.types.Identity; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Interprets a {@link Structure} into a {@link Codec} for the same type. + * @see #interpret(Structure) + */ +public abstract class CodecInterpreter extends KeyStoringInterpreter { + public CodecInterpreter(Keys keys, Keys2, K1, K1> parametricKeys) { + super(keys.join(Keys.builder() + .add(Interpreter.UNIT, new Holder<>(Codec.unit(Unit.INSTANCE))) + .add(Interpreter.BOOL, new Holder<>(Codec.BOOL)) + .add(Interpreter.BYTE, new Holder<>(Codec.BYTE)) + .add(Interpreter.SHORT, new Holder<>(Codec.SHORT)) + .add(Interpreter.INT, new Holder<>(Codec.INT)) + .add(Interpreter.LONG, new Holder<>(Codec.LONG)) + .add(Interpreter.FLOAT, new Holder<>(Codec.FLOAT)) + .add(Interpreter.DOUBLE, new Holder<>(Codec.DOUBLE)) + .add(Interpreter.STRING, new Holder<>(Codec.STRING)) + .build() + ), parametricKeys.join(Keys2., K1, K1>builder() + .add(Interpreter.INT_IN_RANGE, numberRangeCodecParameter(Codec.INT)) + .add(Interpreter.BYTE_IN_RANGE, numberRangeCodecParameter(Codec.BYTE)) + .add(Interpreter.SHORT_IN_RANGE, numberRangeCodecParameter(Codec.SHORT)) + .add(Interpreter.LONG_IN_RANGE, numberRangeCodecParameter(Codec.LONG)) + .add(Interpreter.FLOAT_IN_RANGE, numberRangeCodecParameter(Codec.FLOAT)) + .add(Interpreter.DOUBLE_IN_RANGE, numberRangeCodecParameter(Codec.DOUBLE)) + .add(Interpreter.STRING_REPRESENTABLE, new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + var representation = StringRepresentation.unbox(parameter); + return new Holder<>(representation.codec().xmap(Identity::new, app -> Identity.unbox(app).value())); + } + }) + .build() + )); + } + + private static > ParametricKeyedValue>, Const.Mu> numberRangeCodecParameter(Codec codec) { + return new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + return new Holder<>(codec.validate(Codec.checkRange(Const.unbox(parameter).min(), Const.unbox(parameter).max())).xmap(Const::create, Const::unbox)); + } + }; + } + + public static CodecInterpreter create( + Keys codecKeys, + Keys mapCodecKeys, + Keys2, K1, K1> parametricCodecKeys, + Keys2, K1, K1> parametricMapCodecKeys + ) { + return new CodecAndMapInterpreters(codecKeys, mapCodecKeys, parametricCodecKeys, parametricMapCodecKeys).codecInterpreter(); + } + + public static CodecInterpreter create() { + return new CodecAndMapInterpreters().codecInterpreter(); + } + + protected abstract MapCodecInterpreter mapCodecInterpreter(); + + @Override + public DataResult>> list(App single) { + return DataResult.success(new Holder<>(CommentFirstListCodec.of(Holder.unbox(single).codec))); + } + + @Override + public DataResult> record(List> fields, Function creator) { + return StructuredMapCodec.of(fields, creator, this, CodecInterpreter::unbox) + .map(mapCodec -> new Holder<>(mapCodec.codec())); + } + + @Override + public DataResult> flatXmap(App input, Function> to, Function> from) { + var codec = Holder.unbox(input).codec(); + return DataResult.success(new Holder<>(codec.flatXmap(to, from))); + } + + @Override + public DataResult> annotate(Structure original, Keys annotations) { + // No annotations handled here + return original.interpret(this); + } + + @Override + public DataResult> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures) { + return keyStructure.interpret(this).flatMap(keyCodecApp -> { + var keyCodec = unbox(keyCodecApp); + var map = new ConcurrentHashMap>>(); + Function>> cache = k -> map.computeIfAbsent(k , structures.andThen(result -> result.flatMap(s -> s.interpret(mapCodecInterpreter())).map(MapCodecInterpreter::unbox))); + return DataResult.success(new Holder<>(keyCodec.partialDispatch(key, function, cache))); + }); + } + + @Override + public DataResult>> unboundedMap(App key, App value) { + var keyCodec = unbox(key); + var valueCodec = unbox(value); + return DataResult.success(new Holder<>(Codec.unboundedMap(keyCodec, valueCodec))); + } + + @Override + public DataResult>> either(App left, App right) { + var leftCodec = unbox(left); + var rightCodec = unbox(right); + return DataResult.success(new Holder<>(Codec.either(leftCodec, rightCodec))); + } + + @Override + public DataResult>> xor(App left, App right) { + var leftCodec = unbox(left); + var rightCodec = unbox(right); + return DataResult.success(new Holder<>(Codec.xor(leftCodec, rightCodec))); + } + + @Override + public DataResult>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures) { + return keyStructure.interpret(this).map(CodecInterpreter::unbox).flatMap(keyCodec -> { + var map = new ConcurrentHashMap>>(); + Function>> cache = k -> map.computeIfAbsent(k , valueStructures.andThen(result -> result.flatMap(s -> s.interpret(this)).map(CodecInterpreter::unbox))); + return DataResult.success(new Holder<>(new PartialDispatchedMapCodec<>(keyCodec, cache))); + }); + } + + @Override + public CodecInterpreter with(Keys keys, Keys2, K1, K1> parametricKeys) { + return new CodecAndMapInterpreters(keys().join(keys), mapCodecInterpreter().keys(), parametricKeys().join(parametricKeys), mapCodecInterpreter().parametricKeys()).codecInterpreter(); + } + + public CodecInterpreter with( + Keys codecKeys, + Keys mapCodecKeys, + Keys2, K1, K1> parametricCodecKeys, + Keys2, K1, K1> parametricMapCodecKeys + ) { + return new CodecAndMapInterpreters( + keys().join(codecKeys), + mapCodecInterpreter().keys().join(mapCodecKeys), + parametricKeys().join(parametricCodecKeys), + mapCodecInterpreter().parametricKeys().join(parametricMapCodecKeys) + ).codecInterpreter(); + } + + public static final Key KEY = Key.create("CodecInterpreter"); + + @Override + public Stream> keyConsumers() { + return Stream.of( + new KeyConsumer() { + @Override + public Key key() { + return KEY; + } + + @Override + public App convert(App input) { + return input; + } + }, + new KeyConsumer() { + @Override + public Key key() { + return MapCodecInterpreter.KEY; + } + + @Override + public App convert(App input) { + return new Holder<>(MapCodecInterpreter.unbox(input).codec()); + } + } + ); + } + + public static Codec unbox(App box) { + return Holder.unbox(box).codec(); + } + + public DataResult> interpret(Structure structure) { + return structure.interpret(this).map(CodecInterpreter::unbox); + } + + public record Holder(Codec codec) implements App { + public static final class Mu implements K1 { private Mu() {} } + + static Holder unbox(App box) { + return (Holder) box; + } + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/IdentityInterpreter.java b/src/main/java/dev/lukebemish/codecextras/structured/IdentityInterpreter.java new file mode 100644 index 0000000..7bdb28e --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/IdentityInterpreter.java @@ -0,0 +1,125 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.DataResult; +import dev.lukebemish.codecextras.types.Identity; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; + +/** + * Attempts to recover a default value from a structure by evaluating missing behaviours as if the value is missing. + */ +public class IdentityInterpreter implements Interpreter { + /** + * The singleton instance of this interpreter. + */ + public static final IdentityInterpreter INSTANCE = new IdentityInterpreter(); + + /** + * The key for this interpreter. + */ + public static final Key KEY = Key.create("IdentityInterpreter"); + + @Override + public Stream> keyConsumers() { + return Stream.of( + new KeyConsumer() { + @Override + public Key key() { + return KEY; + } + + @Override + public App convert(App input) { + return input; + } + } + ); + } + + @Override + public DataResult>> unboundedMap(App key, App value) { + return DataResult.error(() -> "No default value available for an unbounded map"); + } + + @Override + public DataResult>> list(App single) { + return DataResult.error(() -> "No default value available for a list"); + } + + @Override + public DataResult> keyed(Key key) { + return DataResult.error(() -> "No default value available for a key"); + } + + @Override + public DataResult> record(List> fields, Function creator) { + var builder = RecordStructure.Container.builder(); + for (var field : fields) { + DataResult> result = forField(field, builder); + if (result != null) return result; + } + return DataResult.success(new Identity<>(creator.apply(builder.build()))); + } + + private @Nullable DataResult> forField(RecordStructure.Field field, RecordStructure.Container.Builder builder) { + var missingBehavior = field.missingBehavior(); + if (missingBehavior.isPresent()) { + builder.add(field.key(), missingBehavior.get().missing().get()); + } else { + var result = field.structure().interpret(this).map(i -> Identity.unbox(i).value()); + if (result.error().isPresent()) { + return DataResult.error(() -> "No default value available for field " + field.name() + ": " + result.error().orElseThrow().message()); + } + builder.add(field.key(), result.result().orElseThrow()); + } + return null; + } + + @Override + public DataResult> flatXmap(App input, Function> to, Function> from) { + var value = Identity.unbox(input).value(); + return to.apply(value).map(Identity::new); + } + + @Override + public DataResult> annotate(Structure original, Keys annotations) { + return original.interpret(this); + } + + @Override + public DataResult> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures) { + return DataResult.error(() -> "No default value available for a dispatch"); + } + + @Override + public DataResult>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures) { + return DataResult.error(() -> "No default value available for a dispatched map"); + } + + @Override + public DataResult>> parametricallyKeyed(Key2 key, App parameter) { + return DataResult.error(() -> "No default value available for a parametric key"); + } + + @Override + public DataResult>> either(App left, App right) { + return DataResult.error(() -> "No default value available for an either"); + } + + @Override + public DataResult>> xor(App left, App right) { + return DataResult.error(() -> "No default value available for an xor"); + } + + public DataResult interpret(Structure structure) { + return structure.interpret(this).map(i -> Identity.unbox(i).value()); + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Interpreter.java b/src/main/java/dev/lukebemish/codecextras/structured/Interpreter.java new file mode 100644 index 0000000..928e90b --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Interpreter.java @@ -0,0 +1,81 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.Const; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Dynamic; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.types.Identity; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +public interface Interpreter { + DataResult>> list(App single); + + DataResult> keyed(Key key); + + DataResult> record(List> fields, Function creator); + + DataResult> flatXmap(App input, Function> to, Function> from); + + DataResult> annotate(Structure original, Keys annotations); + + DataResult> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures); + + default Stream> keyConsumers() { + return Stream.of(); + } + + public interface KeyConsumer { + Key key(); + App convert(App input); + } + + default DataResult> bounded(Structure input, Supplier> values) { + Function> validator = a -> { + if (values.get().contains(a)) { + return DataResult.success(a); + } + return DataResult.error(() -> "Invalid value: " + a); + }; + return input.interpret(this).flatMap(a -> flatXmap(a, validator, validator)); + } + + DataResult>> unboundedMap(App key, App value); + + Key UNIT = Key.create("UNIT"); + Key BOOL = Key.create("BOOL"); + Key BYTE = Key.create("BYTE"); + Key SHORT = Key.create("SHORT"); + Key INT = Key.create("INT"); + Key LONG = Key.create("LONG"); + Key FLOAT = Key.create("FLOAT"); + Key DOUBLE = Key.create("DOUBLE"); + Key STRING = Key.create("STRING"); + Key> PASSTHROUGH = Key.create("PASSTHROUGH"); + Key EMPTY_MAP = Key.create("EMPTY_MAP"); + Key EMPTY_LIST = Key.create("EMPTY_LIST"); + + DataResult>> parametricallyKeyed(Key2 key, App parameter); + + Key2>, Const.Mu> INT_IN_RANGE = Key2.create("int_in_range"); + Key2>, Const.Mu> BYTE_IN_RANGE = Key2.create("byte_in_range"); + Key2>, Const.Mu> SHORT_IN_RANGE = Key2.create("short_in_range"); + Key2>, Const.Mu> LONG_IN_RANGE = Key2.create("long_in_range"); + Key2>, Const.Mu> FLOAT_IN_RANGE = Key2.create("float_in_range"); + Key2>, Const.Mu> DOUBLE_IN_RANGE = Key2.create("double_in_range"); + Key2 STRING_REPRESENTABLE = Key2.create("enum"); + + DataResult>> either(App left, App right); + + DataResult>> xor(App left, App right); + + DataResult>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures); +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Key.java b/src/main/java/dev/lukebemish/codecextras/structured/Key.java new file mode 100644 index 0000000..57517e0 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Key.java @@ -0,0 +1,51 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; + +/** + * A key which might be associated with a value. Keys carry a type parameter, and are compared by identity. + * @param the type parameter carried by the key, which may determine the type of the associated value + */ +public final class Key implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static Key unbox(App box) { + return (Key) box; + } + + private final String name; + + private Key(String name) { + this.name = name; + } + + /** + * {@return a new key with the given name} + * Names are used for debugging purposes only, as keys are compared by identity. The name of the calling class will + * also be included in the key's name. + * @param name the name of the key + * @param the type parameter carried by the key + */ + public static Key create(String name) { + var className = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).getCallerClass().getSimpleName(); + return new Key<>(className + ":" + name); + } + + /** + * {@return the name of the key} + * Names are used for debugging purposes only, as keys are compared by identity; two keys with the same name are not + * necessarily the same key. + */ + public String name() { + return name; + } + + @Override + public String toString() { + return "Key[" + name + "]"; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Key2.java b/src/main/java/dev/lukebemish/codecextras/structured/Key2.java new file mode 100644 index 0000000..0ef47ae --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Key2.java @@ -0,0 +1,35 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K2; + +public final class Key2 implements App2 { + public static final class Mu implements K2 { + private Mu() { + } + } + + public static Key2 unbox(App2 box) { + return (Key2) box; + } + + private final String name; + + private Key2(String name) { + this.name = name; + } + + public static Key2 create(String name) { + var className = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).getCallerClass().getSimpleName(); + return new Key2<>(className + ":" + name); + } + + public String name() { + return name; + } + + @Override + public String toString() { + return "Key2[" + name + "]"; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/KeyStoringInterpreter.java b/src/main/java/dev/lukebemish/codecextras/structured/KeyStoringInterpreter.java new file mode 100644 index 0000000..993c75c --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/KeyStoringInterpreter.java @@ -0,0 +1,39 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.serialization.DataResult; + +public abstract class KeyStoringInterpreter> implements Interpreter { + private final Keys keys; + private final Keys2, K1, K1> parametricKeys; + + protected KeyStoringInterpreter(Keys keys, Keys2, K1, K1> parametricKeys) { + this.keys = keys; + this.parametricKeys = parametricKeys; + } + + @Override + public DataResult> keyed(Key key) { + return keys.get(key).map(DataResult::success).orElse(DataResult.error(() -> "Unknown key "+key.name())); + } + + @Override + public DataResult>> parametricallyKeyed(Key2 key, App parameter) { + return parametricKeys.get(key) + .map(ParametricKeyedValue::unbox) + .map(val -> val.convert(parameter)) + .map(DataResult::success) + .orElse(DataResult.error(() -> "Unknown key "+key.name())); + } + + protected Keys keys() { + return keys; + } + + protected Keys2, K1, K1> parametricKeys() { + return parametricKeys; + } + + public abstract SELF with(Keys keys, Keys2, K1, K1> parametricKeys); +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Keys.java b/src/main/java/dev/lukebemish/codecextras/structured/Keys.java new file mode 100644 index 0000000..23555e2 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Keys.java @@ -0,0 +1,107 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * A collection of keys and their associated values. Each key is parameterized by a type extending {@code L}, and a + * value matching a given key will be of the type of {@code Mu} applied to the key's type. + * @param the type function mapping key type parameters to value types + * @param the bound on the key type parameters + */ +public final class Keys { + private final IdentityHashMap, App> keys; + + private Keys(IdentityHashMap, App> keys) { + this.keys = keys; + } + + /** + * {@return the value associated with a key, if present} + * @param key the key to search for + * @param the type parameter of the value associated with the key + */ + @SuppressWarnings("unchecked") + public Optional> get(Key key) { + return Optional.ofNullable((App) keys.get(key)); + } + + /** + * {@return a new instance with the same keys, with values whose type is the application of a different type function} + * @param converter converts {@code Mu} to {@code N} for each key's type parameter {@code T extends L} + * @param the type function associated with the new {@link Keys} + */ + public Keys map(Converter converter) { + var map = new IdentityHashMap, App>(); + keys.forEach((key, value) -> map.put(key, converter.convert(value))); + return new Keys<>(map); + } + + /** + * Effectively "lifts" values from {@code Mu} to {@code N}. Type parameters are bounded by {@code L}. + * @param + * @param + * @param + */ + public interface Converter { + /** + * {@return a single value, converted} + * @param input the value to convert + * @param the type parameter of the value to convert + */ + App convert(App input); + } + + /** + * {@return a new key set builder} + * @param the type function mapping key type parameters to value types + * @param the bound on the key type parameters + */ + public static Builder builder() { + return new Builder<>(); + } + + /** + * {@return a new key set combining this set with another} + * Values in the other set will overwrite values in this set if they share a key. + * @param other the other key set to combine with this one + */ + public Keys join(Keys other) { + var map = new IdentityHashMap<>(this.keys); + map.putAll(other.keys); + return new Keys<>(map); + } + + /** + * {@return a new key set with the single key-value pair provided added} + * @param key the key to add + * @param value the value to associate with the key + * @param the type parameter for the key + */ + public Keys with(Key key, App value) { + var map = new IdentityHashMap<>(this.keys); + map.put(key, value); + return new Keys<>(map); + } + + public final static class Builder { + private final Map, App> keys = new IdentityHashMap<>(); + + public Builder add(Key key, App value) { + keys.put(key, value); + return this; + } + + public Keys build() { + return new Keys<>(new IdentityHashMap<>(keys)); + } + + public Builder join(Keys other) { + keys.putAll(other.keys); + return this; + } + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Keys2.java b/src/main/java/dev/lukebemish/codecextras/structured/Keys2.java new file mode 100644 index 0000000..d4645af --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Keys2.java @@ -0,0 +1,64 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K2; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; + +public final class Keys2 { + private final IdentityHashMap, App2> keys; + + private Keys2(IdentityHashMap, App2> keys) { + this.keys = keys; + } + + @SuppressWarnings("unchecked") + public Optional> get(Key2 key) { + return Optional.ofNullable((App2) keys.get(key)); + } + + public Keys2 map(Converter converter) { + var map = new IdentityHashMap, App2>(); + keys.forEach((key, value) -> map.put(key, converter.convert(value))); + return new Keys2<>(map); + } + + public interface Converter { + App2 convert(App2 input); + } + + public static Builder builder() { + return new Builder<>(); + } + + public Keys2 join(Keys2 other) { + var map = new IdentityHashMap<>(this.keys); + map.putAll(other.keys); + return new Keys2<>(map); + } + + public Keys2 with(Key2 key, App2 value) { + var map = new IdentityHashMap<>(this.keys); + map.put(key, value); + return new Keys2<>(map); + } + + public final static class Builder { + private final Map, App2> keys = new IdentityHashMap<>(); + + public Builder add(Key2 key, App2 value) { + keys.put(key, value); + return this; + } + + public Keys2 build() { + return new Keys2<>(new IdentityHashMap<>(keys)); + } + + public Builder join(Keys2 other) { + keys.putAll(other.keys); + return this; + } + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/MapCodecInterpreter.java b/src/main/java/dev/lukebemish/codecextras/structured/MapCodecInterpreter.java new file mode 100644 index 0000000..cb46dac --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/MapCodecInterpreter.java @@ -0,0 +1,192 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.MapLike; +import com.mojang.serialization.RecordBuilder; +import com.mojang.serialization.codecs.KeyDispatchCodec; +import dev.lukebemish.codecextras.XorMapCodec; +import dev.lukebemish.codecextras.comments.CommentMapCodec; +import dev.lukebemish.codecextras.types.Identity; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Interprets a {@link Structure} into a {@link MapCodec} for the same type. + * @see #interpret(Structure) + */ +public abstract class MapCodecInterpreter extends KeyStoringInterpreter { + public MapCodecInterpreter( + Keys keys, + Keys2, K1, K1> parametricKeys + ) { + super(keys, parametricKeys); + } + + public static MapCodecInterpreter create( + Keys codecKeys, + Keys mapCodecKeys, + Keys2, K1, K1> parametricCodecKeys, + Keys2, K1, K1> parametricMapCodecKeys + ) { + return new CodecAndMapInterpreters(codecKeys, mapCodecKeys, parametricCodecKeys, parametricMapCodecKeys).mapCodecInterpreter(); + } + + public static MapCodecInterpreter create() { + return new CodecAndMapInterpreters().mapCodecInterpreter(); + } + + protected abstract CodecInterpreter codecInterpreter(); + + @Override + public DataResult>> list(App single) { + return DataResult.error(() -> "Cannot make a MapCodec for a list"); + } + + @Override + public DataResult> record(List> fields, Function creator) { + return StructuredMapCodec.of(fields, creator, codecInterpreter(), CodecInterpreter::unbox) + .map(Holder::new); + } + + @Override + public DataResult> flatXmap(App input, Function> to, Function> from) { + var mapCodec = unbox(input); + return DataResult.success(new Holder<>(mapCodec.flatXmap(to, from))); + } + + @Override + public DataResult> annotate(Structure original, Keys annotations) { + return original.interpret(this).map(input -> { + var mapCodec = new Object() { + MapCodec m = unbox(input); + }; + mapCodec.m = Annotation.get(annotations, Annotation.COMMENT).map(comment -> CommentMapCodec.of(mapCodec.m, comment)).orElse(mapCodec.m); + mapCodec.m = Annotation.get(annotations, Annotation.LENIENT).>map(ignored -> { + var outer = mapCodec.m; + return new MapCodec<>() { + @Override + public RecordBuilder encode(A input, DynamicOps ops, RecordBuilder prefix) { + return outer.encode(input, ops, prefix); + } + + @Override + public DataResult decode(DynamicOps ops, MapLike input) { + var result = outer.decode(ops, input); + if (result.error().isPresent()) { + var fromEmpty = ops.getMap(ops.emptyMap()).flatMap(map -> outer.decode(ops, map)); + if (fromEmpty.error().isEmpty()) { + return fromEmpty; + } + } + return result; + } + + @Override + public Stream keys(DynamicOps ops) { + return outer.keys(ops); + } + }; + }).orElse(mapCodec.m); + return new Holder<>(mapCodec.m); + }); + } + + public static MapCodec unbox(App box) { + return Holder.unbox(box).mapCodec(); + } + + public DataResult> interpret(Structure structure) { + return structure.interpret(this).map(MapCodecInterpreter::unbox); + } + + @Override + public DataResult> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures) { + return keyStructure.interpret(codecInterpreter()).flatMap(keyCodecApp -> { + var keyCodec = CodecInterpreter.unbox(keyCodecApp); + var map = new ConcurrentHashMap>>(); + Function>> cache = k -> map.computeIfAbsent(k , structures.andThen(result -> result.flatMap(s -> s.interpret(this)).map(MapCodecInterpreter::unbox))); + return DataResult.success(new MapCodecInterpreter.Holder<>(new KeyDispatchCodec<>(key, keyCodec, function, cache))); + }); + } + + @Override + public DataResult>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures) { + return DataResult.error(() -> "Cannot make a MapCodec for a dispatched map"); + } + + @Override + public DataResult>> unboundedMap(App key, App value) { + return DataResult.error(() -> "Cannot make a MapCodec for an unbounded map"); + } + + @Override + public DataResult>> either(App left, App right) { + var leftCodec = unbox(left); + var rightCodec = unbox(right); + return DataResult.success(new Holder<>(Codec.mapEither(leftCodec, rightCodec))); + } + + @Override + public DataResult>> xor(App left, App right) { + var leftCodec = unbox(left); + var rightCodec = unbox(right); + return DataResult.success(new Holder<>(XorMapCodec.of(leftCodec, rightCodec))); + } + + @Override + public MapCodecInterpreter with(Keys keys, Keys2, K1, K1> parametricKeys) { + return new CodecAndMapInterpreters(codecInterpreter().keys(), keys().join(keys), codecInterpreter().parametricKeys(), parametricKeys().join(parametricKeys)).mapCodecInterpreter(); + } + + public MapCodecInterpreter with( + Keys codecKeys, + Keys mapCodecKeys, + Keys2, K1, K1> parametricCodecKeys, + Keys2, K1, K1> parametricMapCodecKeys + ) { + return new CodecAndMapInterpreters( + codecInterpreter().keys().join(codecKeys), + keys().join(mapCodecKeys), + codecInterpreter().parametricKeys().join(parametricCodecKeys), + parametricKeys().join(parametricMapCodecKeys) + ).mapCodecInterpreter(); + } + + public record Holder(MapCodec mapCodec) implements App { + public static final class Mu implements K1 { private Mu() {} } + + static Holder unbox(App box) { + return (Holder) box; + } + } + + public static final Key KEY = Key.create("MapCodecInterpreter"); + + @Override + public Stream> keyConsumers() { + return Stream.of( + new KeyConsumer() { + @Override + public Key key() { + return KEY; + } + + @Override + public App convert(App input) { + return input; + } + } + ); + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/ParametricKeyedValue.java b/src/main/java/dev/lukebemish/codecextras/structured/ParametricKeyedValue.java new file mode 100644 index 0000000..22c2498 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/ParametricKeyedValue.java @@ -0,0 +1,30 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.kinds.K2; + +public interface ParametricKeyedValue extends App2, MuP, MuO> { + final class Mu implements K2 { private Mu() {} } + + App> convert(App parameter); + + default ParametricKeyedValue map(Converter converter) { + var outer = this; + return new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + return converter.convert(outer.convert(parameter)); + } + }; + } + + interface Converter { + App> convert(App> app); + } + + static ParametricKeyedValue unbox(App2, MuP, MuO> boxed) { + return (ParametricKeyedValue) boxed; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Range.java b/src/main/java/dev/lukebemish/codecextras/structured/Range.java new file mode 100644 index 0000000..b38b030 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Range.java @@ -0,0 +1,42 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; + +/** + * A range of values + * + * @param min minimum value, inclusive + * @param max maximum value, inclusive + * @param number type of the range + */ +public record Range>(N min, N max) implements App { + public Range { + if (min.compareTo(max) >= 0) { + throw new IllegalArgumentException("min >= max"); + } + } + + /** + * {@return the value, or the closest endpoint if the value is outside the range} + * @param value the value to clamp + */ + public N clamp(N value) { + if (value.compareTo(min) < 0) { + return min; + } + if (value.compareTo(max) > 0) { + return max; + } + return value; + } + + public static final class Mu implements K1 { + private Mu() { + } + } + + public static > Range unbox(App app) { + return (Range) app; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/RecordStructure.java b/src/main/java/dev/lukebemish/codecextras/structured/RecordStructure.java new file mode 100644 index 0000000..f4177e2 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/RecordStructure.java @@ -0,0 +1,267 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.serialization.DataResult; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Used to assemble a set of key-structure pairs, potentially optionally present, into a structure. Most often you will + * create a {@link RecordStructure.Builder} and pass it to {@link Structure#record(Builder)}. + * @param The type of the record represented. + */ +public class RecordStructure { + private final List> fields = new ArrayList<>(); + private final Set fieldNames = new HashSet<>(); + private int count = 0; + + /** + * When assembling an object from the layout defined in a record structure, values that have been read in for known + * keys are available in a {@link Container}. + */ + public static final class Container { + private final Key[] keys; + private final Object[] array; + + private Container(Key[] keys, Object[] array) { + this.array = array; + this.keys = keys; + } + + @SuppressWarnings("unchecked") + private T get(Key key) { + if (key.count >= array.length || key != keys[key.count]) { + throw new IllegalArgumentException("Key does not belong to the container"); + } + return (T) array[key.count]; + } + + /** + * {@return a container builder that has not read any keys yet} + * {@link Interpreter}s may need to assemble a container to use with a record structure. + */ + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final List values = new ArrayList<>(); + private final List> keys = new ArrayList<>(); + + private Builder() {} + + /** + * Add a key-value pair to the container. + * @param key the key + * @param value the value + * @param the type of the value + */ + public void add(Key key, T value) { + keys.add(key); + values.add(value); + } + + /** + * {@return a new container with the keys and values added so far} + */ + public Container build() { + return new Container(keys.toArray(Key[]::new), values.toArray()); + } + } + } + + /** + * A handle to the value of a single field as read into a {@link Container} + * @param the type of the field + */ + public static final class Key implements Function { + private final int count; + + private Key(int i) { + this.count = i; + } + + @Override + public T apply(Container container) { + return container.get(this); + } + } + + /** + * A single field in a record structure. + * @param the type of the complete type + * @param the type of the field + */ + public interface Field { + /** + * {@return the name of the field} + */ + String name(); + + /** + * {@return the structure for the field's contents} + */ + Structure structure(); + + /** + * {@return a function to extract the field's value from the full data type} + */ + Function getter(); + + /** + * {@return how the field should behave if it is missing from data} + */ + Optional> missingBehavior(); + + /** + * {@return the key to access the field's value once it is read in} + */ + Key key(); + + /** + * Represents how a field should behave if it is missing from data. + * @param the type of the field + */ + interface MissingBehavior { + /** + * {@return the default value to use if the field is missing} + */ + Supplier missing(); + + /** + * {@return whether a given field value should be re-encoded or just left missing} + */ + Predicate predicate(); + } + } + + private record MissingBehaviorImpl(Supplier missing, Predicate predicate) implements Field.MissingBehavior {} + + private record FieldImpl(String name, Structure structure, Function getter, Optional> missingBehavior, Key key) implements Field {} + + /** + * Add a field to the record structure. + * @param name the name of the field + * @param structure the structure of the field's contents + * @param getter a function to extract the field's value from the full data type + * @return a key to access the field's value once it is read in to a {@link Container} + * @param the type of the field + */ + public Key add(String name, Structure structure, Function getter) { + var key = new Key(count); + count++; + fields.add(new FieldImpl<>(name, structure, getter, Optional.empty(), key)); + fieldNames.add(name); + return key; + } + + /** + * Add an optional field to the record structure. + * @param name the name of the field + * @param structure the structure of the field's contents + * @param getter a function to extract the field's value from the full data type + * @return a key to access the field's value once it is read in to a {@link Container} + * @param the type of the field + */ + public Key> addOptional(String name, Structure structure, Function> getter) { + var key = new Key>(count); + count++; + fields.add(new FieldImpl<>( + name, + structure.flatXmap( + t -> DataResult.success(Optional.of(t)), + o -> o.map(DataResult::success).orElseGet(() -> DataResult.error(() ->"Optional default value not handled by interpreter")) + ), + getter, + Optional.of(new MissingBehaviorImpl<>(Optional::empty, Optional::isPresent)), + key + )); + fieldNames.add(name); + return key; + } + + /** + * Add a field to the record structure with a default value. The field will not be encoded if equal to its default value. + * @param name the name of the field + * @param structure the structure of the field's contents + * @param getter a function to extract the field's value from the full data type + * @param defaultValue the default value to use if the field is missing + * @return a key to access the field's value once it is read in to a {@link Container} + * @param the type of the field + */ + public Key addOptional(String name, Structure structure, Function getter, Supplier defaultValue) { + var key = new Key(count); + count++; + fields.add(new FieldImpl<>(name, structure, getter, Optional.of(new MissingBehaviorImpl<>(defaultValue, t -> !t.equals(defaultValue.get()))), key)); + fieldNames.add(name); + return key; + } + + /** + * Add a sub-record to the record structure. All that record's fields will be located at the top level, but can be + * retrieved as one from a {@link Container}. + * @param part the record structure to add + * @param getter a function to extract the sub-record from the full data type + * @return a function to create the sub-record from a {@link Container} + * @param the type of the sub-record + */ + public Function add(RecordStructure.Builder part, Function getter) { + RecordStructure partial = new RecordStructure<>(); + partial.count = this.count; + var creator = part.build(partial); + for (var field : partial.fields) { + partialField(getter, field); + } + return creator; + } + + private void partialField(Function getter, Field field) { + if (fieldNames.contains(field.name())) { + throw new IllegalArgumentException("Duplicate field name: " + field.name()); + } + fields.add(new FieldImpl<>(field.name(), field.structure(), a -> field.getter().apply(getter.apply(a)), field.missingBehavior(), field.key())); + count++; + fieldNames.add(field.name()); + } + + /** + * Turn a record structure builder into a {@link Structure}. + * @param builder the record structure builder + * @return a new structure + * @param the type of the data represented + */ + static Structure create(RecordStructure.Builder builder) { + RecordStructure instance = new RecordStructure<>(); + var creator = builder.build(instance); + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.record(instance.fields, creator); + } + }; + } + + /** + * A builder for a record structure. This is the fundamental unit used to assemble record structures, and much as + * {@link com.mojang.serialization.MapCodec}s are reusable, record structure builders can be combined and reused via + * {@link #add(Builder, Function)}. + * @param the type of the record represented + */ + @FunctionalInterface + public interface Builder { + /** + * Assemble a record structure for the given type. Should collect {@link Key}s for every field needed and return + * a function that uses those keys to assemble the final type from a {@link Container}. + * @param builder a blank record structure to add fields to + * @return a function to assemble the final type from a {@link Container} + */ + Function build(RecordStructure builder); + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/Structure.java b/src/main/java/dev/lukebemish/codecextras/structured/Structure.java new file mode 100644 index 0000000..a4bc40f --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/Structure.java @@ -0,0 +1,742 @@ +package dev.lukebemish.codecextras.structured; + +import com.google.common.collect.Sets; +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.Const; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Dynamic; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.types.Flip; +import dev.lukebemish.codecextras.types.Identity; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Represents the structure of a data type in a generic form. This structure can then be interpreted into any number of + * specific representations, such as a {@link com.mojang.serialization.Codec}, by using the appropriate {@link Interpreter}. + * @param the type of data this structure represents + */ +public interface Structure { + /** + * Use the structure to create a representation of the data type. + * @param interpreter contains the logic to create a specific type of representation from a structure + * @return the specific representation of the data type, boxed as an {@link App}, or an error if one could not be created + * @param the type function of the sort of specific representation + */ + DataResult> interpret(Interpreter interpreter); + + /** + * {@return the annotations attached to this structure} + * Annotations are pieces of metadata attached to a structure which an interpreter may optionally use to mark up the + * result it produces; examples would include comments to attach to a codec that would show up in supported serialized + * data formats, or the like. + * @see Annotation + */ + default Keys annotations() { + return Annotation.empty(); + } + + /** + * {@return a new structure with the single provided annotation added} + * @param key the annotation key + * @param value the annotation value + * @param the type of the annotation + * @see Annotation + */ + default Structure annotate(Key key, T value) { + var outer = this; + var annotations = annotations().with(key, new Identity<>(value)); + return annotatedDelegatingStructure(Function.identity(), outer, annotations); + } + + /** + * {@return a new structure with the provided annotations added} + * @param annotations the annotations to add + * @see Annotation + */ + default Structure annotate(Keys annotations) { + var outer = this; + var combined = annotations().join(annotations); + return annotatedDelegatingStructure(Function.identity(), outer, combined); + } + + private static Structure annotatedDelegatingStructure(Function, Structure> outerFunction, Structure outer, Keys annotations) { + final class AnnotatedDelegatingStructure implements Structure { + final Structure original; + + AnnotatedDelegatingStructure(Function, Structure> function, Structure original) { + while (original instanceof AnnotatedDelegatingStructure annotatedDelegatingStructure) { + original = annotatedDelegatingStructure.original; + } + this.original = function.apply(original); + } + + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.annotate(original, annotations); + } + + @Override + public Keys annotations() { + return annotations; + } + } + + return new AnnotatedDelegatingStructure<>(outerFunction, outer); + } + + /** + * {@return a new structure representing a list of the current structure} + * Analogous to {@link Codec#listOf()}. + */ + default Structure> listOf() { + var outer = this; + return new Structure<>() { + @Override + public DataResult>> interpret(Interpreter interpreter) { + return outer.interpret(interpreter).flatMap(interpreter::list); + } + }; + } + + /** + * {@return a structure representing key-value pairs of the provided types, with no bounds on the possible key values} + * Unlike a structure created with {@link #record(RecordStructure.Builder)}, there is no set of potential keys known + * ahead of time. Analogous to {@link Codec#unboundedMap(Codec, Codec)}. + * @param key the structure representing the key type + * @param value the structure representing the value type + * @param the represented key type + * @param the represented value type + */ + static Structure> unboundedMap(Structure key, Structure value) { + return new Structure<>() { + @Override + public DataResult>> interpret(Interpreter interpreter) { + return key.interpret(interpreter).flatMap(k -> value.interpret(interpreter).flatMap(v -> interpreter.unboundedMap(k, v))); + } + }; + } + + /** + * {@return a structure representing data matching at least one of two possible structures, with the left structure preferred} + * Analogous to {@link Codec#either(Codec, Codec)}. + * @param left the preferred structure + * @param right the fallback structure + * @param the type of data the first structure represents + * @param the type of data the second structure represents + * @see #xor(Structure, Structure) + */ + static Structure> either(Structure left, Structure right) { + return new Structure<>() { + @Override + public DataResult>> interpret(Interpreter interpreter) { + var leftResult = left.interpret(interpreter); + var rightResult = right.interpret(interpreter); + return leftResult + .mapError(s -> rightResult.error().map(e -> s + "; " + e.message()).orElse(s)) + .flatMap(leftApp -> rightResult.flatMap(rightApp -> interpreter.either(leftApp, rightApp))); + } + }; + } + + /** + * {@return a structure representing data matching exactly one of two possible structures} + * Analogous to {@link Codec#xor(Codec, Codec)}. + * @param left the first structure + * @param right the second structure + * @param the type of data the first structure represents + * @param the type of data the second structure represents + * @see #either(Structure, Structure) + */ + static Structure> xor(Structure left, Structure right) { + return new Structure<>() { + @Override + public DataResult>> interpret(Interpreter interpreter) { + var leftResult = left.interpret(interpreter); + var rightResult = right.interpret(interpreter); + return leftResult + .mapError(s -> rightResult.error().map(e -> s + "; " + e.message()).orElse(s)) + .flatMap(leftApp -> rightResult.flatMap(rightApp -> interpreter.xor(leftApp, rightApp))); + } + }; + } + + /** + * {@return a partial record structure containing the current structure as a field} + * Analogous to {@link Codec#fieldOf(String)}. + * @param name the name of the field + * @see #record(RecordStructure.Builder) + */ + default RecordStructure.Builder fieldOf(String name) { + return builder -> builder.add(name, this, Function.identity()); + } + + /** + * {@return a record structure optionally containing the current structure as a field} + * Analogous t= {@link Codec#optionalFieldOf(String)}. + * @param name the name of the field + * @see #fieldOf(String) + */ + default RecordStructure.Builder> optionalFieldOf(String name) { + return builder -> builder.addOptional(name, this, Function.identity()); + } + + /** + * {@return a record structure containing the current structure as a field, with a default value if it is not present} + * Analogous t= {@link Codec#optionalFieldOf(String, Object)}. + * @param name the name of the field + * @see #optionalFieldOf(String) + */ + default RecordStructure.Builder optionalFieldOf(String name, Supplier defaultValue) { + return builder -> builder.addOptional(name, this, Function.identity(), defaultValue); + } + + /** + * Like codecs, the type a structure represents can be changed without changing the actual underlying data structure, + * by providing conversion functions to and from the new type. Analogous to {@link Codec#flatXmap(Function, Function)}. + * @param to converts the old type to the new type, if possible + * @param from converts the new type to the old type, if possible + * @return a new structure representing the new type + * @param the new type to represent + */ + default Structure flatXmap(Function> to, Function> from) { + return annotatedDelegatingStructure(outer -> new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return outer.interpret(interpreter).flatMap(app -> interpreter.flatXmap(app, to, from)); + } + }, this, this.annotations()); + } + + /** + * Similar to {@link #flatXmap(Function, Function)}, except that the conversion functions are not allowed to fail. + * Analogous to {@link Codec#xmap(Function, Function)}. + * @param to converts the old type to the new type + * @param from converts the new type to the old type + * @return a new structure representing the new type + * @param the new type to represent + */ + default Structure xmap(Function to, Function from) { + return flatXmap(a -> DataResult.success(to.apply(a)), b -> DataResult.success(from.apply(b))); + } + + /** + * Similar to {@link #flatXmap(Function, Function)}, except that the second conversion function is not allowed to fail. + * Analogous to {@link Codec#comapFlatMap(Function, Function)}. + * @param to converts the old type to the new type + * @param from converts the new type to the old type + * @return a new structure representing the new type + * @param the new type to represent + */ + default Structure comapFlatMap(Function> to, Function from) { + return flatXmap(to, b -> DataResult.success(from.apply(b))); + } + + /** + * Similar to {@link #flatXmap(Function, Function)}, except that the first conversion function is not allowed to fail. + * Analogous to {@link Codec#flatComapMap(Function, Function)}. + * @param to converts the old type to the new type + * @param from converts the new type to the old type + * @return a new structure representing the new type + * @param the new type to represent + */ + default Structure flatComapMap(Function to, Function> from) { + return flatXmap(a -> DataResult.success(to.apply(a)), from); + } + + /** + * Creates a structure such that the structure of the data is dependent on the value for a given key. The key must + * have a finite set of possible values. The current structure becomes the structure of the key field. + * Analogous to {@link Codec#dispatch(String, Function, Function)}. + * @param key the key to dispatch on + * @param function retrieve a key from the final data type + * @param keys the set of possible key values + * @param structures converts keys into structures + * @return a new structure representing the data type + * @param the type of data the structure represents + */ + default Structure dispatch(String key, Function> function, Supplier> keys, Function>> structures) { + return dispatch(key, function, keys, structures, true); + } + + /** + * Similar to {@link #dispatch(String, Function, Supplier, Function)}, except that while the set of keys is still finite, + * and keys are still checked as belonging to the set, the resulting structure does not expose information about those bounds. + * @param key the key to dispatch on + * @param function retrieve a key from the final data type + * @param keys the set of possible key values + * @param structures converts keys into structures + * @return a new structure representing the data type + * @param the type of data the structure represents + */ + default Structure dispatchUnbounded(String key, Function> function, Supplier> keys, Function>> structures) { + return dispatch(key, function, keys, structures, false); + } + + private Structure dispatch(String key, Function> function, Supplier> keys, Function>> structures, boolean bounded) { + var outer = bounded ? this.bounded(keys) : this; + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.dispatch(key, outer, function, keys, structures); + } + }; + } + + /** + * Creates a structure representing a map where the structure of a value is dependent on the key. The key must have a + * finite set of possible values. The current structure becomes the structure of the key field. Analogous to {@link Codec#dispatchedMap(Codec, Function)}. + * @param keys the set of possible key values + * @param structures converts keys into structures + * @return a new structure representing the map type + * @param the type of data the map values represent + */ + default Structure> dispatchedMap(Supplier> keys, Function>> structures) { + return dispatchedMap(keys, structures, true); + } + + /** + * Similar to {@link #dispatchedMap(Supplier, Function)}, except that while the set of keys is still finite, and keys + * are still checked as belonging to the set, the resulting structure does not expose information about those bounds. + * @param keys the set of possible key values + * @param structures converts keys into structures + * @return a new structure representing the map type + * @param the type of data the map values represent + */ + default Structure> dispatchedUnboundedMap(Supplier> keys, Function>> structures) { + return dispatchedMap(keys, structures, false); + } + + private Structure> dispatchedMap(Supplier> keys, Function>> structures, boolean bounded) { + var outer = bounded ? this.bounded(keys) : this; + return new Structure<>() { + @Override + public DataResult>> interpret(Interpreter interpreter) { + return interpreter.dispatchedMap(outer, keys, structures); + } + }; + } + + /** + * It might be necessary to lazily-initialize a structure to avoid circular static field dependencies. Analogous to + * {@link Codec#lazyInitialized(Supplier)}. + * @param supplier the structure to lazily initialize + * @return a new structure that will initialize the wrapped structure when interpreted + * @param the type of data the structure represents + */ + static Structure lazyInitialized(Supplier> supplier) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return supplier.get().interpret(interpreter); + } + }; + } + + /** + * Keys provide a way of representing the smallest building blocks of a structure. Interpreters are responsible for + * finding a matching specific representation given a key when interpreting a structure. + * @param key the key which will be matched to a specific representation + * @return a new structure + * @param the type of data the structure represents + * @see Key + */ + static Structure keyed(Key key) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.keyed(key); + } + }; + } + + /** + * Similar to {@link #keyed(Key)}, except a fallback structure is provided in case the interpreter cannot resolve the key. + * @param key the key which will be matched to a specific representation + * @param fallback the structure to interpret if the key cannot be resolved + * @return a new structure + * @param the type of data the structure represents + * @see #keyed(Key) + */ + static Structure keyed(Key key, Structure fallback) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + var result = interpreter.keyed(key); + if (result.error().isPresent()) { + return fallback.interpret(interpreter).mapError(s -> "Could not interpret keyed structure: "+s+"; "+result.error().orElseThrow().message()); + } + return result; + } + }; + } + + /** + * Similar to {@link #keyed(Key)}, except that specific representations may also be stored on the structure, by + * resolved interpreter-specific keys. The key set used is {@link Flip}ed so that the type parameterizing the key can + * be the type function of the interpreter. Interpreter keys are matched against the provided key set, and if missing + * the provided key is resolved by the interpreter. + * @param key the key which will be matched to a specific representation + * @param keys the set of specific representations to match against + * @return a new structure + * @param the type of data the structure represents + * @see Interpreter#keyConsumers() + * @see #keyed(Key) + */ + static Structure keyed(Key key, Keys, K1> keys) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.keyConsumers().flatMap(c -> convertedAppFromKeys(keys, c).stream()) + .findFirst() + .map(DataResult::success) + .orElseGet(() -> interpreter.keyed(key)); + } + }; + } + + + /** + * Similar to {@link #keyed(Key)}, providing both a fallback structure and a set of interpreter-specific + * representations. + * @param key the key which will be matched to a specific representation + * @param keys the set of specific representations to match against + * @param fallback the structure to interpret if the key cannot be resolved + * @return a new structure + * @param the type of data the structure represents + * @see #keyed(Key) + * @see #keyed(Key, Keys) + * @see #keyed(Key, Structure) + */ + static Structure keyed(Key key, Keys, K1> keys, Structure fallback) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + var result = interpreter.keyConsumers().flatMap(c -> convertedAppFromKeys(keys, c).stream()) + .findFirst() + .map(DataResult::success) + .orElseGet(() -> interpreter.keyed(key)); + if (result.error().isPresent()) { + return fallback.interpret(interpreter).mapError(s -> "Could not interpret keyed structure: "+s+"; "+result.error().orElseThrow().message()); + } + return result; + } + }; + } + + /** + * Similar to a {@link #keyed(Key)}, a parametrically keyed structure provides a key that interpreters can resolve to + * obtain a specific representation; however, the key is parameterized by another type, which the structure also contains + * an instance of. The structure will resolve a {@link ParametricKeyedValue} for the key, representing a conversion + * of the parameter type into the structure type for arbitrary parameterizations of the parameter type. + * @param key the key which will be matched to a specific representation + * @param parameter the parameter to convert into a specific representation + * @param unboxer unboxes the structure type from its {@link App} representation used by the key + * @return a new structure + * @param the type function of the data type represented by the created structure + * @param the type function of the parameter type associated with the key + * @param the type to parameterize both {@link MuO} and {@link MuP} with + * @param the type of data the structure represents + */ + static > Structure parametricallyKeyed(Key2 key, App parameter, Function, A> unboxer) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.parametricallyKeyed(key, parameter).flatMap(app -> + interpreter.flatXmap(app, a -> DataResult.success(unboxer.apply(a)), DataResult::success) + ); + } + }; + } + + /** + * Similar to {@link #parametricallyKeyed(Key2, App, Function)}, except a fallback structure is provided in case the + * interpreter cannot resolve the key. + * @param key the key which will be matched to a specific representation + * @param parameter the parameter to convert into a specific representation + * @param unboxer unboxes the structure type from its {@link App} representation used by the key + * @param fallback the structure to interpret if the key cannot be resolved + * @return a new structure + * @param the type function of the data type represented by the created structure + * @param the type function of the parameter type associated with the key + * @param the type to parameterize both {@link MuO} and {@link MuP} with + * @param the type of data the structure represents + * @see #parametricallyKeyed(Key2, App, Function) + */ + static > Structure parametricallyKeyed(Key2 key, App parameter, Function, A> unboxer, Structure fallback) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + var result = interpreter.parametricallyKeyed(key, parameter).flatMap(app -> + interpreter.flatXmap(app, a -> DataResult.success(unboxer.apply(a)), DataResult::success) + ); + if (result.error().isPresent()) { + return fallback.interpret(interpreter).mapError(s -> "Could not interpret parametrically keyed structure: "+s+"; "+result.error().orElseThrow().message()); + } + return result; + } + }; + } + + /** + * Similar to {@link #parametricallyKeyed(Key2, App, Function)}, except that specific representations may also be + * stored on the structure, by resolved interpreter-specific keys. The key set used is {@link Flip}ed so that the + * type parameterizing the key can be the type function of the interpreter. Interpreter keys are matched against the + * provided key set, and if missing the provided key is resolved by the interpreter. + * @param key the key which will be matched to a specific representation + * @param parameter the parameter to convert into a specific representation + * @param unboxer unboxes the structure type from its {@link App} representation used by the key + * @param keys the set of specific representations to match against + * @return a new structure + * @param the type function of the data type represented by the created structure + * @param the type function of the parameter type associated with the key + * @param the type to parameterize both {@link MuO} and {@link MuP} with + * @param the type of data the structure represents + * @see #parametricallyKeyed(Key2, App, Function) + */ + static > Structure parametricallyKeyed(Key2 key, App parameter, Function, A> unboxer, Keys, K1> keys) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.keyConsumers().flatMap(c -> convertedAppFromKeys(keys, c).stream()) + .findFirst().map(DataResult::success) + .orElseGet(() -> interpreter.parametricallyKeyed(key, parameter).flatMap(app -> + interpreter.flatXmap(app, a -> DataResult.success(unboxer.apply(a)), DataResult::success) + )); + } + }; + } + + private static Optional> convertedAppFromKeys(Keys, K1> keys, Interpreter.KeyConsumer c) { + return keys.get(c.key()) + .map(Flip::unbox) + .map(Flip::value) + .map(c::convert); + } + + /** + * Similar to {@link #parametricallyKeyed(Key2, App, Function)}, providing both a fallback structure and a set of + * interpreter-specific representations. + * @param key the key which will be matched to a specific representation + * @param parameter the parameter to convert into a specific representation + * @param unboxer unboxes the structure type from its {@link App} representation used by the key + * @param keys the set of specific representations to match against + * @param fallback the structure to interpret if the key cannot be resolved + * @return a new structure + * @param the type function of the data type represented by the created structure + * @param the type function of the parameter type associated with the key + * @param the type to parameterize both {@link MuO} and {@link MuP} with + * @param the type of data the structure represents + * @see #parametricallyKeyed(Key2, App, Function) + * @see #parametricallyKeyed(Key2, App, Function, Keys) + * @see #parametricallyKeyed(Key2, App, Function, Structure) + */ + static > Structure parametricallyKeyed(Key2 key, App parameter, Function, A> unboxer, Keys, K1> keys, Structure fallback) { + return new Structure<>() { + @Override + public DataResult> interpret(Interpreter interpreter) { + var result = interpreter.keyConsumers().flatMap(c -> convertedAppFromKeys(keys, c).stream()) + .findFirst() + .map(DataResult::success) + .orElseGet(() -> interpreter.parametricallyKeyed(key, parameter).flatMap(app -> + interpreter.flatXmap(app, a -> DataResult.success(unboxer.apply(a)), DataResult::success) + )); + if (result.error().isPresent()) { + return fallback.interpret(interpreter).mapError(s -> "Could not interpret parametrically keyed structure: "+s+"; "+result.error().orElseThrow().message()); + } + return result; + } + }; + } + + /** + * {@return a structure that represents only the provided values} + * @param available the set of values to represent + */ + default Structure bounded(Supplier> available) { + final class BoundedStructure implements Structure { + private final Structure outer; + private final Supplier> totalAvailable; + + BoundedStructure(Structure outer) { + if (outer instanceof BoundedStructure boundedStructure) { + this.outer = boundedStructure.outer; + this.totalAvailable = () -> Sets.union(boundedStructure.totalAvailable.get(), available.get()); + } else { + this.outer = outer; + this.totalAvailable = available; + } + } + + @Override + public DataResult> interpret(Interpreter interpreter) { + return interpreter.bounded(outer, totalAvailable); + } + } + + return annotatedDelegatingStructure(BoundedStructure::new, this, this.annotations()); + } + + /** + * {@return a structure that represents only data passing the provided validation function} + * @param verifier the validation function + */ + default Structure validate(Function> verifier) { + return this.flatXmap(verifier, verifier); + } + + /** + * {@return a structure representing a collection of key-value pairs with defined structures, which may be optionally present} + * Analogous to the use of {@link com.mojang.serialization.codecs.RecordCodecBuilder}. + * @param builder the builder to use to create the record structure + * @param the type of data the structure represents + * @see RecordStructure + */ + static Structure record(RecordStructure.Builder builder) { + return RecordStructure.create(builder); + } + + /** + * Represents a {@link Unit} value. + */ + Structure UNIT = keyed(Interpreter.UNIT); + /** + * Represents a {@code boolean} value. + */ + Structure BOOL = keyed(Interpreter.BOOL); + /** + * Represents a {@code byte} value. + */ + Structure BYTE = keyed(Interpreter.BYTE); + /** + * Represents a {@code short} value. + */ + Structure SHORT = keyed(Interpreter.SHORT); + /** + * Represents an {@code int} value. + */ + Structure INT = keyed(Interpreter.INT); + /** + * Represents a {@code long} value. + */ + Structure LONG = keyed(Interpreter.LONG); + /** + * Represents a {@code float} value. + */ + Structure FLOAT = keyed(Interpreter.FLOAT); + /** + * Represents a {@code double} value. + */ + Structure DOUBLE = keyed(Interpreter.DOUBLE); + /** + * Represents a {@link String} value. + */ + Structure STRING = keyed(Interpreter.STRING); + + /** + * Represents a {@link Dynamic} value. + */ + Structure> PASSTHROUGH = keyed( + Interpreter.PASSTHROUGH, + Keys.>, K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(Codec.PASSTHROUGH))) + .build() + ); + + /** + * Represents a {@link Unit} value, but only as an empty map. + */ + Structure EMPTY_MAP = keyed( + Interpreter.EMPTY_MAP, + unboundedMap(STRING, PASSTHROUGH) + .comapFlatMap(map -> map.isEmpty() ? DataResult.success(Unit.INSTANCE) : DataResult.error(() -> "Expected an empty map"), u -> Map.of()) + ); + + /** + * Represents a {@link Unit} value, but only as an empty list. + */ + Structure EMPTY_LIST = keyed( + Interpreter.EMPTY_LIST, + PASSTHROUGH.listOf() + .comapFlatMap(list -> list.isEmpty() ? DataResult.success(Unit.INSTANCE) : DataResult.error(() -> "Expected an empty list"), u -> List.of()) + ); + + /** + * {@return a structure representing integer values within a range} + * @param min the minimum value (inclusive) + * @param max the maximum value (inclusive) + */ + static Structure intInRange(int min, int max) { + return Structure.parametricallyKeyed(Interpreter.INT_IN_RANGE, Const.create(new Range<>(min, max)), app -> (Const) app) + .xmap(Const::unbox, Const::create); + } + + /** + * {@return a structure representing byte values within a range} + * @param min the minimum value (inclusive) + * @param max the maximum value (inclusive) + */ + static Structure byteInRange(byte min, byte max) { + return Structure.parametricallyKeyed(Interpreter.BYTE_IN_RANGE, Const.create(new Range<>(min, max)), app -> (Const) app) + .xmap(Const::unbox, Const::create); + } + + /** + * {@return a structure representing short values within a range} + * @param min the minimum value (inclusive) + * @param max the maximum value (inclusive) + */ + static Structure shortInRange(short min, short max) { + return Structure.parametricallyKeyed(Interpreter.SHORT_IN_RANGE, Const.create(new Range<>(min, max)), app -> (Const) app) + .xmap(Const::unbox, Const::create); + } + + /** + * {@return a structure representing long values within a range} + * @param min the minimum value (inclusive) + * @param max the maximum value (inclusive) + */ + static Structure longInRange(long min, long max) { + return Structure.parametricallyKeyed(Interpreter.LONG_IN_RANGE, Const.create(new Range<>(min, max)), app -> (Const) app) + .xmap(Const::unbox, Const::create); + } + + /** + * {@return a structure representing float values within a range} + * @param min the minimum value (inclusive) + * @param max the maximum value (inclusive) + */ + static Structure floatInRange(float min, float max) { + return Structure.parametricallyKeyed(Interpreter.FLOAT_IN_RANGE, Const.create(new Range<>(min, max)), app -> (Const) app) + .xmap(Const::unbox, Const::create); + } + + /** + * {@return a structure representing double values within a range} + * @param min the minimum value (inclusive) + * @param max the maximum value (inclusive) + */ + static Structure doubleInRange(double min, double max) { + return Structure.parametricallyKeyed(Interpreter.DOUBLE_IN_RANGE, Const.create(new Range<>(min, max)), app -> (Const) app) + .xmap(Const::unbox, Const::create); + } + + /** + * {@return a structure representing a type with finite possible values, each of which can be represented as a string} + * @param values provides the possible (ordered) values of the type + * @param representation converts a value to a string + * @param the type to represent + */ + static Structure stringRepresentable(Supplier values, Function representation) { + return Structure.parametricallyKeyed(Interpreter.STRING_REPRESENTABLE, StringRepresentation.ofArray(values, representation), app -> (Identity) app) + .xmap(i -> Identity.unbox(i).value(), Identity::new); + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/StructuredMapCodec.java b/src/main/java/dev/lukebemish/codecextras/structured/StructuredMapCodec.java new file mode 100644 index 0000000..666ab0a --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/StructuredMapCodec.java @@ -0,0 +1,134 @@ +package dev.lukebemish.codecextras.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.Lifecycle; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.MapLike; +import com.mojang.serialization.RecordBuilder; +import dev.lukebemish.codecextras.comments.CommentMapCodec; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; + +class StructuredMapCodec extends MapCodec { + private record Field(String name, MapCodec codec, RecordStructure.Key key, Function getter) {} + + private final List> fields; + private final Function creator; + + private StructuredMapCodec(List> fields, Function creator) { + this.fields = fields; + this.creator = creator; + } + + public interface Unboxer { + Codec unbox(App box); + } + + public static DataResult> of(List> fields, Function creator, Interpreter interpreter, Unboxer unboxer) { + var mapCodecFields = new ArrayList>(); + for (var field : fields) { + DataResult> result = recordSingleField(field, mapCodecFields, interpreter, unboxer); + if (result != null) return result; + } + return DataResult.success(new StructuredMapCodec<>(mapCodecFields, creator)); + } + + private static @Nullable DataResult> recordSingleField(RecordStructure.Field field, ArrayList> mapCodecFields, Interpreter interpreter, Unboxer unboxer) { + var result = field.structure().interpret(interpreter); + if (result.error().isPresent()) { + return DataResult.error(result.error().orElseThrow().messageSupplier()); + } + Codec fieldCodec = unboxer.unbox(result.result().orElseThrow()); + boolean lenient = Annotation.get(field.structure().annotations(), Annotation.LENIENT).isPresent(); + MapCodec fieldMapCodec = Annotation.get(field.structure().annotations(), Annotation.COMMENT) + .map(comment -> CommentMapCodec.of(makeFieldCodec(fieldCodec, field, lenient), comment)) + .orElseGet(() -> makeFieldCodec(fieldCodec, field, lenient)); + mapCodecFields.add(new StructuredMapCodec.Field<>(field.name(), fieldMapCodec, field.key(), field.getter())); + return null; + } + + private static MapCodec makeFieldCodec(Codec fieldCodec, RecordStructure.Field field, boolean lenient) { + return field.missingBehavior().map(behavior -> (lenient ? fieldCodec.lenientOptionalFieldOf(field.name()) : fieldCodec.optionalFieldOf(field.name())).xmap( + optional -> optional.orElseGet(behavior.missing()), + value -> behavior.predicate().test(value) ? Optional.of(value) : Optional.empty() + )).orElseGet(() -> fieldCodec.fieldOf(field.name())); + } + + @Override + public Stream keys(DynamicOps ops) { + return fields.stream().flatMap(f -> f.codec().keys(ops)); + } + + @Override + public DataResult decode(DynamicOps ops, MapLike input) { + var builder = RecordStructure.Container.builder(); + boolean isPartial = false; + boolean isError = false; + Lifecycle errorLifecycle = Lifecycle.stable(); + Supplier errorMessage = null; + for (var field : fields) { + DataResult result = singleField(ops, input, field, builder); + if (result.isError()) { + if (result.hasResultOrPartial()) { + isPartial = true; + } + isError = true; + errorLifecycle = errorLifecycle.add(result.lifecycle()); + if (errorMessage == null) { + errorMessage = result.error().orElseThrow().messageSupplier(); + } else { + var oldMessage = errorMessage; + errorMessage = () -> oldMessage.get() + ": " + result.error().orElseThrow().messageSupplier().get(); + } + } + } + if (isError) { + if (isPartial) { + return DataResult.error(errorMessage, creator.apply(builder.build()), errorLifecycle); + } else { + return DataResult.error(errorMessage, errorLifecycle); + } + } else { + return DataResult.success(creator.apply(builder.build())); + } + } + + private static DataResult singleField(DynamicOps ops, MapLike input, Field field, RecordStructure.Container.Builder builder) { + var key = field.key(); + var codec = field.codec(); + var result = codec.decode(ops, input); + if (result.hasResultOrPartial()) { + builder.add(key, result.resultOrPartial().orElseThrow()); + } + return result; + } + + @Override + public RecordBuilder encode(A input, DynamicOps ops, RecordBuilder prefix) { + for (var field : fields) { + prefix = encodeSingleField(input, ops, prefix, field); + } + return prefix; + } + + private RecordBuilder encodeSingleField(A input, DynamicOps ops, RecordBuilder prefix, Field field) { + var codec = field.codec(); + var value = field.getter().apply(input); + return codec.encode(value, ops, prefix); + } + + @Override + public String toString() { + var fields = this.fields.stream().map(f -> f.codec().toString()).reduce((a, b) -> a + ", " + b).orElse(""); + return "StructuredMapCodec[" + fields + "]"; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/package-info.java b/src/main/java/dev/lukebemish/codecextras/structured/package-info.java new file mode 100644 index 0000000..46903d8 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/package-info.java @@ -0,0 +1,27 @@ +/** + * CodecExtras' structure API allows you to create any number of representations of the structure of a type of data from + * a single shared representation. + *

+ * The core piece of this API is {@link dev.lukebemish.codecextras.structured.Structure}. + * If you define a {@code Structure} representing a data structure for some type {@code T}, you can then interpret + * that structure into any number of types parameterized by {@code T} dependent on that structure, say {@code F}, by + * using an appropriate {@link dev.lukebemish.codecextras.structured.Interpreter} of type {@code Interpreter}. + * For instance, you can turn a {@code Structure} into a {@link com.mojang.serialization.Codec} by using a + * {@link dev.lukebemish.codecextras.structured.CodecInterpreter}. + *

+ * CodecExtras' core module has a number of interpreter representations built in, including: + *

    + *
  • {@link dev.lukebemish.codecextras.structured.CodecInterpreter}, for creating a {@link com.mojang.serialization.Codec} + *
  • {@link dev.lukebemish.codecextras.structured.MapCodecInterpreter}, for creating a {@link com.mojang.serialization.MapCodec} + *
  • {@link dev.lukebemish.codecextras.structured.IdentityInterpreter}, which extracts the default value from a structure made up of optional components + *
  • {@link dev.lukebemish.codecextras.structured.schema.JsonSchemaInterpreter}, which creates a JSON schema describing how a structure would be (de)serialized by a {@link com.mojang.serialization.Codec} + *
+ * The interpreter system is extensible, so you can implement your own interpreters for your own types. The {@code codecextras-minecraft} + * module provides a number of interpreters for Minecraft-specific types, including stream codecs and config screens. + */ +@NullMarked +@ApiStatus.Experimental +package dev.lukebemish.codecextras.structured; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/lukebemish/codecextras/structured/schema/JsonSchemaInterpreter.java b/src/main/java/dev/lukebemish/codecextras/structured/schema/JsonSchemaInterpreter.java new file mode 100644 index 0000000..3039004 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/schema/JsonSchemaInterpreter.java @@ -0,0 +1,476 @@ +package dev.lukebemish.codecextras.structured.schema; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.Const; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.JsonOps; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.structured.Annotation; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Interpreter; +import dev.lukebemish.codecextras.structured.Key; +import dev.lukebemish.codecextras.structured.KeyStoringInterpreter; +import dev.lukebemish.codecextras.structured.Keys; +import dev.lukebemish.codecextras.structured.Keys2; +import dev.lukebemish.codecextras.structured.ParametricKeyedValue; +import dev.lukebemish.codecextras.structured.Range; +import dev.lukebemish.codecextras.structured.RecordStructure; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.types.Identity; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.SequencedMap; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; + +/** + * Creates a JSON schema for a given {@link Structure}. Note that interpreting a structure with this interpreter will + * require resolving any lazy aspects of the structure, such as bounds, so should not be done until that is safe + * @see #interpret(Structure) + */ +public class JsonSchemaInterpreter extends KeyStoringInterpreter { + private final CodecInterpreter codecInterpreter; + private final DynamicOps ops; + + public JsonSchemaInterpreter( + Keys keys, + Keys2, K1, K1> parametricKeys, + CodecInterpreter codecInterpreter, + DynamicOps ops + ) { + super(keys.join(Keys.builder() + .add(Interpreter.UNIT, new Holder<>(OBJECT.get())) + .add(Interpreter.BOOL, new Holder<>(BOOLEAN.get())) + .add(Interpreter.BYTE, new Holder<>(INTEGER.get())) + .add(Interpreter.SHORT, new Holder<>(INTEGER.get())) + .add(Interpreter.INT, new Holder<>(INTEGER.get())) + .add(Interpreter.LONG, new Holder<>(INTEGER.get())) + .add(Interpreter.FLOAT, new Holder<>(NUMBER.get())) + .add(Interpreter.DOUBLE, new Holder<>(NUMBER.get())) + .add(Interpreter.STRING, new Holder<>(STRING.get())) + .add(Interpreter.EMPTY_MAP, new Holder<>(() -> { + var json = OBJECT.get(); + json.add("additionalProperties", new JsonPrimitive(false)); + return json; + })) + .add(Interpreter.EMPTY_LIST, new Holder<>(() -> { + var json = ARRAY.get(); + json.add("prefixItems", new JsonArray()); + json.add("items", new JsonPrimitive(false)); + return json; + })) + .build() + ), parametricKeys.join(Keys2., K1, K1>builder() + .add(Interpreter.INT_IN_RANGE, numberInRange(INTEGER)) + .add(Interpreter.BYTE_IN_RANGE, numberInRange(INTEGER)) + .add(Interpreter.SHORT_IN_RANGE, numberInRange(INTEGER)) + .add(Interpreter.LONG_IN_RANGE, numberInRange(INTEGER)) + .add(Interpreter.FLOAT_IN_RANGE, numberInRange(NUMBER)) + .add(Interpreter.DOUBLE_IN_RANGE, numberInRange(NUMBER)) + .add(Interpreter.STRING_REPRESENTABLE, new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + var representation = StringRepresentation.unbox(parameter); + JsonArray oneOf = new JsonArray(); + for (var value : representation.values().get()) { + JsonObject object = new JsonObject(); + object.addProperty("const", representation.representation().apply(value)); + oneOf.add(object); + } + JsonObject schema = new JsonObject(); + schema.add("oneOf", oneOf); + return new Holder<>(schema); + } + }) + .build() + )); + this.codecInterpreter = codecInterpreter; + this.ops = ops; + } + + private static > ParametricKeyedValue>, Const.Mu> numberInRange(Supplier base) { + return new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + final var range = Const.unbox(parameter); + final var result = base.get(); + result.addProperty("minimum", range.min()); + result.addProperty("maximum", range.max()); + return new JsonSchemaInterpreter.Holder<>(result); + } + }; + } + + @Override + public JsonSchemaInterpreter with(Keys keys, Keys2, K1, K1> parametricKeys) { + return new JsonSchemaInterpreter( + keys().join(keys), + parametricKeys().join(parametricKeys), + this.codecInterpreter, this.ops + ); + } + + public JsonSchemaInterpreter() { + this( + Keys.builder().build(), + Keys2., K1, K1>builder().build(), + CodecInterpreter.create(), + JsonOps.INSTANCE + ); + } + + @Override + public
DataResult>> list(App single) { + var object = ARRAY.get(); + object.add("items", schemaValue(single)); + return DataResult.success(new Holder<>(object, definitions(single))); + } + + @Override + public DataResult> record(List> fields, Function creator) { + var object = OBJECT.get(); + var properties = new JsonObject(); + var required = new JsonArray(); + var definitions = new LinkedHashMap>(); + for (RecordStructure.Field field : fields) { + Supplier error = singleField(field, properties, required, definitions); + if (error != null) { + return DataResult.error(error); + } + } + object.add("properties", properties); + object.add("required", required); + return DataResult.success(new Holder<>(object, definitions)); + } + + private @Nullable Supplier singleField(RecordStructure.Field field, JsonObject properties, JsonArray required, Map> definitions) { + var partialResolt = field.structure().interpret(this); + if (partialResolt.isError()) { + return partialResolt.error().orElseThrow().messageSupplier(); + } + var fieldObject = copy(schemaValue(partialResolt.result().orElseThrow())); + definitions.putAll(definitions(partialResolt.result().orElseThrow())); + + var error = new Object() { + @Nullable Supplier value = null; + }; + + field.missingBehavior().ifPresentOrElse(missingBehavior -> { + var codec = codecInterpreter.interpret(field.structure()); + if (codec.error().isPresent()) { + error.value = codec.error().get().messageSupplier(); + return; + } + var defaultValue = missingBehavior.missing().get(); + var defaultValueResult = codec.result().orElseThrow().encodeStart(ops, defaultValue); + if (defaultValueResult.error().isPresent()) { + // If it cannot serialize the default value, we just don't report it -- it could be something like an Optional where the default value does not exist. + return; + } + fieldObject.add("default", defaultValueResult.result().orElseThrow()); + }, () -> required.add(field.name())); + + if (error.value != null) { + return error.value; + } + + properties.add(field.name(), fieldObject); + return null; + } + + @Override + public DataResult> flatXmap(App input, Function> to, Function> from) { + return DataResult.success(new Holder<>(schemaValue(input), definitions(input))); + } + + @Override + public DataResult> annotate(Structure input, Keys annotations) { + JsonObject schema; + SequencedMap> definitions; + var refName = Annotation.get(annotations, SchemaAnnotations.REUSE_KEY); + if (refName.isPresent()) { + schema = new JsonObject(); + var ref = refName.get(); + schema.addProperty("$ref", "#/$defs/"+ref); + definitions = new LinkedHashMap<>(); + definitions.put(ref, input); + } else { + var result = input.interpret(this); + if (result.error().isPresent()) { + return DataResult.error(result.error().get().messageSupplier()); + } + schema = copy(schemaValue(result.result().orElseThrow())); + definitions = new LinkedHashMap<>(definitions(result.result().orElseThrow())); + } + + Annotation.get(annotations, Annotation.PATTERN).ifPresent(pattern -> { + schema.addProperty("pattern", pattern); + }); + Annotation.get(annotations, Annotation.DESCRIPTION).or(() -> Annotation.get(annotations, Annotation.COMMENT)).ifPresent(comment -> { + schema.addProperty("description", comment); + }); + Annotation.get(annotations, Annotation.TITLE).ifPresent(comment -> { + schema.addProperty("title", comment); + }); + return DataResult.success(new Holder<>(schema, definitions)); + } + + @Override + public DataResult> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures) { + return keyStructure.interpret(this).flatMap(keySchemaApp -> { + var definitions = new LinkedHashMap<>(definitions(keySchemaApp)); + var keySchema = schemaValue(keySchemaApp); + JsonObject out = new JsonObject(); + JsonObject properties = new JsonObject(); + JsonArray required = new JsonArray(); + + required.add(key); + properties.add(key, keySchema); + + JsonArray allOf = new JsonArray(); + var keyCodecResult = codecInterpreter.interpret(keyStructure); + if (keyCodecResult.error().isPresent()) { + return DataResult.error(keyCodecResult.error().get().messageSupplier()); + } + var keyCodec = keyCodecResult.result().orElseThrow(); + for (A entryKey : keys.get()) { + var result = structures.apply(entryKey).flatMap(it -> it.interpret(this)); + if (result.error().isPresent()) { + return DataResult.error(result.error().get().messageSupplier()); + } + var entrySchema = schemaValue(result.result().orElseThrow()); + definitions.putAll(definitions(result.result().orElseThrow())); + var entryValueResult = keyCodec.encodeStart(JsonOps.INSTANCE, entryKey); + if (entryValueResult.error().isPresent()) { + return DataResult.error(entryValueResult.error().get().messageSupplier()); + } + var entryValue = entryValueResult.result().orElseThrow(); + var ifObj = new JsonObject(); + var ifProperties = new JsonObject(); + var keyProperty = new JsonObject(); + keyProperty.add("const", entryValue); + ifProperties.add(key, keyProperty); + ifObj.add("properties", ifProperties); + var obj = new JsonObject(); + obj.add("if", ifObj); + obj.add("then", entrySchema); + allOf.add(obj); + } + + out.add("properties", properties); + out.add("required", required); + out.add("allOf", allOf); + return DataResult.success(new Holder<>(out, definitions)); + }); + } + + @Override + public DataResult> bounded(Structure input, Supplier> values) { + return codecInterpreter.interpret(input).flatMap(codec -> { + var types = new JsonArray(); + for (var value : values.get()) { + var result = codec.encodeStart(ops, value); + if (result.error().isPresent()) { + return DataResult.error(result.error().get().messageSupplier()); + } + types.add(result.result().orElseThrow()); + } + return input.interpret(this).flatMap(outer -> { + var schema = copy(schemaValue(outer)); + schema.add("enum", types); + return DataResult.success(new Holder<>(schema, definitions(outer))); + }); + }); + } + + @Override + public DataResult>> unboundedMap(App key, App value) { + var schema = OBJECT.get(); + var definitions = new LinkedHashMap>(); + var keyJson = schemaValue(key); + if (keyJson.has("pattern") && keyJson.get("pattern").isJsonPrimitive()) { + // if the key has a pattern, we use "patternProperties" + var patternProperties = new JsonObject(); + patternProperties.add(keyJson.get("pattern").getAsString(), schemaValue(value)); + schema.add("patternProperties", patternProperties); + } else { + schema.add("additionalProperties", schemaValue(value)); + } + definitions.putAll(definitions(value)); + definitions.putAll(definitions(key)); + return DataResult.success(new Holder<>(schema, definitions)); + } + + @Override + public DataResult>> either(App left, App right) { + var schema = new JsonObject(); + var anyOf = new JsonArray(); + var definitions = new LinkedHashMap>(); + var leftSchema = schemaValue(left); + var rightSchema = schemaValue(right); + definitions.putAll(definitions(left)); + definitions.putAll(definitions(right)); + anyOf.add(leftSchema); + anyOf.add(rightSchema); + schema.add("anyOf", anyOf); + return DataResult.success(new Holder<>(schema, definitions)); + } + + @Override + public DataResult>> xor(App left, App right) { + var schema = new JsonObject(); + var oneOf = new JsonArray(); + var definitions = new LinkedHashMap>(); + var leftSchema = schemaValue(left); + var rightSchema = schemaValue(right); + definitions.putAll(definitions(left)); + definitions.putAll(definitions(right)); + oneOf.add(leftSchema); + oneOf.add(rightSchema); + schema.add("oneOf", oneOf); + return DataResult.success(new Holder<>(schema, definitions)); + } + + @Override + public DataResult>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures) { + var definitions = new LinkedHashMap>(); + return codecInterpreter.interpret(keyStructure).flatMap(keyCodec -> { + var schema = OBJECT.get(); + for (var key : keys.get()) { + var keyValue = keyCodec.encodeStart(ops, key).flatMap(ops::getStringValue); + if (keyValue.error().isPresent()) { + return DataResult.error(keyValue.error().get().messageSupplier()); + } + var valueSchema = valueStructures.apply(key).flatMap(it -> it.interpret(this)); + if (valueSchema.error().isPresent()) { + return DataResult.error(valueSchema.error().get().messageSupplier()); + } + schema.add(keyValue.result().orElseThrow(), schemaValue(valueSchema.result().orElseThrow())); + definitions.putAll(definitions(valueSchema.result().orElseThrow())); + } + return DataResult.success(schema); + }).map(schema -> new Holder<>(schema, definitions)); + } + + private static JsonObject schemaValue(App box) { + return Holder.unbox(box).jsonObject; + } + + private static SequencedMap> definitions(App box) { + return Holder.unbox(box).definition; + } + + public DataResult interpret(Structure structure) { + return structure.interpret(this).flatMap(holder -> { + var object = copy(schemaValue(holder)); + var definitions = new LinkedHashMap<>(definitions(holder)); + var defsObject = new JsonObject(); + while (true) { + final var entry = definitions.pollFirstEntry(); + if (entry == null) break; + if (defsObject.has(entry.getKey())) { + continue; + } + var result = entry.getValue().interpret(this); + if (result.error().isPresent()) { + return DataResult.error(result.error().get().messageSupplier()); + } + var schema = schemaValue(result.result().orElseThrow()); + defsObject.add(entry.getKey(), schema); + definitions.putAll(definitions(result.result().orElseThrow())); + } + if (!defsObject.isEmpty()) { + object.add("$defs", defsObject); + } + return DataResult.success(object); + }); + } + + public static final Key KEY = Key.create("JsonSchemaInterpreter"); + + @Override + public Stream> keyConsumers() { + return Stream.of( + new KeyConsumer() { + @Override + public Key key() { + return KEY; + } + + @Override + public App convert(App input) { + return input; + } + } + ); + } + + public record Holder(JsonObject jsonObject, SequencedMap> definition) implements App { + private static final SequencedMap> NO_DEFINITIONS = Collections.unmodifiableSequencedMap(new LinkedHashMap<>()); + + public Holder(JsonObject object) { + this(object, NO_DEFINITIONS); + } + + public Holder(Supplier objectCreator) { + this(objectCreator.get()); + } + + public static final class Mu implements K1 { private Mu() {} } + + static Holder unbox(App box) { + return (Holder) box; + } + } + + private JsonObject copy(JsonObject object) { + JsonObject copy = new JsonObject(); + for (String key : object.keySet()) { + copy.add(key, object.get(key)); + } + return copy; + } + + public static final Supplier OBJECT = () -> { + JsonObject object = new JsonObject(); + object.addProperty("type", "object"); + return object; + }; + public static final Supplier NUMBER = () -> { + JsonObject object = new JsonObject(); + object.addProperty("type", "number"); + return object; + }; + public static final Supplier STRING = () -> { + JsonObject object = new JsonObject(); + object.addProperty("type", "string"); + return object; + }; + public static final Supplier BOOLEAN = () -> { + JsonObject object = new JsonObject(); + object.addProperty("type", "boolean"); + return object; + }; + public static final Supplier INTEGER = () -> { + JsonObject object = new JsonObject(); + object.addProperty("type", "integer"); + return object; + }; + public static final Supplier ARRAY = () -> { + JsonObject object = new JsonObject(); + object.addProperty("type", "array"); + return object; + }; +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/schema/SchemaAnnotations.java b/src/main/java/dev/lukebemish/codecextras/structured/schema/SchemaAnnotations.java new file mode 100644 index 0000000..95b7fb6 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/schema/SchemaAnnotations.java @@ -0,0 +1,13 @@ +package dev.lukebemish.codecextras.structured.schema; + +import dev.lukebemish.codecextras.structured.Key; + +public final class SchemaAnnotations { + private SchemaAnnotations() {} + + /** + * The key used with {@code $ref} and {@code $defs} to reuse this schema throughout a root schema. + * Only one schema in a nested structure should have this key. + */ + public static final Key REUSE_KEY = Key.create("reuseKey"); +} diff --git a/src/main/java/dev/lukebemish/codecextras/structured/schema/package-info.java b/src/main/java/dev/lukebemish/codecextras/structured/schema/package-info.java new file mode 100644 index 0000000..f259ed8 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/structured/schema/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Experimental +package dev.lukebemish.codecextras.structured.schema; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/lukebemish/codecextras/types/ConstantFirst.java b/src/main/java/dev/lukebemish/codecextras/types/ConstantFirst.java new file mode 100644 index 0000000..e67f413 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/ConstantFirst.java @@ -0,0 +1,14 @@ +package dev.lukebemish.codecextras.types; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.kinds.K2; + +public record ConstantFirst(App value) implements App2, A, B> { + public static final class Mu implements K2 { private Mu() {} } + + public static ConstantFirst unbox(App2, A, B> box) { + return (ConstantFirst) box; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/ConstantSecond.java b/src/main/java/dev/lukebemish/codecextras/types/ConstantSecond.java new file mode 100644 index 0000000..f5883cb --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/ConstantSecond.java @@ -0,0 +1,14 @@ +package dev.lukebemish.codecextras.types; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.kinds.K2; + +public record ConstantSecond(App value) implements App2, A, B> { + public static final class Mu implements K2 { private Mu() {} } + + public static ConstantSecond unbox(App2, A, B> box) { + return (ConstantSecond) box; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/First.java b/src/main/java/dev/lukebemish/codecextras/types/First.java new file mode 100644 index 0000000..77a78a8 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/First.java @@ -0,0 +1,14 @@ +package dev.lukebemish.codecextras.types; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.kinds.K2; + +public record First(App2 value) implements App, A> { + public static final class Mu implements K1 { private Mu() {} } + + public static First unbox(App, A> box) { + return (First) box; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/Flip.java b/src/main/java/dev/lukebemish/codecextras/types/Flip.java new file mode 100644 index 0000000..c7f3a8e --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/Flip.java @@ -0,0 +1,19 @@ +package dev.lukebemish.codecextras.types; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; + +/** + * Allows the order of arguments to {@link App} to be "flipped", where {@code App F T = F[T]} becomes {@code App (Flip T) F = F[T]} + * when boxed. + * @param value the boxed value + * @param the type function to flip + * @param the type parameter for {@link F} + */ +public record Flip(App value) implements App, F> { + public static final class Mu implements K1 { private Mu() {} } + + public static Flip unbox(App, M> box) { + return (Flip) box; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/Identity.java b/src/main/java/dev/lukebemish/codecextras/types/Identity.java new file mode 100644 index 0000000..fc3f7cb --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/Identity.java @@ -0,0 +1,41 @@ +package dev.lukebemish.codecextras.types; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.Applicative; +import com.mojang.datafixers.kinds.K1; +import java.util.function.Function; + +/** + * Represents the type function {@code T -> T} as an {@link App} type. + * @param value the boxed value + * @param the type represented + */ +public record Identity(T value) implements App { + public static final class Mu implements K1 { private Mu() {} } + + public static Identity unbox(App input) { + return (Identity) input; + } + + enum Instance implements Applicative { + INSTANCE; + + public static final class Mu implements Applicative.Mu { private Mu() {} } + + @Override + public App point(A a) { + return new Identity<>(a); + } + + @Override + public Function, App> lift1(App> function) { + var f = unbox(function).value(); + return app -> new Identity<>(f.apply(unbox(app).value())); + } + + @Override + public App map(Function func, App ts) { + return new Identity<>(func.apply(unbox(ts).value())); + } + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/Preparable.java b/src/main/java/dev/lukebemish/codecextras/types/Preparable.java new file mode 100644 index 0000000..4e45c2f --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/Preparable.java @@ -0,0 +1,33 @@ +package dev.lukebemish.codecextras.types; + +import com.google.common.base.Suppliers; +import java.util.function.Supplier; + +public interface Preparable extends PreparableView { + void prepare(); + + static Preparable memoize(Supplier supplier) { + var memoized = Suppliers.memoize(supplier::get); + return new Preparable<>() { + private volatile boolean prepared = false; + + @Override + public boolean isReady() { + return prepared; + } + + @Override + public T get() { + if (!prepared) { + throw new IllegalStateException("Not ready!"); + } + return memoized.get(); + } + + @Override + public void prepare() { + prepared = true; + } + }; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/PreparableView.java b/src/main/java/dev/lukebemish/codecextras/types/PreparableView.java new file mode 100644 index 0000000..2ac7702 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/PreparableView.java @@ -0,0 +1,7 @@ +package dev.lukebemish.codecextras.types; + +import java.util.function.Supplier; + +public interface PreparableView extends Supplier { + boolean isReady(); +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/Second.java b/src/main/java/dev/lukebemish/codecextras/types/Second.java new file mode 100644 index 0000000..a01e077 --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/Second.java @@ -0,0 +1,14 @@ +package dev.lukebemish.codecextras.types; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.kinds.K2; + +public record Second(App2 value) implements App, B> { + public static final class Mu implements K1 { private Mu() {} } + + public static Second unbox(App, B> box) { + return (Second) box; + } +} diff --git a/src/main/java/dev/lukebemish/codecextras/types/package-info.java b/src/main/java/dev/lukebemish/codecextras/types/package-info.java new file mode 100644 index 0000000..f0b7e5c --- /dev/null +++ b/src/main/java/dev/lukebemish/codecextras/types/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Experimental +package dev.lukebemish.codecextras.types; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..edf4411 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,35 @@ +module dev.lukebemish.codecextras { + uses dev.lukebemish.codecextras.companion.AlternateCompanionRetriever; + + requires static autoextension; + requires static com.electronwill.nightconfig.core; + requires static com.electronwill.nightconfig.toml; + requires com.google.common; + requires com.google.gson; + requires datafixerupper; + requires it.unimi.dsi.fastutil; + requires static jankson; + requires static org.jetbrains.annotations; + requires static org.jspecify; + requires static org.objectweb.asm; + requires static org.slf4j; + + exports dev.lukebemish.codecextras; + exports dev.lukebemish.codecextras.comments; + exports dev.lukebemish.codecextras.companion; + + exports dev.lukebemish.codecextras.compat.jankson; + exports dev.lukebemish.codecextras.compat.nightconfig; + + exports dev.lukebemish.codecextras.config; + exports dev.lukebemish.codecextras.extension; + exports dev.lukebemish.codecextras.mutable; + exports dev.lukebemish.codecextras.polymorphic; + exports dev.lukebemish.codecextras.record; + exports dev.lukebemish.codecextras.repair; + + exports dev.lukebemish.codecextras.structured; + exports dev.lukebemish.codecextras.structured.schema; + + exports dev.lukebemish.codecextras.types; +} diff --git a/src/main/resources/META-INF/MANIFEST.MF b/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 0000000..5e57fdd --- /dev/null +++ b/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1 @@ +FMLModType: LIBRARY diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 1e9e620..7efb632 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -2,5 +2,6 @@ "schemaVersion": 1, "id": "dev_lukebemish_codecextras", "version": "${version}", - "name": "CodecExtras" + "name": "CodecExtras", + "license": "LGPL-3.0-only" } diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/companion/RegistryOpsCompanionRetriever.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/companion/RegistryOpsCompanionRetriever.java new file mode 100644 index 0000000..c06ec13 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/companion/RegistryOpsCompanionRetriever.java @@ -0,0 +1,44 @@ +package dev.lukebemish.codecextras.minecraft.companion; + +import com.google.auto.service.AutoService; +import com.mojang.serialization.DynamicOps; +import dev.lukebemish.codecextras.companion.AccompaniedOps; +import dev.lukebemish.codecextras.companion.AlternateCompanionRetriever; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.util.Optional; +import net.minecraft.resources.DelegatingOps; +import net.minecraft.resources.RegistryOps; + +@AutoService(AlternateCompanionRetriever.class) +public class RegistryOpsCompanionRetriever implements AlternateCompanionRetriever { + static final MethodHandle DELEGATE_FIELD; + + static { + try { + var lookup = MethodHandles.privateLookupIn(DelegatingOps.class, MethodHandles.lookup()); + DELEGATE_FIELD = lookup.unreflectGetter(DelegatingOps.class.getDeclaredField("delegate")); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + @Override + public Optional> locateCompanionDelegate(DynamicOps ops) { + if (ops instanceof RegistryOps registryOps) { + try { + return Optional.of((AccompaniedOps) DELEGATE_FIELD.invoke(registryOps)); + } catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + } + return Optional.empty(); + } + + @Override + public DynamicOps delegate(DynamicOps ops, AccompaniedOps delegate) { + var registryOps = (RegistryOps) ops; + return registryOps.withParent(delegate); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/companion/package-info.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/companion/package-info.java new file mode 100644 index 0000000..9fc339b --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/companion/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.lukebemish.codecextras.minecraft.companion; + +import org.jspecify.annotations.NullMarked; diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/CodecExtrasRegistries.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/CodecExtrasRegistries.java new file mode 100644 index 0000000..af206c4 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/CodecExtrasRegistries.java @@ -0,0 +1,49 @@ +package dev.lukebemish.codecextras.minecraft.structured; + +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.types.Preparable; +import dev.lukebemish.codecextras.types.PreparableView; +import java.util.Objects; +import java.util.ServiceLoader; +import net.minecraft.core.Registry; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.ApiStatus; + +public class CodecExtrasRegistries { + private static final String NAMESPACE = "codecextras_minecraft"; + private static final RegistryRegistrar.RegistriesImpl REGISTRIES_IMPL = new RegistryRegistrar.RegistriesImpl(); + + public static final ResourceKey>>> DATA_COMPONENT_STRUCTURES = ResourceKey.createRegistryKey(ResourceLocation.fromNamespaceAndPath(NAMESPACE, "data_component_type")); + public static final Registries REGISTRIES = REGISTRIES_IMPL; + + static { + // This MUST be the last static initializer in this class -- the registry registrar may depend on the keys defined earlier on + ServiceLoader.load(RegistryRegistrar.class).stream().map(ServiceLoader.Provider::get).forEach(registryRegistrar -> registryRegistrar.setup(REGISTRIES_IMPL)); + } + + public abstract sealed static class Registries { + public abstract PreparableView>>> dataComponentStructures(); + } + + @ApiStatus.Internal + public interface RegistryRegistrar { + @ApiStatus.Internal + final class RegistriesImpl extends Registries { + private RegistriesImpl() {} + + @Override + public PreparableView>>> dataComponentStructures() { + return dataComponentStructures; + } + + @SuppressWarnings("unchecked") + public final Preparable>>> dataComponentStructures = Preparable.memoize(() -> + (Registry>>) Objects.requireNonNull(BuiltInRegistries.REGISTRY.getValue(CodecExtrasRegistries.DATA_COMPONENT_STRUCTURES.location()), "Registry does not exist")); + } + + void setup(RegistriesImpl registries); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftInterpreters.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftInterpreters.java new file mode 100644 index 0000000..8fbfb83 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftInterpreters.java @@ -0,0 +1,75 @@ +package dev.lukebemish.codecextras.minecraft.structured; + +import com.mojang.datafixers.kinds.K1; +import dev.lukebemish.codecextras.stream.structured.StreamCodecInterpreter; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Keys; +import dev.lukebemish.codecextras.structured.Keys2; +import dev.lukebemish.codecextras.structured.MapCodecInterpreter; +import dev.lukebemish.codecextras.structured.ParametricKeyedValue; +import dev.lukebemish.codecextras.structured.schema.JsonSchemaInterpreter; +import java.util.List; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; + +public final class MinecraftInterpreters { + private MinecraftInterpreters() {} + + public static final Keys MAP_CODEC_KEYS = Keys.builder() + .build(); + + public static final Keys2, K1, K1> MAP_CODEC_PARAMETRIC_KEYS = Keys2., K1, K1>builder() + .build(); + + public static final Keys CODEC_KEYS = Keys.builder() + .build(); + + public static final Keys2, K1, K1> CODEC_PARAMETRIC_KEYS = Keys2., K1, K1>builder() + .build(); + + public static final CodecInterpreter CODEC_INTERPRETER = CodecInterpreter.create().with( + CODEC_KEYS, + MAP_CODEC_KEYS, + CODEC_PARAMETRIC_KEYS, + MAP_CODEC_PARAMETRIC_KEYS + ); + + public static final MapCodecInterpreter MAP_CODEC_INTERPRETER = MapCodecInterpreter.create().with( + CODEC_KEYS, + MAP_CODEC_KEYS, + CODEC_PARAMETRIC_KEYS, + MAP_CODEC_PARAMETRIC_KEYS + ); + + public static final Keys, Object> FRIENDLY_STREAM_KEYS = Keys., Object>builder() + .build(); + + public static final Keys2>, K1, K1> FRIENDLY_STREAM_PARAMETRIC_KEYS = Keys2.>, K1, K1>builder() + .build(); + + public static final StreamCodecInterpreter FRIENDLY_STREAM_CODEC_INTERPRETER = new StreamCodecInterpreter<>( + StreamCodecInterpreter.FRIENDLY_BYTE_BUF_KEY, + FRIENDLY_STREAM_KEYS, + FRIENDLY_STREAM_PARAMETRIC_KEYS + ); + + public static final Keys, Object> REGISTRY_STREAM_KEYS = Keys., Object>builder() + .build(); + + public static final Keys2>, K1, K1> REGISTRY_STREAM_PARAMETRIC_KEYS = Keys2.>, K1, K1>builder() + .build(); + + public static final StreamCodecInterpreter REGISTRY_STREAM_CODEC_INTERPRETER = new StreamCodecInterpreter<>( + StreamCodecInterpreter.REGISTRY_FRIENDLY_BYTE_BUF_KEY, + List.of(FRIENDLY_STREAM_CODEC_INTERPRETER), + REGISTRY_STREAM_KEYS, + REGISTRY_STREAM_PARAMETRIC_KEYS + ); + + public static final Keys JSON_SCHEMA_KEYS = Keys.builder() + .build(); + + // TODO: Add regex for schemas + public static final Keys2, K1, K1> JSON_SCHEMA_PARAMETRIC_KEYS = Keys2., K1, K1>builder() + .build(); +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftKeys.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftKeys.java new file mode 100644 index 0000000..a00b8cb --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftKeys.java @@ -0,0 +1,138 @@ +package dev.lukebemish.codecextras.minecraft.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import dev.lukebemish.codecextras.structured.Key; +import dev.lukebemish.codecextras.structured.Key2; +import dev.lukebemish.codecextras.types.Identity; +import java.util.Map; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.TagKey; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +public final class MinecraftKeys { + public static Key, Object>> VALUE_MAP = Key.create("value_map"); + public static Key DATA_COMPONENT_MAP = Key.create("data_component_map"); + public static Key DATA_COMPONENT_PATCH = Key.create("data_component_patch"); + public static Key2 FALLBACK_DATA_COMPONENT_TYPE = Key2.create("data_component_type"); + + private MinecraftKeys() { + } + + public static final Key RESOURCE_LOCATION = Key.create("resource_location"); + public static final Key ARGB_COLOR = Key.create("argb_color"); + public static final Key RGB_COLOR = Key.create("rgb_color"); + + public static final Key> ITEM = Key.create("item_non_air"); + public static final Key OPTIONAL_ITEM_STACK = Key.create("optional_item_stack"); + public static final Key ITEM_STACK = Key.create("item_stack"); + public static final Key STRICT_ITEM_STACK = Key.create("strict_item_stack"); + public static final Key SINGLE_ITEM_STACK = Key.create("single_item_item_stack"); + public static final Key STRICT_SINGLE_ITEM_STACK = Key.create("strict_single_item_item_stack"); + + public record DataComponentTypeHolder(DataComponentType value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static DataComponentTypeHolder unbox(App box) { + return (DataComponentTypeHolder) box; + } + } + + public record ResourceKeyHolder(ResourceKey value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static ResourceKeyHolder unbox(App box) { + return (ResourceKeyHolder) box; + } + } + + public record TagKeyHolder(TagKey value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static TagKeyHolder unbox(App box) { + return (TagKeyHolder) box; + } + } + + public record HolderSetHolder(HolderSet value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static HolderSetHolder unbox(App box) { + return (HolderSetHolder) box; + } + } + + public record RegistryKeyHolder( + ResourceKey> value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static RegistryKeyHolder unbox(App box) { + return (RegistryKeyHolder) box; + } + } + + public record RegistryHolder(Registry value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static RegistryHolder unbox(App box) { + return (RegistryHolder) box; + } + } + + public record HolderHolder(Holder value) implements App { + public static final class Mu implements K1 { + private Mu() { + } + } + + public static HolderHolder unbox(App box) { + return (HolderHolder) box; + } + } + + public static final Key> DATA_COMPONENT_PATCH_KEY = Key.create("data_component_patch_key"); + + public record DataComponentPatchKey(DataComponentType type, boolean removes) {} + + public static final Key2 RESOURCE_KEY = Key2.create("resource_key"); + /** + * A key for structures handling a {@link Holder}, using an ID when handling non-permanent data. + */ + public static final Key2 ORDERED_HOLDER = Key2.create("holder"); + /** + * A key for structures handling a {@link Holder}, which does not use an ID. + */ + public static final Key2 UNORDERED_HOLDER = Key2.create("holder"); + + public static final Key2 TAG_KEY = Key2.create("tag_key"); + + public static final Key2 HASHED_TAG_KEY = Key2.create("#tag_key"); + + public static final Key2 HOMOGENOUS_LIST = Key2.create("homogenous_list"); +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftStructures.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftStructures.java new file mode 100644 index 0000000..fac8b4f --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/MinecraftStructures.java @@ -0,0 +1,426 @@ +package dev.lukebemish.codecextras.minecraft.structured; + +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.DataResult; +import dev.lukebemish.codecextras.stream.structured.StreamCodecInterpreter; +import dev.lukebemish.codecextras.structured.Annotation; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Keys; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.structured.schema.SchemaAnnotations; +import dev.lukebemish.codecextras.types.Flip; +import dev.lukebemish.codecextras.types.Identity; +import io.netty.handler.codec.DecoderException; +import io.netty.handler.codec.EncoderException; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryCodecs; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.component.TypedDataComponent; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.TagKey; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.jspecify.annotations.Nullable; + +public final class MinecraftStructures { + private MinecraftStructures() { + } + + public static final Structure RESOURCE_LOCATION = Structure.keyed( + MinecraftKeys.RESOURCE_LOCATION, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(ResourceLocation.CODEC))) + .add(StreamCodecInterpreter.FRIENDLY_BYTE_BUF_KEY, new Flip<>(new StreamCodecInterpreter.Holder<>(ResourceLocation.STREAM_CODEC.cast()))) + .build(), + Structure.STRING.flatXmap(ResourceLocation::read, rl -> DataResult.success(rl.toString())) + .annotate(Annotation.PATTERN, "^([a-z0-9_.-]+:)?[a-z0-9_/.-]+$") + ); + + public static final Structure ARGB_COLOR = Structure.keyed( + MinecraftKeys.ARGB_COLOR, + Structure.INT + ); + + public static final Structure RGB_COLOR = Structure.keyed( + MinecraftKeys.RGB_COLOR, + Structure.INT + ); + + private static final Structure, Object>> DATA_COMPONENT_VALUE_MAP_FALLBACK = resourceKey(Registries.DATA_COMPONENT_TYPE) + .>flatXmap(key -> { + var type = BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(key); + if (type == null) { + return DataResult.error(() -> "Unknown data component type: " + key); + } + return DataResult.success(type); + }, type -> { + var location = BuiltInRegistries.DATA_COMPONENT_TYPE.getResourceKey(type); + if (location.isPresent()) { + return DataResult.success(location.orElseThrow()); + } + return DataResult.error(() -> "Data component type " + type + " is not registered"); + }) + .dispatchedMap(() -> BuiltInRegistries.DATA_COMPONENT_TYPE.stream().collect(Collectors.toSet()), MinecraftStructures::dataComponentTypeStructure); + + public static final Structure, Object>> DATA_COMPONENT_VALUE_MAP = Structure.keyed( + MinecraftKeys.VALUE_MAP, + Keys., Object>>, K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(DataComponentType.VALUE_MAP_CODEC))) + .build(), + DATA_COMPONENT_VALUE_MAP_FALLBACK + ); + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static final Structure DATA_COMPONENT_MAP = Structure.keyed( + MinecraftKeys.DATA_COMPONENT_MAP, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(DataComponentMap.CODEC))) + .build(), + DATA_COMPONENT_VALUE_MAP.xmap(values -> { + var builder = DataComponentMap.builder(); + values.forEach((type, value) -> builder.set((DataComponentType) type, value)); + return builder.build(); + }, dataComponentMap -> dataComponentMap.stream().collect(Collectors.toMap(TypedDataComponent::type, TypedDataComponent::value))) + ); + + public static final Structure> DATA_COMPONENT_PATCH_KEY = Structure.keyed( + MinecraftKeys.DATA_COMPONENT_PATCH_KEY, + Structure.STRING + .>flatXmap(string -> { + boolean removes = string.startsWith("!"); + string = removes ? string.substring(1) : string; + return ResourceLocation.read(string).flatMap(rl -> { + var type = BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(rl); + if (type == null) { + return DataResult.error(() -> "Unknown data component type: " + rl); + } + return DataResult.success(new MinecraftKeys.DataComponentPatchKey<>(type, removes)); + }); + }, key -> { + var rl = BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(key.type()); + if (rl == null) { + return DataResult.error(() -> "Unknown data component type: " + key.type()); + } + return DataResult.success((key.removes() ? "!" : "") + rl); + }) + .bounded(MinecraftStructures::possibleDataComponentPatchKeys) + .annotate(Annotation.PATTERN, "^[!]?([a-z0-9_.-]+:)?[a-z0-9_/.-]+$") + ); + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static final Structure DATA_COMPONENT_PATCH = Structure.keyed( + MinecraftKeys.DATA_COMPONENT_PATCH, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(DataComponentPatch.CODEC))) + .add(StreamCodecInterpreter.REGISTRY_FRIENDLY_BYTE_BUF_KEY, new Flip<>(new StreamCodecInterpreter.Holder<>(DataComponentPatch.STREAM_CODEC))) + .build(), + DATA_COMPONENT_PATCH_KEY + .dispatchedUnboundedMap(MinecraftStructures::possibleDataComponentPatchKeys, MinecraftStructures::dataComponentPatchValueCodec) + .xmap(map -> { + var builder = DataComponentPatch.builder(); + map.forEach((key, value) -> { + if (key.removes()) { + builder.remove(key.type()); + } else { + builder.set((DataComponentType) key.type(), value); + } + }); + return builder.build(); + }, patches -> patches.entrySet().stream().collect(Collectors.toMap(entry -> { + var key = entry.getKey(); + var removes = entry.getValue().isEmpty(); + return new MinecraftKeys.DataComponentPatchKey<>(key, removes); + }, entry -> { + if (entry.getValue().isEmpty()) { + return Unit.INSTANCE; + } + return entry.getValue().get(); + }))) + ); + + @SuppressWarnings("deprecation") + public static final Structure> ITEM = Structure.keyed( + MinecraftKeys.ITEM, + Keys.>, K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(Item.CODEC))) + .build(), + registryOrderedHolder(BuiltInRegistries.ITEM) + .validate(holder -> holder.is(Items.AIR.builtInRegistryHolder()) ? DataResult.error(() -> "Item must not be minecraft:air") : DataResult.success(holder)) + ); + + public static final Structure ITEM_STACK = Structure.lazyInitialized(() -> Structure.keyed( + MinecraftKeys.ITEM_STACK, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(ItemStack.CODEC))) + .add(StreamCodecInterpreter.REGISTRY_FRIENDLY_BYTE_BUF_KEY, new Flip<>(new StreamCodecInterpreter.Holder<>(ItemStack.STREAM_CODEC))) + .build(), + Structure.record(builder -> { + var item = builder.add("id", ITEM, ItemStack::getItemHolder); + var count = builder.addOptional("count", Structure.intInRange(1, 99), ItemStack::getCount, () -> 1); + var patch = builder.addOptional("components", DATA_COMPONENT_PATCH, ItemStack::getComponentsPatch, () -> DataComponentPatch.EMPTY); + return container -> new ItemStack( + item.apply(container), + count.apply(container), + patch.apply(container) + ); + }) + )); + + public static final Structure SINGLE_ITEM_STACK = Structure.lazyInitialized(() -> Structure.keyed( + MinecraftKeys.SINGLE_ITEM_STACK, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(ItemStack.SINGLE_ITEM_CODEC))) + .build(), + Structure.record(builder -> { + var item = builder.add("id", ITEM, ItemStack::getItemHolder); + var patch = builder.addOptional("components", DATA_COMPONENT_PATCH, ItemStack::getComponentsPatch, () -> DataComponentPatch.EMPTY); + return container -> new ItemStack( + item.apply(container), + 1, + patch.apply(container) + ); + }) + )); + + public static final Structure OPTIONAL_ITEM_STACK = Structure.lazyInitialized(() -> Structure.keyed( + MinecraftKeys.OPTIONAL_ITEM_STACK, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(ItemStack.OPTIONAL_CODEC))) + .add(StreamCodecInterpreter.REGISTRY_FRIENDLY_BYTE_BUF_KEY, new Flip<>(new StreamCodecInterpreter.Holder<>(ItemStack.OPTIONAL_STREAM_CODEC))) + .build(), + Structure.either( + Structure.EMPTY_MAP, + ITEM_STACK + ) + .xmap(e -> e.map(u -> ItemStack.EMPTY, Function.identity()), itemStack -> itemStack.isEmpty() ? Either.left(Unit.INSTANCE) : Either.right(itemStack)) + )); + + public static final Structure STRICT_ITEM_STACK = Structure.lazyInitialized(() -> Structure.keyed( + MinecraftKeys.STRICT_ITEM_STACK, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(ItemStack.STRICT_CODEC))) + .build(), + ITEM_STACK.validate(MinecraftStructures::validateItemStackStrict) + )); + + public static final Structure STRICT_SINGLE_ITEM_STACK = Structure.lazyInitialized(() -> Structure.keyed( + MinecraftKeys.STRICT_SINGLE_ITEM_STACK, + Keys., K1>builder() + .add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(ItemStack.STRICT_SINGLE_ITEM_CODEC))) + .build(), + SINGLE_ITEM_STACK.validate(MinecraftStructures::validateItemStackStrict) + )); + + private static DataResult validateItemStackStrict(ItemStack itemStack) { + return ItemStack.validateComponents(itemStack.getComponents()) + .flatMap( + u -> itemStack.getCount() > itemStack.getMaxStackSize() ? + DataResult.error(() -> "Item stack with stack size of " + itemStack.getCount() + " was larger than maximum: " + itemStack.getMaxStackSize(), itemStack) : + DataResult.success(itemStack) + ); + } + + public static Structure> resourceKey(ResourceKey> registry) { + return Structure.parametricallyKeyed( + MinecraftKeys.RESOURCE_KEY, + new MinecraftKeys.RegistryKeyHolder<>(registry), + MinecraftKeys.ResourceKeyHolder::unbox, + Keys.>, K1>builder() + .add( + CodecInterpreter.KEY, + new Flip<>(new CodecInterpreter.Holder<>( + ResourceKey.codec(registry).xmap(MinecraftKeys.ResourceKeyHolder::new, MinecraftKeys.ResourceKeyHolder::value) + )) + ) + .add( + StreamCodecInterpreter.FRIENDLY_BYTE_BUF_KEY, + new Flip<>(new StreamCodecInterpreter.Holder<>( + ResourceKey.streamCodec(registry).map(MinecraftKeys.ResourceKeyHolder::new, MinecraftKeys.ResourceKeyHolder::value).cast() + )) + ) + .build(), + RESOURCE_LOCATION.xmap(resourceLocation -> ResourceKey.create(registry, resourceLocation), ResourceKey::location) + .xmap(MinecraftKeys.ResourceKeyHolder::new, MinecraftKeys.ResourceKeyHolder::value) + ).xmap(MinecraftKeys.ResourceKeyHolder::value, MinecraftKeys.ResourceKeyHolder::new); + } + + public static Structure> registryOrderedHolder(Registry registry) { + return Structure.parametricallyKeyed( + MinecraftKeys.ORDERED_HOLDER, + new MinecraftKeys.RegistryHolder<>(registry), + MinecraftKeys.HolderHolder::unbox, + Keys.>, K1>builder() + .add( + CodecInterpreter.KEY, + new Flip<>(new CodecInterpreter.Holder<>( + registry.holderByNameCodec().xmap(MinecraftKeys.HolderHolder::new, MinecraftKeys.HolderHolder::value) + )) + ) + .add( + StreamCodecInterpreter.REGISTRY_FRIENDLY_BYTE_BUF_KEY, + new Flip<>(new StreamCodecInterpreter.Holder<>( + ByteBufCodecs.holderRegistry(registry.key()).map(MinecraftKeys.HolderHolder::new, MinecraftKeys.HolderHolder::value) + )) + ) + .build(), + resourceKey(registry.key()) + .bounded(registry::registryKeySet) + .flatXmap(key -> registry.get(key).>>map(DataResult::success).orElse(DataResult.error(() -> "Unknown registry entry: " + key)), holder -> + keyForEntry(holder, registry).map(DataResult::success).orElse(DataResult.error(() -> "Unknown registry entry: " + holder))) + .xmap(MinecraftKeys.HolderHolder::new, MinecraftKeys.HolderHolder::value) + ).xmap(MinecraftKeys.HolderHolder::value, MinecraftKeys.HolderHolder::new); + } + + public static Structure> registryUnorderedHolder(Registry registry) { + return Structure.parametricallyKeyed( + MinecraftKeys.UNORDERED_HOLDER, + new MinecraftKeys.RegistryHolder<>(registry), + MinecraftKeys.HolderHolder::unbox, + Keys.>, K1>builder() + .add( + CodecInterpreter.KEY, + new Flip<>(new CodecInterpreter.Holder<>( + registry.holderByNameCodec().xmap(MinecraftKeys.HolderHolder::new, MinecraftKeys.HolderHolder::value) + )) + ) + .add( + StreamCodecInterpreter.FRIENDLY_BYTE_BUF_KEY, + new Flip<>(new StreamCodecInterpreter.Holder<>( + ResourceLocation.STREAM_CODEC.>map( + rl -> registry.get(rl).orElseThrow(() -> new DecoderException("Unknown registry entry: " + rl)), + holder -> keyForEntry(holder, registry).orElseThrow(() -> new EncoderException("Unknown registry entry: " + holder)).location() + ).cast().map(MinecraftKeys.HolderHolder::new, MinecraftKeys.HolderHolder::value) + )) + ) + .build(), + resourceKey(registry.key()) + .bounded(registry::registryKeySet) + .flatXmap(key -> registry.get(key).>>map(DataResult::success).orElse(DataResult.error(() -> "Unknown registry entry: " + key)), holder -> + keyForEntry(holder, registry).map(DataResult::success).orElse(DataResult.error(() -> "Unknown registry entry: " + holder))) + .xmap(MinecraftKeys.HolderHolder::new, MinecraftKeys.HolderHolder::value) + ).xmap(MinecraftKeys.HolderHolder::value, MinecraftKeys.HolderHolder::new); + } + + private static Optional> keyForEntry(Holder entry, Registry registry) { + if (entry instanceof Holder.Reference reference) { + return Optional.of(reference.key()); + } else { + var value = entry.value(); + return registry.getResourceKey(value); + } + } + + public static Structure> homogenousList(ResourceKey> registry) { + return Structure.parametricallyKeyed( + MinecraftKeys.HOMOGENOUS_LIST, + new MinecraftKeys.RegistryKeyHolder<>(registry), + MinecraftKeys.HolderSetHolder::unbox, + Keys.>, K1>builder() + .add( + CodecInterpreter.KEY, + new Flip<>(new CodecInterpreter.Holder<>( + RegistryCodecs.homogeneousList(registry).xmap(MinecraftKeys.HolderSetHolder::new, MinecraftKeys.HolderSetHolder::value) + )) + ) + .build() + ).xmap(MinecraftKeys.HolderSetHolder::value, MinecraftKeys.HolderSetHolder::new); + } + + public static Structure> tagKey(ResourceKey> registry, boolean hashPrefix) { + return Structure.parametricallyKeyed( + hashPrefix ? MinecraftKeys.HASHED_TAG_KEY : MinecraftKeys.TAG_KEY, + new MinecraftKeys.RegistryKeyHolder<>(registry), + MinecraftKeys.TagKeyHolder::unbox, + Keys.>, K1>builder() + .add( + CodecInterpreter.KEY, + new Flip<>(new CodecInterpreter.Holder<>( + (hashPrefix ? TagKey.hashedCodec(registry) : TagKey.codec(registry)).xmap(MinecraftKeys.TagKeyHolder::new, MinecraftKeys.TagKeyHolder::value) + )) + ) + .build(), + (hashPrefix ? + Structure.STRING.comapFlatMap(string -> string.startsWith("#") ? ResourceLocation.read(string.substring(1)).map(resourceLocation -> TagKey.create(registry, resourceLocation)) : DataResult.>error(() -> "Not a tag id"), tagKey -> "#" + tagKey.location()) : + RESOURCE_LOCATION.xmap(resourceLocation -> TagKey.create(registry, resourceLocation), TagKey::location)) + .xmap(MinecraftKeys.TagKeyHolder::new, MinecraftKeys.TagKeyHolder::value) + .annotate(Annotation.PATTERN, "^"+(hashPrefix ? "#" : "")+"([a-z0-9_.-]+:)?[a-z0-9_/.-]+$") + ).xmap(MinecraftKeys.TagKeyHolder::value, MinecraftKeys.TagKeyHolder::new); + } + + public static Structure registryDispatch(String keyField, Function>>> structureFunction, Registry> registry) { + var keyStructure = resourceKey(registry.key()); + return keyStructure.dispatch(keyField, structureFunction, registry::registryKeySet, k -> + DataResult.success(registry.getValueOrThrow(k).annotate(SchemaAnnotations.REUSE_KEY, toDefsKey(k.registry()) + "::" + toDefsKey(k.location()))) + ); + } + + private static String toDefsKey(ResourceLocation location) { + return location.getNamespace().replace('/', '.') + ":" + location.getPath().replace('/', '.'); + } + + private static DataResult> dataComponentTypeStructure(DataComponentType type) { + var resourceKey = BuiltInRegistries.DATA_COMPONENT_TYPE.getResourceKey(type); + if (resourceKey.isEmpty()) { + return DataResult.error(() -> "Unregistered data component type: " + type); + } + if (!CodecExtrasRegistries.REGISTRIES.dataComponentStructures().isReady()) { + return DataResult.error(() -> "Data component structures registry is not frozen"); + } + var structure = CodecExtrasRegistries.REGISTRIES.dataComponentStructures().get().getValue(resourceKey.orElseThrow().location()); + return fallbackDataComponentTypeStructure(type, structure); + } + + private static DataResult> fallbackDataComponentTypeStructure(DataComponentType type, @Nullable Structure> fallback) { + if (fallback != null) { + return DataResult.success(fallback); + } + var key2 = MinecraftKeys.FALLBACK_DATA_COMPONENT_TYPE; + + var codec = type.codec(); + var streamCodec = type.streamCodec(); + var keysBuilder = Keys.>, K1>builder() + .add(StreamCodecInterpreter.REGISTRY_FRIENDLY_BYTE_BUF_KEY, new Flip<>(new StreamCodecInterpreter.Holder<>(streamCodec.cast().map(Identity::new, i -> Identity.unbox(i).value()))));; + if (codec != null) { + keysBuilder.add(CodecInterpreter.KEY, new Flip<>(new CodecInterpreter.Holder<>(codec.xmap(Identity::new, i -> Identity.unbox(i).value())))); + } + var keys = keysBuilder.build(); + return DataResult.success(Structure.parametricallyKeyed( + key2, + new MinecraftKeys.DataComponentTypeHolder<>(type), + Identity::unbox, + keys + )); + } + + private static Set> possibleDataComponentPatchKeys() { + return BuiltInRegistries.DATA_COMPONENT_TYPE.stream() + .flatMap(type -> Stream.of(new MinecraftKeys.DataComponentPatchKey<>(type, false), new MinecraftKeys.DataComponentPatchKey<>(type, true))) + .collect(Collectors.toSet()); + } + + private static DataResult> dataComponentPatchValueCodec(MinecraftKeys.DataComponentPatchKey key) { + if (key.removes()) { + return DataResult.success(Structure.UNIT); + } + return dataComponentTypeStructure(key.type()); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ChoiceScreen.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ChoiceScreen.java new file mode 100644 index 0000000..5928f01 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ChoiceScreen.java @@ -0,0 +1,161 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.ObjectSelectionList; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +class ChoiceScreen extends Screen { + private static final int KEYS_NEED_SEARCH = 10; + + private final Screen lastScreen; + private final HeaderAndFooterLayout layout; + private final Consumer<@Nullable String> onClose; + private @Nullable EntryList list; + private final List keys; + private @Nullable String selectedKey; + private String filter = ""; + private final Button doneButton = Button.builder(CommonComponents.GUI_DONE, (button) -> this.onClose()).width(200).build(); + + public ChoiceScreen(Screen screen, Component title, List keys, @Nullable String selectedKey, Consumer<@Nullable String> onClose) { + super(title); + this.lastScreen = screen; + this.keys = keys; + this.selectedKey = selectedKey; + this.onClose = onClose; + if (keys.size() > KEYS_NEED_SEARCH) { + layout = new HeaderAndFooterLayout(this, HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT + Button.DEFAULT_HEIGHT + Button.DEFAULT_SPACING, HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT); + } else { + layout = new HeaderAndFooterLayout(this); + } + } + + protected void init() { + this.addHeader(); + + this.list = new EntryList(); + this.addContents(); + this.layout.addToContents(this.list); + this.updateButtonValidity(); + + this.addFooter(); + this.layout.visitWidgets(this::addRenderableWidget); + this.repositionElements(); + } + + @Override + protected void repositionElements() { + this.lastScreen.resize(this.minecraft, this.width, this.height); + this.layout.arrangeElements(); + if (this.list != null) { + this.list.updateSize(this.width, this.layout); + } + } + + public void added() { + super.added(); + } + + protected void addHeader() { + var title = new StringWidget(this.title, this.font); + var layout = LinearLayout.vertical().spacing(Button.DEFAULT_SPACING); + layout.addChild(title, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + if (keys.size() > KEYS_NEED_SEARCH) { + var search = new EditBox(this.font, 0, 0, Button.DEFAULT_WIDTH, Button.DEFAULT_HEIGHT, Component.empty()); + search.setValue(this.filter); + search.setResponder(string -> { + if (this.list != null) { + this.filter = string; + this.list.clear(); + this.addContents(); + this.repositionElements(); + } + }); + layout.addChild(search, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + } + this.layout.addToHeader(layout); + } + + protected void addContents() { + this.keys.forEach(key -> { + if (filter.isBlank() || key.contains(filter)) { + this.list.addEntry(list.new Entry(key)); + } + }); + this.list.setSelected(this.list.children().stream() + .filter(entry -> filter.isBlank() || entry.key.contains(filter)) + .filter(entry -> Objects.equals(entry.key, this.selectedKey)).findFirst().orElse(null) + ); + } + + protected void addFooter() { + this.layout.addToFooter(this.doneButton); + } + + public void onClose() { + this.onClose.accept(this.selectedKey); + this.minecraft.setScreen(this.lastScreen); + } + + private final class EntryList extends ObjectSelectionList { + private EntryList() { + super(ChoiceScreen.this.minecraft, ChoiceScreen.this.width, ChoiceScreen.this.layout.getContentHeight(), ChoiceScreen.this.layout.getHeaderHeight(), 16); + } + + public void setSelected(EntryList.@Nullable Entry entry) { + super.setSelected(entry); + if (entry != null) { + ChoiceScreen.this.selectedKey = entry.key; + } + + ChoiceScreen.this.updateButtonValidity(); + } + + @Override + public int addEntry(Entry entry) { + return super.addEntry(entry); + } + + public void clear() { + this.clearEntries(); + } + + private class Entry extends ObjectSelectionList.Entry { + final String key; + final Component name; + + public Entry(final String key) { + this.key = key; + this.name = Component.literal(key); + } + + public Component getNarration() { + return Component.translatable("narrator.select", this.name); + } + + public void render(GuiGraphics guiGraphics, int i, int j, int k, int l, int m, int n, int o, boolean bl, float f) { + guiGraphics.drawString(ChoiceScreen.this.font, this.name, k + 5, j + 2, 0xFFFFFF); + } + + public boolean mouseClicked(double d, double e, int i) { + ChoiceScreen.EntryList.this.setSelected(this); + return super.mouseClicked(d, e, i); + } + } + } + + private void updateButtonValidity() { + this.doneButton.active = this.list.getSelected() != null; + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ColorPickScreen.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ColorPickScreen.java new file mode 100644 index 0000000..7496419 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ColorPickScreen.java @@ -0,0 +1,115 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.logging.LogUtils; +import java.util.Locale; +import java.util.function.Consumer; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +class ColorPickScreen extends Screen { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final Screen backgroundScreen; + private final LinearLayout layout = LinearLayout.vertical(); + private final Consumer consumer; + private final boolean hasAlpha; + int color; + private @Nullable EditBox textField; + private @Nullable ColorPickWidget pick; + + protected ColorPickScreen(Screen backgroundScreen, Component component, Consumer consumer, boolean hasAlpha) { + super(component); + this.backgroundScreen = backgroundScreen; + this.consumer = consumer; + this.hasAlpha = hasAlpha; + } + + @Override + public void added() { + super.added(); + this.backgroundScreen.clearFocus(); + } + + protected void init() { + this.backgroundScreen.init(this.minecraft, this.width, this.height); + this.layout.spacing(12).defaultCellSetting().alignHorizontallyCenter(); + this.pick = new ColorPickWidget(0, 0, Component.empty(), this::setColor, hasAlpha); + this.layout.addChild(pick); + var bottomLayout = new EqualSpacingLayout(pick.getWidth(), Button.DEFAULT_HEIGHT, EqualSpacingLayout.Orientation.HORIZONTAL); + this.textField = new EditBox(this.font, 0, 0, 80, Button.DEFAULT_HEIGHT, Component.empty()); + textField.setResponder(string -> { + try { + if (hasAlpha && string.length() != 8) { + return; + } else if (!hasAlpha && string.length() != 6) { + return; + } + var color = Integer.parseUnsignedInt(string, 16); + setColor(color); + } catch (NumberFormatException e) { + LOGGER.error("Invalid hex number: {}", string, e); + } + }); + textField.setFilter(string -> string.matches("^[0-9a-fA-F]{0,"+(hasAlpha ? 8 : 6)+"}$")); + var button = Button.builder(CommonComponents.GUI_DONE, b -> this.onClose()).width(pick.getWidth() - 80 - 8).build(); + bottomLayout.addChild(textField); + bottomLayout.addChild(button); + this.layout.addChild(bottomLayout); + this.layout.visitWidgets(this::addRenderableWidget); + this.updateColor(color); + this.repositionElements(); + } + + public void onClose() { + this.consumer.accept(this.color); + this.minecraft.setScreen(this.backgroundScreen); + } + + @Override + protected void repositionElements() { + this.backgroundScreen.resize(this.minecraft, this.width, this.height); + this.layout.arrangeElements(); + FrameLayout.centerInRectangle(this.layout, this.getRectangle()); + } + + public void renderBackground(GuiGraphics guiGraphics, int i, int j, float f) { + this.backgroundScreen.render(guiGraphics, -1, -1, f); + guiGraphics.flush(); + RenderSystem.clear(256); + this.renderTransparentBackground(guiGraphics); + } + + public void setColor(int color) { + if (color != this.color) { + updateColor(color); + } + } + + private void updateColor(int color) { + this.color = color; + var string = Integer.toHexString(hasAlpha ? color : color & 0xFFFFFF); + if (hasAlpha) { + string = "00000000".substring(string.length()) + string; + } else { + string = "000000".substring(string.length()) + string; + } + if (this.textField != null) { + if (!this.textField.getValue().equalsIgnoreCase(string)) { + this.textField.setValue(string.toUpperCase(Locale.ROOT)); + } + } + if (this.pick != null) { + this.pick.setColor(color); + } + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ColorPickWidget.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ColorPickWidget.java new file mode 100644 index 0000000..2f4eed6 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ColorPickWidget.java @@ -0,0 +1,234 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import java.util.function.Consumer; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import org.joml.Matrix4f; + +class ColorPickWidget extends AbstractWidget { + private static final ResourceLocation HUE = ResourceLocation.fromNamespaceAndPath("codecextras_minecraft", "widget/hue"); + private static final ResourceLocation TRANSPARENT = ResourceLocation.fromNamespaceAndPath("codecextras_minecraft", "widget/transparent"); + private static final int BORDER_COLOR = 0xFFA0A0A0; + + private final Consumer consumeClick; + private final boolean hasAlpha; + + private int color; + private double alpha; + private double hue; + private double saturation; + private double value; + + private int fullySaturated; + private int inverted; + + ColorPickWidget(int i, int j, Component component, Consumer consumer, boolean hasAlpha) { + super(i, j, calculateWidth(hasAlpha), 128 + 2, component); + this.consumeClick = consumer; + this.hasAlpha = hasAlpha; + } + + private static int calculateWidth(boolean alpha) { + return 128 + 8*(alpha ? 4 : 2) + 2*(alpha ? 3 : 2); + } + + private void recalculateInternal() { + this.fullySaturated = 0xFF000000 | toRgb(hue, 1.0, 1.0); + this.inverted = 0xFF000000 | toRgb((hue + 0.5) % 1, 1.0, 1.0 - value); + } + + public void setColor(int argbColor) { + this.color = argbColor; + + double r = (argbColor >> 16 & 255) / 255.0F; + double g = (argbColor >> 8 & 255) / 255.0F; + double b = (argbColor & 255) / 255.0F; + + this.alpha = (argbColor >> 24 & 255) / 255.0F; + this.value = value(r, g, b); + this.saturation = saturation(r, g, b); + this.hue = hue(r, g, b); + + recalculateInternal(); + } + + private static int toRgb(double hue, double saturation, double value) { + double prime = hue / (1d/6d); + double c = value * saturation; + double x = c * (1 - Math.abs(prime % 2 - 1)); + double r = 0; + double g = 0; + double b = 0; + if (prime < 1) { + r = c; + g = x; + } else if (prime < 2) { + r = x; + g = c; + } else if (prime < 3) { + g = c; + b = x; + } else if (prime < 4) { + g = x; + b = c; + } else if (prime < 5) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + r += value - c; + g += value - c; + b += value - c; + + int rI = (int) Math.round(r * 255); + int gI = (int) Math.round(g * 255); + int bI = (int) Math.round(b * 255); + return (rI & 0xFF) << 16 | (gI & 0xFF) << 8 | (bI & 0xFF); + } + + private static double value(double r, double g, double b) { + return Math.max(r, Math.max(g, b)); + } + + private static double saturation(double r, double g, double b) { + double max = Math.max(r, Math.max(g, b)); + double min = Math.min(r, Math.min(g, b)); + double diff = max - min; + return max == 0 ? 0 : diff / max; + } + + private static double hue(double r, double g, double b) { + double max = Math.max(r, Math.max(g, b)); + double min = Math.min(r, Math.min(g, b)); + double diff = max - min; + double h; + + if (diff == 0) { + h = 0; + } else if (max == r) { + h = (1d/6d * ((g - b) / diff) + 1.0) % 1.0; + } else if (max == g) { + h = (1d/6d * ((b - r) / diff) + 1d/3d) % 1.0; + } else { + h = (1d/6d * ((r - g) / diff) + 2d/3d) % 1.0; + } + return h; + } + + @Override + protected void renderWidget(GuiGraphics guiGraphics, int i, int j, float f) { + if (!this.visible) { + return; + } + int x1 = getX()+1; + int y1 = getY()+1; + int x2 = x1 + 128; + int y2 = y1 + 128; + guiGraphics.renderOutline(getX(), getY(), 128+2, 128+2, BORDER_COLOR); + guiGraphics.renderOutline(getX()+128+8+2, getY(), 8+2, 128+2, BORDER_COLOR); + if (hasAlpha) { + guiGraphics.renderOutline(getX() + 128 + 8 * 3 + 2 * 2, getY(), 8 + 2, 128 + 2, BORDER_COLOR); + } + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + guiGraphics.drawSpecial(bufferSource -> { + VertexConsumer vertexConsumer = bufferSource.getBuffer(RenderType.gui()); + vertexConsumer.addVertex(matrix4f, x1, y1, 0).setColor(0xFFFFFFFF); + vertexConsumer.addVertex(matrix4f, x1, y2, 0).setColor(0xFFFFFFFF); + vertexConsumer.addVertex(matrix4f, x2, y2, 0).setColor(fullySaturated); + vertexConsumer.addVertex(matrix4f, x2, y1, 0).setColor(fullySaturated); + }); + guiGraphics.fillGradient(x1, y1, x2, y2, 0, 0xFF000000); + + guiGraphics.enableScissor(x1, y1, x2, y2); + int xCenter = (int) (x1 + saturation * 127); + int yCenter = (int) (y1 + (1 - value) * 127); + guiGraphics.fill(xCenter-2, yCenter, xCenter+3, yCenter+1, inverted); + guiGraphics.fill(xCenter, yCenter-2, xCenter+1, yCenter+3, inverted); + guiGraphics.disableScissor(); + + guiGraphics.blitSprite(RenderType::guiTextured, HUE, x1+128+8+2, y1, 8, 128); + + guiGraphics.fill(x1+128+8+2, y1+(int)(hue*127), x1+128+8*2+2, y1+(int)(hue*127)+1, 0xFF000000); + + var ax1 = x1 + 128 + 8 * 3 + 2 * 2; + var ax2 = ax1 + 8; + if (this.hasAlpha) { + guiGraphics.blitSprite(RenderType::guiTextured, TRANSPARENT, ax1, y1, 8, 128); + + guiGraphics.fillGradient(ax1, y1, ax2, y2, 0x00000000, color | 0xFF000000); + + guiGraphics.fill(ax1, y1+(int)(alpha*127), ax2, y1+(int)(alpha*127)+1, 0xFF000000); + } + } + + @Override + protected void onDrag(double x, double y, double oldX, double oldY) { + this.fromPosition(x, y); + super.onDrag(x, y, oldX, oldY); + } + + @Override + public void onClick(double x, double y) { + this.fromPosition(x, y); + super.onClick(x, y); + } + + private void fromPosition(double x, double y) { + x = x - getX(); + y = y - getY(); + if (x > 1 && x < 128+1 && y > 1 && y < 129) { + var saturation = Math.max(0, Math.min(1, (x - 1) / 128)); + var value = Math.max(0, Math.min(1, 1 - (y - 1) / 128)); + this.saturation = saturation; + this.value = value; + var hue = this.hue; + updateColor(); + this.hue = hue; + this.saturation = saturation; + this.value = value; + recalculateInternal(); + } else if (x > 128+2+8 && x < 128+2+8*2+2 && y > 1 && y < 129) { + var hue = Math.max(0, Math.min(1, (y - 1) / 128)); + this.hue = hue; + var saturation = this.saturation; + var value = this.value; + updateColor(); + this.hue = hue; + this.saturation = saturation; + this.value = value; + recalculateInternal(); + } else if (hasAlpha && x > 128+2*2+8*3 && x < 128+2*2+8*4+2 && y > 1 && y < 129) { + var alpha = Math.max(0, Math.min(1, (y - 1) / 128)); + this.alpha = alpha; + var saturation = this.saturation; + var value = this.value; + var hue = this.hue; + updateColor(); + this.hue = hue; + this.saturation = saturation; + this.value = value; + recalculateInternal(); + } + } + + private void updateColor() { + int color = toRgb(hue, saturation, value); + if (hasAlpha) { + color |= (int) (alpha * 255) << 24; + } + consumeClick.accept(color); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ComponentInfo.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ComponentInfo.java new file mode 100644 index 0000000..6444403 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ComponentInfo.java @@ -0,0 +1,44 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import java.util.Optional; +import net.minecraft.network.chat.Component; + +public record ComponentInfo(Optional maybeTitle, Optional maybeDescription) { + private static final ComponentInfo EMPTY = new ComponentInfo(Optional.empty(), Optional.empty()); + + public static ComponentInfo empty() { + return EMPTY; + } + + public Component title() { + return maybeTitle.orElseGet(Component::empty); + } + + public Component description() { + return maybeDescription.orElseGet(Component::empty); + } + + public ComponentInfo fallbackTitle(Component fallback) { + if (maybeTitle.isPresent()) { + return this; + } else { + return new ComponentInfo(Optional.of(fallback), maybeDescription); + } + } + + public ComponentInfo fallbackDescription(Component fallback) { + if (maybeDescription.isPresent()) { + return this; + } else { + return new ComponentInfo(maybeTitle, Optional.of(fallback)); + } + } + + public ComponentInfo withTitle(Component title) { + return new ComponentInfo(Optional.of(title), maybeDescription); + } + + public ComponentInfo withDescription(Component description) { + return new ComponentInfo(maybeTitle, Optional.of(description)); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigAnnotations.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigAnnotations.java new file mode 100644 index 0000000..c63963c --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigAnnotations.java @@ -0,0 +1,11 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import dev.lukebemish.codecextras.structured.Key; +import net.minecraft.network.chat.Component; + +public final class ConfigAnnotations { + private ConfigAnnotations() {} + + public static Key TITLE = Key.create("title"); + public static Key DESCRIPTION = Key.create("description"); +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenBuilder.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenBuilder.java new file mode 100644 index 0000000..c07a098 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenBuilder.java @@ -0,0 +1,79 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Turns a list of {@link ConfigScreenEntry}s into a screen that can be opened by the user. + */ +public class ConfigScreenBuilder { + private record SingleScreen(ConfigScreenEntry screenEntry, Consumer onClose, Supplier context, Supplier initialData) {} + + private final List> screens = new ArrayList<>(); + private Logger logger = LoggerFactory.getLogger(ConfigScreenBuilder.class); + + private ConfigScreenBuilder() {} + + public static ConfigScreenBuilder create() { + return new ConfigScreenBuilder(); + } + + public ConfigScreenBuilder logger(Logger logger) { + this.logger = logger; + return this; + } + + public ConfigScreenBuilder add(ConfigScreenEntry entry, Consumer onClose, Supplier context, Supplier initialData) { + screens.add(new SingleScreen<>(entry, onClose, context, initialData)); + return this; + } + + public UnaryOperator factory() { + if (screens.isEmpty()) { + throw new IllegalStateException("No screens have been added to the builder"); + } + return parent -> { + if (screens.size() == 1) { + var entry = screens.getFirst(); + return openSingleScreen(parent, entry); + } else { + return ScreenEntryProvider.create(new ScreenEntryProvider() { + @Override + public void onExit(EntryCreationContext context) {} + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + for (var screen : screens) { + var label = new StringWidget(Button.DEFAULT_WIDTH, Button.DEFAULT_HEIGHT, screen.screenEntry().entryCreationInfo().componentInfo().title(), Minecraft.getInstance().font).alignLeft(); + var button = Button.builder(Component.translatable("codecextras.config.configurerecord"), b -> { + var newScreen = openSingleScreen(parent, screen); + Minecraft.getInstance().setScreen(newScreen); + }).width(Button.DEFAULT_WIDTH).build(); + screen.screenEntry().entryCreationInfo().componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + label.setTooltip(tooltip); + button.setTooltip(tooltip); + }); + list.addPair(label, button); + } + } + }, parent, EntryCreationContext.builder().build(), ComponentInfo.empty()); + } + }; + } + + private Screen openSingleScreen(Screen parent, SingleScreen entry) { + return entry.screenEntry().rootScreen(parent, entry.onClose(), entry.context().get(), entry.initialData().get(), logger); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenEntry.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenEntry.java new file mode 100644 index 0000000..c5236c1 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenEntry.java @@ -0,0 +1,70 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import net.minecraft.client.gui.screens.Screen; +import org.slf4j.Logger; + +/** + * Represents both a single level of a configuration screen, and the entry it would become when nested in another config + * screen. To turn into a screen, use {@link ConfigScreenBuilder}. + * @param layout prevides the layout used when this data is nested in another config screen + * @param screenEntryProvider provides the entries present on this screen + * @param entryCreationInfo the information needed to create this entry + * @param the type of data this entry represents + */ +public record ConfigScreenEntry(LayoutFactory layout, ScreenEntryFactory screenEntryProvider, EntryCreationInfo entryCreationInfo) implements App { + + public static final class Mu implements K1 { private Mu() {} } + + public static ConfigScreenEntry unbox(App app) { + return (ConfigScreenEntry) app; + } + + public static ConfigScreenEntry single(LayoutFactory first, EntryCreationInfo entryCreationInfo) { + return new ConfigScreenEntry<>(first, (context, original, onClose, creationInfo) -> new SingleScreenEntryProvider<>(original, first, context, creationInfo, onClose), entryCreationInfo); + } + + public ConfigScreenEntry withComponentInfo(UnaryOperator function) { + return new ConfigScreenEntry<>(this.layout, this.screenEntryProvider, this.entryCreationInfo.withComponentInfo(function)); + } + + public ConfigScreenEntry withEntryCreationInfo(Function, EntryCreationInfo> function, Function, EntryCreationInfo> reverse) { + return new ConfigScreenEntry<>( + (parent, width, context, original, update, entry, handleOptional) -> { + var entryCreationInfo = reverse.apply(entry); + return this.layout.create(parent, width, context, original, update, entryCreationInfo, handleOptional); + }, + (context, original, onClose, entry) -> { + var entryCreationInfo = reverse.apply(entry); + return this.screenEntryProvider.openChecked(context, original, onClose, entryCreationInfo); + }, + function.apply(this.entryCreationInfo) + ); + } + + Screen rootScreen(Screen parent, Consumer onClose, EntryCreationContext context, T initialData, Logger logger) { + var initial = entryCreationInfo.codec().encodeStart(context.ops(), initialData); + JsonElement initialJson; + if (initial.error().isPresent()) { + logger.error("Failed to encode `{}`: {}", initialData, initial.error().get().message()); + initialJson = JsonNull.INSTANCE; + } else { + initialJson = initial.getOrThrow(); + } + var provider = screenEntryProvider().openChecked(context, initialJson, json -> { + var decoded = entryCreationInfo.codec().parse(context.ops(), json); + if (decoded.error().isPresent()) { + logger.error("Failed to decode `{}`: {}", json, decoded.error().get().message()); + } else { + onClose.accept(decoded.getOrThrow()); + } + }, this.entryCreationInfo()); + return ScreenEntryProvider.create(provider, parent, context, entryCreationInfo.componentInfo()); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenInterpreter.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenInterpreter.java new file mode 100644 index 0000000..6872a69 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ConfigScreenInterpreter.java @@ -0,0 +1,1081 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.common.base.Suppliers; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.Const; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Dynamic; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.codecs.PrimitiveCodec; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftInterpreters; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftKeys; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftStructures; +import dev.lukebemish.codecextras.structured.Annotation; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Interpreter; +import dev.lukebemish.codecextras.structured.Key; +import dev.lukebemish.codecextras.structured.KeyStoringInterpreter; +import dev.lukebemish.codecextras.structured.Keys; +import dev.lukebemish.codecextras.structured.Keys2; +import dev.lukebemish.codecextras.structured.ParametricKeyedValue; +import dev.lukebemish.codecextras.structured.Range; +import dev.lukebemish.codecextras.structured.RecordStructure; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.types.Identity; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.LayoutElement; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.Holder; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Items; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +/** + * Interprets a {@link Structure} into a {@link ConfigScreenEntry} for the same type. Note that interpreting a structure + * with this interpreter will require resolving any lazy aspects of the structure, such as bounds, so should not be done + * until that is safe. + * @see #interpret(Structure) + * @see ConfigScreenEntry + */ +public class ConfigScreenInterpreter extends KeyStoringInterpreter { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final CodecInterpreter codecInterpreter; + + public ConfigScreenInterpreter( + Keys keys, + Keys2, K1, K1> parametricKeys, + CodecInterpreter codecInterpreter + ) { + super( + keys.join(Keys.builder() + .add(Interpreter.INT, ConfigScreenEntry.single( + Widgets.text(string -> { + try { + return DataResult.success(Integer.parseInt(string)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not an integer: "+string); + } + }, integer -> DataResult.success(integer+""), string -> string.matches("^-?[0-9]*$"), true), + new EntryCreationInfo<>(Codec.INT, ComponentInfo.empty()) + )) + .add(Interpreter.BYTE, ConfigScreenEntry.single( + Widgets.text(string -> { + try { + return DataResult.success(Byte.parseByte(string)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a byte: "+string); + } + }, byteValue -> DataResult.success(byteValue+""), string -> string.matches("^-?[0-9]*$"), true), + new EntryCreationInfo<>(Codec.BYTE, ComponentInfo.empty()) + )) + .add(Interpreter.SHORT, ConfigScreenEntry.single( + Widgets.text(string -> { + try { + return DataResult.success(Short.parseShort(string)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a short: "+string); + } + }, shortValue -> DataResult.success(shortValue+""), string -> string.matches("^-?[0-9]*$"), true), + new EntryCreationInfo<>(Codec.SHORT, ComponentInfo.empty()) + )) + .add(Interpreter.LONG, ConfigScreenEntry.single( + Widgets.text(string -> { + try { + return DataResult.success(Long.parseLong(string)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a long: "+string); + } + }, longValue -> DataResult.success(longValue+""), string -> string.matches("^-?[0-9]*$"), true), + new EntryCreationInfo<>(Codec.LONG, ComponentInfo.empty()) + )) + .add(Interpreter.DOUBLE, ConfigScreenEntry.single( + Widgets.text(string -> { + try { + return DataResult.success(Double.parseDouble(string)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a double: "+string); + } + }, doubleValue -> DataResult.success(doubleValue+""), string -> string.matches("^-?[0-9]*(\\.[0-9]*)?$"), true), + new EntryCreationInfo<>(Codec.DOUBLE, ComponentInfo.empty()) + )) + .add(Interpreter.FLOAT, ConfigScreenEntry.single( + Widgets.text(string -> { + try { + return DataResult.success(Float.parseFloat(string)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a float: "+string); + } + }, floatValue -> DataResult.success(floatValue+""), string -> string.matches("^-?[0-9]*(\\.[0-9]*)?$"), true), + new EntryCreationInfo<>(Codec.FLOAT, ComponentInfo.empty()) + )) + .add(Interpreter.BOOL, ConfigScreenEntry.single( + Widgets.bool(), + new EntryCreationInfo<>(Codec.BOOL, ComponentInfo.empty()) + )) + .add(Interpreter.UNIT, ConfigScreenEntry.single( + Widgets.unit(), + new EntryCreationInfo<>(Codec.unit(Unit.INSTANCE), ComponentInfo.empty()) + )) + .add(Interpreter.EMPTY_LIST, ConfigScreenEntry.single( + Widgets.unit(Component.translatable("codecextras.config.unit.empty")), + new EntryCreationInfo<>(MinecraftInterpreters.CODEC_INTERPRETER.interpret(Structure.EMPTY_LIST).getOrThrow(), ComponentInfo.empty()) + )) + .add(Interpreter.EMPTY_MAP, ConfigScreenEntry.single( + Widgets.unit(Component.translatable("codecextras.config.unit.empty")), + new EntryCreationInfo<>(MinecraftInterpreters.CODEC_INTERPRETER.interpret(Structure.EMPTY_MAP).getOrThrow(), ComponentInfo.empty()) + )) + .add(Interpreter.STRING, ConfigScreenEntry.single( + Widgets.wrapWithOptionalHandling(Widgets.text(DataResult::success, DataResult::success, false)), + new EntryCreationInfo<>(Codec.STRING, ComponentInfo.empty()) + )) + .add(Interpreter.PASSTHROUGH, ConfigScreenEntry.single( + Widgets.wrapWithOptionalHandling(ConfigScreenInterpreter::byJson), + new EntryCreationInfo<>(Codec.PASSTHROUGH, ComponentInfo.empty()) + )) + .add(MinecraftKeys.ITEM, ConfigScreenEntry.single( + Widgets.pickWidget(new StringRepresentation<>( + () -> BuiltInRegistries.ITEM.listElements().filter(e -> e.value() != Items.AIR).>map(Function.identity()).toList(), + holder -> { + if (holder instanceof Holder.Reference reference) { + return reference.key().location().toString(); + } else { + var value = holder.value(); + return BuiltInRegistries.ITEM.getKey(value).toString(); + } + }, + string -> { + var rlResult = ResourceLocation.read(string); + if (rlResult.error().isPresent()) { + return null; + } + var key = rlResult.getOrThrow(); + return BuiltInRegistries.ITEM.get(key).orElse(null); + }, + false + )), + new EntryCreationInfo<>(Item.CODEC, ComponentInfo.empty()) + )) + .add(MinecraftKeys.RESOURCE_LOCATION, ConfigScreenEntry.single( + Widgets.wrapWithOptionalHandling(Widgets.text(ResourceLocation::read, rl -> DataResult.success(rl.toString()), string -> string.matches("^([a-z0-9._-]+:)?[a-z0-9/._-]*$"), false)), + new EntryCreationInfo<>(ResourceLocation.CODEC, ComponentInfo.empty()) + )) + .add(MinecraftKeys.ARGB_COLOR, ConfigScreenEntry.single( + Widgets.color(true), + new EntryCreationInfo<>(Codec.INT, ComponentInfo.empty()) + )) + .add(MinecraftKeys.RGB_COLOR, ConfigScreenEntry.single( + Widgets.color(false), + new EntryCreationInfo<>(Codec.INT, ComponentInfo.empty()) + )) + .add(MinecraftKeys.DATA_COMPONENT_PATCH_KEY, ConfigScreenEntry.single( + (parent, width, context, original, update, creationInfo, handleOptional) -> { + boolean[] removes = new boolean[1]; + DataComponentType[] type = new DataComponentType[1]; + var problems = new EntryCreationContext.ProblemMarker[2]; + if (!original.isJsonNull()) { + var initialResult = creationInfo.codec().parse(context.ops(), original); + if (initialResult.error().isPresent()) { + LOGGER.error("Error parsing data component patch key: {}", initialResult.error().get()); + } else { + removes[0] = initialResult.getOrThrow().removes(); + type[0] = initialResult.getOrThrow().type(); + } + } + var remainingWidth = width - Button.DEFAULT_HEIGHT - 5; + var cycle = CycleButton.builder(bool -> { + if (bool == Boolean.TRUE) { + return Component.translatable("codecextras.config.datacomponent.keytoggle.removes"); + } + return Component.empty(); + }).withValues(List.of(true, false)) + .withInitialValue(removes[0]) + .displayOnlyValue() + .create(0, 0, Button.DEFAULT_HEIGHT, Button.DEFAULT_HEIGHT, Component.translatable("codecextras.config.datacomponent.keytoggle"), (b, bool) -> { + removes[0] = bool; + if (type[0] != null) { + var result = creationInfo.codec().encodeStart(context.ops(), new MinecraftKeys.DataComponentPatchKey<>(type[0], removes[0])); + if (result.error().isPresent()) { + problems[0] = context.problem(problems[0], "Error encoding data component patch key: "+result.error().get().message()); + } else { + context.resolve(problems[0]); + update.accept(result.getOrThrow()); + } + } + }); + var typeKeys = BuiltInRegistries.DATA_COMPONENT_TYPE.registryKeySet().stream().toList(); + var actual = Widgets.pickWidget( + new StringRepresentation<>(() -> typeKeys, key -> key.location().toString()) + ).create(parent, remainingWidth, context, type[0] == null ? JsonNull.INSTANCE : new JsonPrimitive(BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(type[0]).toString()), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isString()) { + String string = json.getAsJsonPrimitive().getAsString(); + var rlResult = ResourceLocation.read(string); + if (rlResult.error().isPresent()) { + problems[1] = context.problem(problems[1], "Error reading resource location: "+rlResult.error().get().message()); + return; + } + var key = rlResult.getOrThrow(); + var typeResult = BuiltInRegistries.DATA_COMPONENT_TYPE.getOptional(key); + if (typeResult.isEmpty()) { + problems[1] = context.problem(problems[1], "Unknown data component type: "+key); + return; + } + type[0] = typeResult.get(); + var result = creationInfo.codec().encodeStart(context.ops(), new MinecraftKeys.DataComponentPatchKey<>(type[0], removes[0])); + if (result.error().isPresent()) { + problems[1] = context.problem(problems[1], "Error encoding data component patch key: "+result.error().get().message()); + } else { + context.resolve(problems[1]); + update.accept(result.getOrThrow()); + } + } else { + problems[1] = context.problem(problems[1], "Not a string: "+json); + } + }, creationInfo.withCodec(ResourceKey.codec(Registries.DATA_COMPONENT_TYPE)), false); + var tooltipToggle = Tooltip.create(Component.translatable("codecextras.config.datacomponent.keytoggle")); + var tooltipType = Tooltip.create(creationInfo.componentInfo().description()); + cycle.setTooltip(tooltipToggle); + actual.visitWidgets(w -> w.setTooltip(tooltipType)); + var layout = new EqualSpacingLayout(width, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + layout.addChild(cycle, LayoutSettings.defaults().alignVerticallyMiddle()); + layout.addChild(actual, LayoutSettings.defaults().alignVerticallyMiddle()); + return layout; + }, + new EntryCreationInfo<>(MinecraftInterpreters.CODEC_INTERPRETER.interpret(MinecraftStructures.DATA_COMPONENT_PATCH_KEY).getOrThrow(), ComponentInfo.empty()) + )).build()), + parametricKeys.join(Keys2., K1, K1>builder() + .add(Interpreter.INT_IN_RANGE, new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + var codec = Codec.INT.validate(Codec.checkRange(range.min(), range.max())); + return ConfigScreenEntry.single( + Widgets.slider(range, i -> DataResult.success(new JsonPrimitive(i)), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) { + try { + return DataResult.success(json.getAsJsonPrimitive().getAsInt()); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not an integer: " + json); + } + } + return DataResult.error(() -> "Not an integer: " + json); + }, false), new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo( + info -> info.withCodec(codec.xmap(Const::create, Const::unbox)), + info -> info.withCodec(codec) + ); + } + }) + .add(Interpreter.BYTE_IN_RANGE, new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + var codec = Codec.BYTE.validate(Codec.checkRange(range.min(), range.max())); + return ConfigScreenEntry.single( + Widgets.slider(range, i -> DataResult.success(new JsonPrimitive(i)), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) { + try { + return DataResult.success(json.getAsJsonPrimitive().getAsByte()); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a byte: " + json); + } + } + return DataResult.error(() -> "Not a byte: " + json); + }, false), new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo( + info -> info.withCodec(codec.xmap(Const::create, Const::unbox)), + info -> info.withCodec(codec) + ); + } + }) + .add(Interpreter.SHORT_IN_RANGE, new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + var codec = Codec.SHORT.validate(Codec.checkRange(range.min(), range.max())); + return ConfigScreenEntry.single( + Widgets.slider(range, i -> DataResult.success(new JsonPrimitive(i)), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) { + try { + return DataResult.success(json.getAsJsonPrimitive().getAsShort()); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a short: " + json); + } + } + return DataResult.error(() -> "Not a short: " + json); + }, false), new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo( + info -> info.withCodec(codec.xmap(Const::create, Const::unbox)), + info -> info.withCodec(codec) + ); + } + }) + .add(Interpreter.LONG_IN_RANGE, new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + var codec = Codec.LONG.validate(Codec.checkRange(range.min(), range.max())); + return ConfigScreenEntry.single( + Widgets.slider(range, i -> DataResult.success(new JsonPrimitive(i)), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) { + try { + return DataResult.success(json.getAsJsonPrimitive().getAsLong()); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a long: " + json); + } + } + return DataResult.error(() -> "Not a long: " + json); + }, false), new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo( + info -> info.withCodec(codec.xmap(Const::create, Const::unbox)), + info -> info.withCodec(codec) + ); + } + }) + .add(Interpreter.FLOAT_IN_RANGE, new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + var codec = Codec.FLOAT.validate(Codec.checkRange(range.min(), range.max())); + return ConfigScreenEntry.single( + Widgets.slider(range, i -> DataResult.success(new JsonPrimitive(i)), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) { + try { + return DataResult.success(json.getAsJsonPrimitive().getAsFloat()); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a float: " + json); + } + } + return DataResult.error(() -> "Not a float: " + json); + }, true), new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo( + info -> info.withCodec(codec.xmap(Const::create, Const::unbox)), + info -> info.withCodec(codec) + ); + } + }) + .add(Interpreter.DOUBLE_IN_RANGE, new ParametricKeyedValue<>() { + @Override + public App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + var codec = Codec.DOUBLE.validate(Codec.checkRange(range.min(), range.max())); + return ConfigScreenEntry.single( + Widgets.slider(range, i -> DataResult.success(new JsonPrimitive(i)), json -> { + if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) { + try { + return DataResult.success(json.getAsJsonPrimitive().getAsDouble()); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a double: " + json); + } + } + return DataResult.error(() -> "Not a double: " + json); + }, true), new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo( + info -> info.withCodec(codec.xmap(Const::create, Const::unbox)), + info -> info.withCodec(codec) + ); + } + }) + .add(Interpreter.STRING_REPRESENTABLE, new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + var representation = StringRepresentation.unbox(parameter); + var codec = representation.codec(); + var identityCodec = codec.>xmap(Identity::new, app -> Identity.unbox(app).value()); + return ConfigScreenEntry.single( + Widgets.pickWidget(representation), + new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo(i -> i.withCodec(identityCodec), i -> i.withCodec(codec)); + } + }) + .add(MinecraftKeys.RESOURCE_KEY, new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + var registryKey = MinecraftKeys.RegistryKeyHolder.unbox(parameter).value(); + var codec = ResourceKey.codec(registryKey); + var holderCodec = codec.>xmap(MinecraftKeys.ResourceKeyHolder::new, app -> MinecraftKeys.ResourceKeyHolder.unbox(app).value()); + return ConfigScreenEntry.single( + (parent, width, context, original, update, creationInfo, handleOptional) -> { + var registry = context.registryAccess().lookup(registryKey); + LayoutFactory> wrapped; + Function, String> mapper = key -> key.location().toString(); + if (registry.isPresent()) { + Supplier>> values = () -> registry.get().registryKeySet().stream().sorted(Comparator., String>comparing(key -> key.location().getNamespace()).thenComparing(key -> key.location().getPath())).toList(); + wrapped = Widgets.pickWidget(new StringRepresentation<>(values, mapper)); + } else { + wrapped = (parent2, width2, context2, original2, update2, creationInfo2, handleOptional2) -> Widgets.text( + ResourceLocation::read, + rl -> DataResult.success(rl.toString()), + string -> string.matches("^([a-z0-9._-]+:)?[a-z0-9/._-]*$"), + false + ).create(parent2, width2, context2, original2, update2, creationInfo2.withCodec(ResourceLocation.CODEC), handleOptional2); + } + return wrapped.create(parent, width, context, original, update, creationInfo, handleOptional); + }, + new EntryCreationInfo<>(codec, ComponentInfo.empty()) + ).withEntryCreationInfo(i -> i.withCodec(holderCodec), i -> i.withCodec(codec)); + } + }) + .add(MinecraftKeys.FALLBACK_DATA_COMPONENT_TYPE, new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + var type = MinecraftKeys.DataComponentTypeHolder.unbox(parameter).value(); + var codec = type.codec(); + if (codec == null) { + LOGGER.error("{} is not a persistent component", type); + return ConfigScreenEntry.single( + (parent, width, context, original, update, creationInfo, handleOptional) -> { + var button = Button.builder( + Component.translatable("codecextras.config.datacomponent.notpersistent", type), + b -> { + } + ).width(width).build(); + button.active = false; + return button; + }, + new EntryCreationInfo<>(Codec.EMPTY.codec().flatXmap( + ignored -> DataResult.error(() -> type + " is not a persistent component"), + ignored -> DataResult.error(() -> type + " is not a persistent component") + ), ComponentInfo.empty()) + ); + } + var identityCodec = codec.>xmap(Identity::new, app -> Identity.unbox(app).value()); + return ConfigScreenEntry.single( + Widgets.wrapWithOptionalHandling(ConfigScreenInterpreter::byJson), + new EntryCreationInfo<>(identityCodec, ComponentInfo.empty()) + ); + } + }) + .build()) + ); + this.codecInterpreter = codecInterpreter; + } + + private static final Codec BIG_DECIMAL_CODEC = new PrimitiveCodec<>() { + @Override + public DataResult read(final DynamicOps ops, final T input) { + return ops + .getNumberValue(input) + .map(number -> number instanceof BigDecimal bigDecimal ? bigDecimal : new BigDecimal(number.toString())); + } + + @Override + public T write(final DynamicOps ops, final BigDecimal value) { + return ops.createNumeric(value); + } + + @Override + public String toString() { + return "BigDecimal"; + } + }; + + private static LayoutElement byJson(Screen parentOuter, int widthOuter, EntryCreationContext contextOuter, JsonElement originalOuter, Consumer updateOuter, EntryCreationInfo creationInfoOuter, boolean handleOptionalOuter) { + var entryHolder = new Object() { + final EntryCreationInfo jsonInfo = new EntryCreationInfo<>( + Codec.PASSTHROUGH.xmap(d -> d.convert(contextOuter.ops()).getValue(), v -> new Dynamic<>(contextOuter.ops(), v)), + ComponentInfo.empty() + ); + final EntryCreationInfo stringInfo = new EntryCreationInfo<>( + Codec.STRING, + ComponentInfo.empty() + ); + final EntryCreationInfo numberInfo = new EntryCreationInfo<>( + BIG_DECIMAL_CODEC.xmap(Function.identity(), number -> number instanceof BigDecimal bigDecimal ? bigDecimal : new BigDecimal(number.toString())), + ComponentInfo.empty() + ); + final EntryCreationInfo booleanInfo = new EntryCreationInfo<>( + Codec.BOOL, + ComponentInfo.empty() + ); + final ConfigScreenEntry stringEntry = ConfigScreenEntry.single( + Widgets.text(DataResult::success, DataResult::success, false), + stringInfo + ); + final ConfigScreenEntry numberEntry = ConfigScreenEntry.single( + Widgets.text(s -> { + if (s.isEmpty()) { + return DataResult.success(BigDecimal.ZERO); + } + try { + return DataResult.success(new BigDecimal(s)); + } catch (NumberFormatException e) { + return DataResult.error(() -> "Not a number: "+s); + } + }, n -> DataResult.success(n.toString()), s -> s.matches("^-?[0-9]*(\\.[0-9]*)?$"), false), + numberInfo + ); + final ConfigScreenEntry booleanEntry = ConfigScreenEntry.single( + Widgets.bool(), + booleanInfo + ); + static final Gson GSON = new GsonBuilder().create(); + final ConfigScreenEntry rawJsonEntry = ConfigScreenEntry.single( + Widgets.text(s -> { + try { + return DataResult.success(GSON.fromJson(s, JsonElement.class)); + } catch (JsonSyntaxException e) { + return DataResult.error(() -> "Invalid JSON `"+s+"`: "+e.getMessage()); + } + }, n -> DataResult.success(n.toString()), false), + jsonInfo + ); + final ConfigScreenEntry jsonEntry = ConfigScreenEntry.single( + (parent, width, context, original, update, creationInfo, handleOptional) -> { + enum JsonType { + OBJECT("codecextras.config.json.object"), + ARRAY("codecextras.config.json.array"), + STRING("codecextras.config.json.string"), + NUMBER("codecextras.config.json.number"), + BOOLEAN("codecextras.config.json.boolean"), + RAW("codecextras.config.json.raw"); + + private final Component component; + + + JsonType(String translationKey) { + component = Component.translatable(translationKey); + } + + static @Nullable JsonType of(JsonElement element) { + if (element.isJsonObject()) { + return OBJECT; + } + if (element.isJsonArray()) { + return ARRAY; + } + if (element.isJsonPrimitive()) { + var primitive = element.getAsJsonPrimitive(); + if (primitive.isString()) { + return STRING; + } + if (primitive.isNumber()) { + return NUMBER; + } + if (primitive.isBoolean()) { + return BOOLEAN; + } + } + return null; + } + } + + if (original.isJsonNull()) { + original = new JsonObject(); + if (!handleOptional) { + update.accept(original); + } + } + + var remainingWidth = width - Button.DEFAULT_HEIGHT - 5; + JsonType[] type = new JsonType[] {JsonType.of(original)}; + if (type[0] == null) { + type[0] = JsonType.OBJECT; + } + Map elements = new EnumMap<>(JsonType.class); + elements.put(JsonType.RAW, original); + elements.put(JsonType.OBJECT, new JsonObject()); + elements.put(JsonType.ARRAY, new JsonArray()); + elements.put(JsonType.STRING, new JsonPrimitive("")); + elements.put(JsonType.NUMBER, new JsonPrimitive(0)); + elements.put(JsonType.BOOLEAN, new JsonPrimitive(false)); + elements.put(type[0], original); + var outerEntryHolder = this; + var holder = new Object() { + private final Runnable onTypeUpdate = () -> { + var currentType = type[0]; + var component = currentType.component; + var tooltip = Tooltip.create(component); + this.cycle.setTooltip(tooltip); + for (var entry : this.layouts.entrySet()) { + var layout = entry.getValue(); + if (entry.getKey() == currentType) { + layout.visitWidgets(w -> { + w.visible = true; + w.active = true; + }); + } else { + layout.visitWidgets(w -> { + w.visible = false; + w.active = false; + }); + } + } + }; + private final EntryCreationContext.ProblemMarker[] problems = new EntryCreationContext.ProblemMarker[1]; + private final Consumer checkedUpdate = newJsonValue -> creationInfo.codec().parse(context.ops(), newJsonValue).ifError(error -> { + problems[0] = context.problem(problems[0], "Could not encode: "+error.message()); + }).ifSuccess(json -> { + context.resolve(problems[0]); + update.accept(json); + }); + private final CycleButton cycle = CycleButton.builder(t -> Component.empty()) + .withValues(List.of(JsonType.RAW, JsonType.OBJECT, JsonType.ARRAY, JsonType.STRING, JsonType.NUMBER, JsonType.BOOLEAN)) + .withInitialValue(type[0] == null ? JsonType.OBJECT : type[0]) + .displayOnlyValue() + .create(0, 0, Button.DEFAULT_HEIGHT, Button.DEFAULT_HEIGHT, Component.translatable("codecextras.config.json.type"), (b, t) -> { + type[0] = t; + onTypeUpdate.run(); + checkedUpdate.accept(elements.get(type[0])); + }); + private final Map layouts = new EnumMap<>(JsonType.class); + + { + layouts.put(JsonType.OBJECT, VisibilityWrapperElement.ofDirect(Button.builder(Component.translatable("codecextras.config.configurerecord"), b -> { + Minecraft.getInstance().setScreen(ScreenEntryProvider.create( + new UnboundedMapScreenEntryProvider<>(stringEntry, jsonEntry, context, elements.get(JsonType.OBJECT), newJsonValue -> { + elements.put(JsonType.OBJECT, newJsonValue); + checkedUpdate.accept(newJsonValue); + }), parent, context, creationInfo.componentInfo() + )); + }).width(remainingWidth).build())); + layouts.put(JsonType.ARRAY, Button.builder(Component.translatable("codecextras.config.configurelist"), b -> { + Minecraft.getInstance().setScreen(ScreenEntryProvider.create( + new ListScreenEntryProvider<>(jsonEntry, context, elements.get(JsonType.ARRAY), newJsonValue -> { + elements.put(JsonType.ARRAY, newJsonValue); + checkedUpdate.accept(newJsonValue); + }), parent, context, creationInfo.componentInfo() + )); + }).width(remainingWidth).build()); + layouts.put(JsonType.STRING, stringEntry.layout().create(parent, remainingWidth, context, elements.get(JsonType.STRING), newJsonValue -> { + elements.put(JsonType.STRING, newJsonValue); + checkedUpdate.accept(newJsonValue); + }, outerEntryHolder.stringInfo, false)); + layouts.put(JsonType.NUMBER, numberEntry.layout().create(parent, remainingWidth, context, elements.get(JsonType.NUMBER), newJsonValue -> { + elements.put(JsonType.NUMBER, newJsonValue); + checkedUpdate.accept(newJsonValue); + }, outerEntryHolder.numberInfo, false)); + layouts.put(JsonType.BOOLEAN, booleanEntry.layout().create(parent, remainingWidth, context, elements.get(JsonType.BOOLEAN), newJsonValue -> { + elements.put(JsonType.BOOLEAN, newJsonValue); + checkedUpdate.accept(newJsonValue); + }, outerEntryHolder.booleanInfo, false)); + layouts.put(JsonType.RAW, outerEntryHolder.rawJsonEntry.layout().create(parent, remainingWidth, context, elements.get(JsonType.RAW), newJsonValue -> { + elements.put(JsonType.RAW, newJsonValue); + checkedUpdate.accept(newJsonValue); + }, outerEntryHolder.jsonInfo, false)); + onTypeUpdate.run(); + } + }; + var layout = new EqualSpacingLayout(width, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + var frame = new FrameLayout(remainingWidth, Button.DEFAULT_HEIGHT); + for (var entry : holder.layouts.entrySet()) { + var layoutElement = entry.getValue(); + frame.addChild(layoutElement, LayoutSettings.defaults().alignVerticallyMiddle()); + } + layout.addChild(holder.cycle, LayoutSettings.defaults().alignVerticallyMiddle()); + layout.addChild(frame, LayoutSettings.defaults().alignVerticallyMiddle()); + return layout; + }, + jsonInfo + ); + }; + Codec jsonWrappingCodec = entryHolder.jsonInfo.codec().validate(json -> creationInfoOuter.codec().parse(contextOuter.ops(), json).map(t -> json)); + return entryHolder.jsonEntry.layout().create( + parentOuter, widthOuter, contextOuter, originalOuter, updateOuter, creationInfoOuter.withCodec(jsonWrappingCodec), handleOptionalOuter + ); + } + + public ConfigScreenInterpreter( + CodecInterpreter codecInterpreter + ) { + this( + Keys.builder().build(), + Keys2., K1, K1>builder().build(), + codecInterpreter + ); + } + + public static final Key KEY = Key.create("ConfigScreenInterpreter"); + + @Override + public Stream> keyConsumers() { + return Stream.of( + new KeyConsumer() { + @Override + public Key key() { + return KEY; + } + + @Override + public App convert(App input) { + return input; + } + } + ); + } + + @Override + public DataResult>> either(App left, App right) { + var codecLeft = ConfigScreenEntry.unbox(left).entryCreationInfo().codec(); + var codecRight = ConfigScreenEntry.unbox(right).entryCreationInfo().codec(); + var codecResult = codecInterpreter.either(new CodecInterpreter.Holder<>(codecLeft), new CodecInterpreter.Holder<>(codecRight)).map(CodecInterpreter::unbox); + if (codecResult.isError()) { + return DataResult.error(codecResult.error().orElseThrow().messageSupplier()); + } + return DataResult.success(ConfigScreenEntry.single( + Widgets.either( + ConfigScreenEntry.unbox(left).layout(), + ConfigScreenEntry.unbox(right).layout() + ), + new EntryCreationInfo<>(codecResult.getOrThrow(), ComponentInfo.empty()) + )); + } + + @Override + public DataResult>> xor(App left, App right) { + var codecLeft = ConfigScreenEntry.unbox(left).entryCreationInfo().codec(); + var codecRight = ConfigScreenEntry.unbox(right).entryCreationInfo().codec(); + var codecResult = codecInterpreter.xor(new CodecInterpreter.Holder<>(codecLeft), new CodecInterpreter.Holder<>(codecRight)).map(CodecInterpreter::unbox); + if (codecResult.isError()) { + return DataResult.error(codecResult.error().orElseThrow().messageSupplier()); + } + return DataResult.success(ConfigScreenEntry.single( + Widgets.either( + ConfigScreenEntry.unbox(left).layout(), + ConfigScreenEntry.unbox(right).layout() + ), + new EntryCreationInfo<>(codecResult.getOrThrow(), ComponentInfo.empty()) + )); + } + + @Override + public DataResult> bounded(Structure inputSupplier, Supplier> values) { + return interpret(inputSupplier).flatMap(input -> { + var codec = codecInterpreter.bounded(inputSupplier, values).map(CodecInterpreter::unbox); + if (codec.isError()) { + return DataResult.error(codec.error().orElseThrow().messageSupplier()); + } + return DataResult.success(ConfigScreenEntry.single( + (parent, width, context, original, update, creationInfo, handleOptional) -> { + List knownValues = new ArrayList<>(); + Map stringValues = new HashMap<>(); + Map inverse = new HashMap<>(); + for (var value : values.get()) { + var encoded = codec.getOrThrow().encodeStart(context.ops(), value); + if (encoded.error().isPresent()) { + LOGGER.error("Error encoding value `{}`: {}", value, encoded.error().get()); + continue; + } + String string; + var result = encoded.getOrThrow(); + if (result.isJsonPrimitive()) { + string = result.getAsString(); + } else { + string = result.toString(); + } + knownValues.add(value); + stringValues.put(value, string); + inverse.put(string, value); + } + var wrapped = Widgets.pickWidget(new StringRepresentation<>(() -> knownValues, stringValues::get, inverse::get, false)); + return wrapped.create(parent, width, context, original, update, creationInfo, handleOptional); + }, + ConfigScreenEntry.unbox(input).entryCreationInfo().withCodec(codec.getOrThrow()) + )); + }); + } + + @Override + public ConfigScreenInterpreter with(Keys keys, Keys2, K1, K1> parametricKeys) { + return new ConfigScreenInterpreter(keys().join(keys), parametricKeys().join(parametricKeys), this.codecInterpreter); + } + + @Override + public DataResult>> list(App single) { + var unwrapped = ConfigScreenEntry.unbox(single); + var codecResult = codecInterpreter.list(new CodecInterpreter.Holder<>(unwrapped.entryCreationInfo().codec())).map(CodecInterpreter::unbox); + if (codecResult.isError()) { + return DataResult.error(codecResult.error().orElseThrow().messageSupplier()); + } + ScreenEntryFactory> factory = (context, original, onClose, creationInfo) -> + new ListScreenEntryProvider<>(unwrapped, context, original, onClose); + return DataResult.success(new ConfigScreenEntry<>( + Widgets.wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonArray(); + if (!handleOptional) { + update.accept(original); + } + } + JsonElement[] finalOriginal = new JsonElement[] {original}; + var subContext = context.subContext(); + return Button.builder( + Component.translatable("codecextras.config.configurelist"), + b -> Minecraft.getInstance().setScreen(ScreenEntryProvider.create(factory.openChecked( + subContext, finalOriginal[0], value -> { + finalOriginal[0] = value; + update.accept(value); + }, creationInfo + ), parent, subContext, creationInfo.componentInfo())) + ).width(width).build(); + }), + factory, + new EntryCreationInfo<>(codecResult.getOrThrow(), ComponentInfo.empty()) + )); + } + + @Override + public DataResult>> unboundedMap(App key, App value) { + var unwrappedKey = ConfigScreenEntry.unbox(key); + var unwrappedValue = ConfigScreenEntry.unbox(value); + var codecResult = codecInterpreter.unboundedMap(new CodecInterpreter.Holder<>(unwrappedKey.entryCreationInfo().codec()), new CodecInterpreter.Holder<>(unwrappedValue.entryCreationInfo().codec())).map(CodecInterpreter::unbox); + if (codecResult.isError()) { + return DataResult.error(codecResult.error().orElseThrow().messageSupplier()); + } + ScreenEntryFactory> factory = (context, original, onClose, creationInfo) -> + new UnboundedMapScreenEntryProvider<>(unwrappedKey, unwrappedValue, context, original, onClose); + return DataResult.success(new ConfigScreenEntry<>( + Widgets.wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonObject(); + if (!handleOptional) { + update.accept(original); + } + } + JsonElement[] finalOriginal = new JsonElement[] {original}; + var subContext = context.subContext(); + return Button.builder( + Component.translatable("codecextras.config.configurelist"), + b -> Minecraft.getInstance().setScreen(ScreenEntryProvider.create(factory.openChecked( + subContext, finalOriginal[0], jsonValue -> { + finalOriginal[0] = jsonValue; + update.accept(jsonValue); + }, creationInfo + ), parent, subContext, creationInfo.componentInfo())) + ).width(width).build(); + }), + factory, + new EntryCreationInfo<>(codecResult.getOrThrow(), ComponentInfo.empty()) + )); + } + + @Override + public DataResult> record(List> fields, Function creator) { + List> entries = new ArrayList<>(); + List> errors = new ArrayList<>(); + for (var field : fields) { + handleEntry(field, entries, errors); + } + if (!errors.isEmpty()) { + return DataResult.error(() -> "Errors crating record screen: "+errors.stream().map(Supplier::get).collect(Collectors.joining(", "))); + } + ScreenEntryFactory factory = (context, original, onClose, creationInfo) -> + new RecordScreenEntryProvider(entries, context, original, onClose); + var codecResult = codecInterpreter.record(fields, creator); + if (codecResult.isError()) { + return DataResult.error(() -> "Error creating record codec: "+codecResult.error().orElseThrow().messageSupplier()); + } + return DataResult.success(new ConfigScreenEntry<>( + Widgets.wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonObject(); + if (!handleOptional) { + update.accept(original); + } + } + JsonElement[] finalOriginal = new JsonElement[] {original}; + var subContext = context.subContext(); + return Button.builder( + Component.translatable("codecextras.config.configurerecord"), + b -> Minecraft.getInstance().setScreen(ScreenEntryProvider.create(factory.openChecked( + subContext, finalOriginal[0], value -> { + finalOriginal[0] = value; + update.accept(value); + }, creationInfo + ), parent, subContext, creationInfo.componentInfo())) + ).width(width).build(); + }), + factory, + new EntryCreationInfo<>(CodecInterpreter.unbox(codecResult.getOrThrow()), ComponentInfo.empty()) + )); + } + + private void handleEntry(RecordStructure.Field field, List> entries, List> errors) { + var codecResult = codecInterpreter.interpret(field.structure()); + if (codecResult.isError()) { + errors.add(codecResult.error().orElseThrow().messageSupplier()); + return; + } + var optionEntryResult = field.structure().interpret(this).map(ConfigScreenEntry::unbox); + if (optionEntryResult.isError()) { + errors.add(optionEntryResult.error().orElseThrow().messageSupplier()); + return; + } + entries.add(new RecordEntry<>( + field.name(), + optionEntryResult.getOrThrow().withComponentInfo(info -> info.fallbackTitle(Component.literal(field.name()))), + field.missingBehavior(), + codecResult.getOrThrow() + )); + } + + @Override + public DataResult> flatXmap(App input, Function> to, Function> from) { + var original = ConfigScreenEntry.unbox(input); + var codecOriginal = original.entryCreationInfo().codec(); + var codecMapped = codecInterpreter.flatXmap(new CodecInterpreter.Holder<>(codecOriginal), to, from).map(CodecInterpreter::unbox); + if (codecMapped.error().isPresent()) { + return DataResult.error(codecMapped.error().get().messageSupplier()); + } + return DataResult.success(original.withEntryCreationInfo( + info -> info.withCodec(codecMapped.getOrThrow()), + info -> info.withCodec(codecOriginal) + )); + } + + @Override + public DataResult> annotate(Structure original, Keys annotations) { + var result = original.interpret(this); + var codecResult = codecInterpreter.annotate(original, annotations).map(CodecInterpreter::unbox); + if (codecResult.isError()) { + return DataResult.error(codecResult.error().orElseThrow().messageSupplier()); + } + return result.map(app -> { + var originalCodec = ConfigScreenEntry.unbox(app).entryCreationInfo().codec(); + var entry = ConfigScreenEntry.unbox(app).withEntryCreationInfo(info -> info.withCodec(codecResult.getOrThrow()), info -> info.withCodec(originalCodec)); + var withTitle = Annotation + .get(annotations, ConfigAnnotations.TITLE) + .or(() -> Annotation.get(annotations, Annotation.TITLE).map(Component::literal)) + .map(title -> entry.withComponentInfo(info -> info.withTitle(title))) + .orElse(entry); + return Annotation + .get(annotations, ConfigAnnotations.DESCRIPTION) + .or(() -> Annotation.get(annotations, Annotation.DESCRIPTION).map(Component::literal)) + .or(() -> Annotation.get(annotations, Annotation.COMMENT).map(Component::literal)) + .map(description -> withTitle.withComponentInfo(info -> info.withDescription(description))) + .orElse(withTitle); + }); + } + + @Override + public DataResult> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures) { + var keyResult = interpret(keyStructure).map(entry -> entry.withComponentInfo(info -> info.fallbackTitle(Component.literal(key)))); + if (keyResult.error().isPresent()) { + return DataResult.error(keyResult.error().get().messageSupplier()); + } + Supplier>>>> entries = Suppliers.memoize(() -> { + Map>>> map = new HashMap<>(); + for (var entryKey : keys.get()) { + map.put(entryKey, Suppliers.memoize(() -> structures.apply(entryKey).flatMap(it -> it.interpret(this)).map(ConfigScreenEntry::unbox))); + } + return map; + }); + var codecResult = codecInterpreter.dispatch(key, keyStructure, function, keys, structures); + if (codecResult.isError()) { + return DataResult.error(() -> "Error creating dispatch codec: "+codecResult.error().orElseThrow().messageSupplier()); + } + ScreenEntryFactory factory = (context, original, onClose, creationInfo) -> + new DispatchScreenEntryProvider<>(keyResult.getOrThrow(), original, key, onClose, context, entries.get()); + return DataResult.success(new ConfigScreenEntry<>( + Widgets.wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonObject(); + if (!handleOptional) { + update.accept(original); + } + } + JsonElement[] finalOriginal = new JsonElement[] {original}; + var subContext = context.subContext(); + return Button.builder( + Component.translatable("codecextras.config.configurerecord"), + b -> Minecraft.getInstance().setScreen(ScreenEntryProvider.create(factory.openChecked( + subContext, finalOriginal[0], value -> { + finalOriginal[0] = value; + update.accept(value); + }, creationInfo + ), parent, subContext, creationInfo.componentInfo())) + ).width(width).build(); + }), + factory, + new EntryCreationInfo<>(CodecInterpreter.unbox(codecResult.getOrThrow()), ComponentInfo.empty()) + )); + } + + @Override + public DataResult>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures) { + var keyResult = interpret(keyStructure); + if (keyResult.error().isPresent()) { + return DataResult.error(keyResult.error().get().messageSupplier()); + } + Supplier>>>> entries = Suppliers.memoize(() -> { + Map>>> map = new HashMap<>(); + for (var entryKey : keys.get()) { + map.put(entryKey, Suppliers.memoize(() -> valueStructures.apply(entryKey).flatMap(it -> it.interpret(this)).map(ConfigScreenEntry::unbox))); + } + return map; + }); + var codecResult = codecInterpreter.dispatchedMap(keyStructure, keys, valueStructures); + if (codecResult.isError()) { + return DataResult.error(() -> "Error creating dispatch codec: "+codecResult.error().orElseThrow().messageSupplier()); + } + ScreenEntryFactory> factory = (context, original, onClose, creationInfo) -> + new DispatchedMapScreenEntryProvider<>(keyResult.getOrThrow(), original, onClose, context, entries.get()); + return DataResult.success(new ConfigScreenEntry<>( + Widgets.wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonObject(); + if (!handleOptional) { + update.accept(original); + } + } + JsonElement[] finalOriginal = new JsonElement[] {original}; + var subContext = context.subContext(); + return Button.builder( + Component.translatable("codecextras.config.configurerecord"), + b -> Minecraft.getInstance().setScreen(ScreenEntryProvider.create(factory.openChecked( + subContext, finalOriginal[0], value -> { + finalOriginal[0] = value; + update.accept(value); + }, creationInfo + ), parent, subContext, creationInfo.componentInfo())) + ).width(width).build(); + }), + factory, + new EntryCreationInfo<>(CodecInterpreter.unbox(codecResult.getOrThrow()), ComponentInfo.empty()) + )); + } + + public DataResult> interpret(Structure structure) { + return structure.interpret(this).map(ConfigScreenEntry::unbox); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/DispatchScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/DispatchScreenEntryProvider.java new file mode 100644 index 0000000..c04d35a --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/DispatchScreenEntryProvider.java @@ -0,0 +1,134 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.DataResult; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +class DispatchScreenEntryProvider implements ScreenEntryProvider { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final ConfigScreenEntry keyEntry; + private final String key; + private JsonElement keyValue = JsonNull.INSTANCE; + private JsonElement oldKeyValue = JsonNull.INSTANCE; + private JsonObject jsonValue; + private final Consumer update; + private final List keys; + private final Map>>> keyProviders; + private final EntryCreationContext context; + + public DispatchScreenEntryProvider(ConfigScreenEntry keyEntry, JsonElement jsonValue, String key, Consumer update, EntryCreationContext context, Map>>> entries) { + this.keyEntry = keyEntry; + this.key = key; + if (jsonValue.isJsonObject()) { + this.jsonValue = jsonValue.getAsJsonObject(); + } else { + if (!jsonValue.isJsonNull()) { + LOGGER.error("Value {} was not a JSON object", jsonValue); + } + this.jsonValue = new JsonObject(); + } + this.update = update; + this.context = context; + this.keys = new ArrayList<>(); + this.keyProviders = new HashMap<>(); + for (var entry : entries.entrySet()) { + var keyResult = keyEntry.entryCreationInfo().codec().encodeStart(context.ops(), entry.getKey()); + if (keyResult.isError()) { + LOGGER.error("Failed to encode key {}", entry.getKey()); + continue; + } + JsonElement keyElement = keyResult.getOrThrow(); + keyProviders.put(keyElement, entry.getValue()); + this.keys.add(keyElement); + } + this.keys.sort(JsonComparator.INSTANCE); + if (this.jsonValue.has(key)) { + this.keyValue = this.jsonValue.get(key); + } + } + + @Override + public void onExit(EntryCreationContext context) { + if (nestedOnExit != null) { + nestedOnExit.accept(context); + } + update.accept(jsonValue); + } + + private @Nullable Consumer nestedOnExit; + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + var label = new StringWidget(Button.DEFAULT_WIDTH, Button.DEFAULT_HEIGHT, keyEntry.entryCreationInfo().componentInfo().title(), Minecraft.getInstance().font).alignLeft(); + var contents = keyEntry.layout().create(parent, Button.DEFAULT_WIDTH, context, keyValue, newKeyValue -> { + if (!Objects.equals(newKeyValue, oldKeyValue)) { + keyValue = newKeyValue; + boolean shouldRebuild = keyProviders.containsKey(keyValue) && !Objects.equals(oldKeyValue, keyValue); + if (shouldRebuild) { + oldKeyValue = keyValue; + jsonValue = new JsonObject(); + } + if (!keyValue.isJsonNull()) { + jsonValue.add(key, keyValue); + } else { + jsonValue.remove(key); + } + if (shouldRebuild) { + rebuild.run(); + } + } + }, keyEntry.entryCreationInfo(), false); + keyEntry.entryCreationInfo().componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + label.setTooltip(tooltip); + }); + list.addPair(label, contents); + if (!keyValue.isJsonNull()) { + var provider = keyProviders.get(keyValue); + if (provider != null) { + var entry = provider.get(); + if (entry.isError()) { + LOGGER.error("Failed to create screen entry for key {}: {}", keyValue, entry.error().orElseThrow().message()); + } else { + JsonObject valueCopy = new JsonObject(); + valueCopy.asMap().putAll(jsonValue.asMap()); + addEntry(entry.getOrThrow(), valueCopy, list, rebuild, parent); + } + } + } + } + + private void addEntry(ConfigScreenEntry provider, JsonObject valueCopy, ScreenEntryList list, Runnable rebuild, Screen parent) { + var entryProvider = provider.screenEntryProvider().openChecked(context, valueCopy, newValue -> { + if (newValue.isJsonObject()) { + for (var entry : newValue.getAsJsonObject().entrySet()) { + if (entry.getKey().equals(key)) { + continue; + } + this.jsonValue.add(entry.getKey(), entry.getValue()); + } + } else { + LOGGER.error("Value {} was not a JSON object", newValue); + } + }, provider.entryCreationInfo()); + this.nestedOnExit = context -> entryProvider.onExit(context); + entryProvider.addEntries(list, rebuild, parent); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/DispatchedMapScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/DispatchedMapScreenEntryProvider.java new file mode 100644 index 0000000..58f6f0b --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/DispatchedMapScreenEntryProvider.java @@ -0,0 +1,164 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.datafixers.util.Pair; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.DataResult; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.slf4j.Logger; + +class DispatchedMapScreenEntryProvider implements ScreenEntryProvider { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final ConfigScreenEntry keyEntry; + private final List keys; + private final Map>>> keyProviders; + private final List, JsonElement>> values = new ArrayList<>(); + private final JsonObject jsonValue = new JsonObject(); + private final Consumer update; + private final EntryCreationContext context; + + public DispatchedMapScreenEntryProvider(ConfigScreenEntry keyEntry, JsonElement jsonValue, Consumer update, EntryCreationContext context, Map>>> entries) { + this.keyEntry = keyEntry; + if (jsonValue.isJsonObject()) { + jsonValue.getAsJsonObject().entrySet().stream() + .map(e -> Pair.of(Optional.of(e.getKey()), e.getValue())) + .forEach(values::add); + updateValue(); + } else { + if (!jsonValue.isJsonNull()) { + LOGGER.error("Value {} was not a JSON array", jsonValue); + } + } + this.update = update; + this.context = context; + this.keys = new ArrayList<>(); + this.keyProviders = new HashMap<>(); + for (var entry : entries.entrySet()) { + var keyResult = keyEntry.entryCreationInfo().codec().encodeStart(context.ops(), entry.getKey()); + if (keyResult.isError()) { + LOGGER.error("Failed to encode key {}", entry.getKey()); + continue; + } + JsonElement keyElement = keyResult.getOrThrow(); + if (!keyElement.isJsonPrimitive()) { + LOGGER.error("Key {} was not a JSON primitive", keyElement); + continue; + } else if (!keyElement.getAsJsonPrimitive().isString()) { + LOGGER.error("Key {} was not a JSON string", keyElement); + continue; + } + String keyAsString = keyElement.getAsString(); + keyProviders.put(keyAsString, entry.getValue()); + this.keys.add(keyAsString); + } + this.keys.sort(Comparator.naturalOrder()); + } + + private void updateValue() { + jsonValue.entrySet().clear(); + for (var pair : values) { + if (pair.getFirst().isEmpty()) { + continue; + } + if (jsonValue.has(pair.getFirst().get())) { + LOGGER.warn("Duplicate key {}", pair.getFirst().get()); + } + jsonValue.add(pair.getFirst().get(), pair.getSecond()); + } + } + + @Override + public void onExit(EntryCreationContext context) { + this.update.accept(jsonValue); + } + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + var fullWidth = Button.DEFAULT_WIDTH*2+ EntryListScreen.Entry.SPACING; + for (int i = 0; i < values.size(); i++) { + var index = i; + var layout = new EqualSpacingLayout(fullWidth, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + // 5 gives us good spacing here + var keyAndValueWidth = (fullWidth - (Button.DEFAULT_HEIGHT + 5) - 5)/2; + layout.addChild(Button.builder(Component.translatable("codecextras.config.list.icon.remove"), b -> { + values.remove(index); + updateValue(); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.remove"))).build(), LayoutSettings.defaults().alignVerticallyMiddle()); + var keyValue = values.get(index).getFirst(); + var keyLayout = keyEntry.layout().create( + parent, + keyAndValueWidth, + context, + keyValue.map(JsonPrimitive::new).orElse(JsonNull.INSTANCE), + newKeyValue -> { + String stringKeyValue = newKeyValue.isJsonNull() ? null : newKeyValue.isJsonPrimitive() && newKeyValue.getAsJsonPrimitive().isString() ? newKeyValue.getAsJsonPrimitive().getAsString() : null; + boolean shouldRebuild = keyProviders.containsKey(stringKeyValue); + if (!Objects.equals(stringKeyValue, keyValue.orElse(null))) { + values.set(index, Pair.of(Optional.ofNullable(stringKeyValue), JsonNull.INSTANCE)); + updateValue(); + if (shouldRebuild) { + rebuild.run(); + } + } + }, + keyEntry.entryCreationInfo(), + false + ); + layout.addChild(keyLayout, LayoutSettings.defaults().alignVerticallyMiddle()); + var valueEntry = keyValue.map(keyProviders::get).orElse(null); + if (valueEntry != null) { + var valueScreenEntry = valueEntry.get(); + if (valueScreenEntry.isError()) { + LOGGER.error("Failed to create screen entry for key {}: {}", keyValue.orElseThrow(), valueScreenEntry.error().orElseThrow().message()); + } else { + addValueEntry(parent, layout, valueScreenEntry.getOrThrow(), keyAndValueWidth, index); + } + } else { + var disabled = Button.builder(Component.translatable("codecextras.config.dispatchedmap.icon.disabled"), b -> {}).width(keyAndValueWidth).build(); + disabled.active = false; + layout.addChild(disabled, LayoutSettings.defaults().alignVerticallyMiddle()); + } + list.addSingle(layout); + } + var addLayout = new FrameLayout(fullWidth, 0); + addLayout.addChild(Button.builder(Component.translatable("codecextras.config.list.icon.add"), b -> { + values.add(new Pair<>(Optional.empty(), JsonNull.INSTANCE)); + updateValue(); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.add"))).build(), LayoutSettings.defaults().alignHorizontallyLeft().alignVerticallyMiddle()); + list.addSingle(addLayout); + } + + private void addValueEntry(Screen parent, EqualSpacingLayout layout, ConfigScreenEntry valueEntry, int keyAndValueWidth, int index) { + layout.addChild(valueEntry.layout().create( + parent, + keyAndValueWidth, + context, values.get(index).getSecond(), + newValue -> { + this.values.set(index, this.values.get(index).mapSecond(old -> newValue)); + updateValue(); + }, valueEntry.entryCreationInfo(), + false + ), LayoutSettings.defaults().alignVerticallyMiddle()); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryCreationContext.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryCreationContext.java new file mode 100644 index 0000000..63aa933 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryCreationContext.java @@ -0,0 +1,80 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.JsonOps; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +public class EntryCreationContext { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final DynamicOps ops; + private final RegistryAccess registryAccess; + final Map problems = new IdentityHashMap<>(); + + public static final class ProblemMarker { + private ProblemMarker() {} + } + + private EntryCreationContext(DynamicOps ops, RegistryAccess registryAccess) { + this.ops = ops; + this.registryAccess = registryAccess; + } + + public DynamicOps ops() { + return ops; + } + + public RegistryAccess registryAccess() { + return registryAccess; + } + + public ProblemMarker problem(@Nullable ProblemMarker old, String message) { + ProblemMarker marker = old == null ? new ProblemMarker() : old; + problems.put(marker, message); + LOGGER.error(message); + return marker; + } + + public EntryCreationContext subContext() { + return new EntryCreationContext(ops, registryAccess); + } + + public void resolve(@Nullable ProblemMarker problem) { + if (problem != null) { + problems.remove(problem); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private DynamicOps ops = JsonOps.INSTANCE; + private RegistryAccess registryAccess = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + + private Builder() {} + + public Builder ops(DynamicOps ops) { + this.ops = ops; + return this; + } + + public Builder registryAccess(RegistryAccess registryAccess) { + this.registryAccess = registryAccess; + return this; + } + + public EntryCreationContext build() { + return new EntryCreationContext(Objects.requireNonNull(ops), registryAccess); + } + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryCreationInfo.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryCreationInfo.java new file mode 100644 index 0000000..b68c47f --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryCreationInfo.java @@ -0,0 +1,14 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.mojang.serialization.Codec; +import java.util.function.UnaryOperator; + +public record EntryCreationInfo(Codec codec, ComponentInfo componentInfo) { + public EntryCreationInfo withComponentInfo(UnaryOperator function) { + return new EntryCreationInfo<>(this.codec, function.apply(this.componentInfo)); + } + + public EntryCreationInfo withCodec(Codec codec) { + return new EntryCreationInfo<>(codec, this.componentInfo); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryListScreen.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryListScreen.java new file mode 100644 index 0000000..2245896 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/EntryListScreen.java @@ -0,0 +1,173 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; +import net.minecraft.client.gui.layouts.Layout; +import net.minecraft.client.gui.layouts.LayoutElement; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +class EntryListScreen extends Screen { + private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this); + private final Screen lastScreen; + private @Nullable EntryList list; + private final ScreenEntryProvider screenEntries; + private final EntryCreationContext context; + + public EntryListScreen(Screen screen, Component title, ScreenEntryProvider screenEntries, EntryCreationContext context) { + super(title); + this.lastScreen = screen; + this.screenEntries = screenEntries; + this.context = context; + } + + protected void init() { + this.addTitle(); + this.addContents(); + this.addFooter(); + this.layout.visitWidgets(this::addRenderableWidget); + this.repositionElements(); + } + + protected void addTitle() { + this.layout.addTitleHeader(this.title, this.font); + } + + protected void addContents() { + if (this.list == null) { + this.list = new EntryList(this.width); + } else { + this.list.clear(); + } + this.layout.addToContents(this.list); + this.addEntries(); + } + + protected void addFooter() { + this.layout.addToFooter(Button.builder(CommonComponents.GUI_DONE, (button) -> this.onClose()).width(200).build()); + } + + protected void repositionElements() { + this.layout.arrangeElements(); + if (this.list != null) { + this.list.updateSize(this.width, this.layout); + } + } + + public void onClose() { + this.screenEntries.onExit(this.context); + var problems = this.context.problems; + if (!problems.isEmpty()) { + var issues = String.join("\n", problems.values()); + var screen = new ConfirmScreen(bl -> { + // Resolve everything in either case + for (var problem : problems.keySet().stream().toList()) { + this.context.resolve(problem); + } + if (bl) { + this.minecraft.setScreen(this.lastScreen); + } else { + this.minecraft.setScreen(this); + } + }, Component.translatable("codecextras.config.issue"), Component.translatable("codecextras.config.issue.message", issues)); + this.minecraft.setScreen(screen); + return; + } + this.minecraft.setScreen(this.lastScreen); + } + + protected void addEntries() { + this.screenEntries.addEntries(this.list, this::rebuildWidgets, this); + } + + final class EntryList extends ContainerObjectSelectionList implements ScreenEntryList { + public EntryList(int i) { + super(EntryListScreen.this.minecraft, i, EntryListScreen.this.layout.getContentHeight(), EntryListScreen.this.layout.getHeaderHeight(), 25); + } + + @Override + public int getRowWidth() { + return 310; + } + + @Override + public void addPair(LayoutElement left, LayoutElement right) { + var layout = new EqualSpacingLayout(Button.DEFAULT_WIDTH*2+EntryListScreen.Entry.SPACING, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + var leftLayout = new FrameLayout(Button.DEFAULT_WIDTH, 0); + leftLayout.addChild(left, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + layout.addChild(leftLayout, LayoutSettings.defaults().alignVerticallyMiddle()); + var rightLayout = new FrameLayout(Button.DEFAULT_WIDTH, 0); + rightLayout.addChild(right, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + layout.addChild(rightLayout, LayoutSettings.defaults().alignVerticallyMiddle()); + this.addEntry(new EntryListScreen.Entry(layout, EntryListScreen.this)); + } + + @Override + public void addSingle(LayoutElement layoutElement) { + var layout = new FrameLayout(Button.DEFAULT_WIDTH*2+EntryListScreen.Entry.SPACING, 0); + layout.addChild(layoutElement, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + this.addEntry(new EntryListScreen.Entry(layout, EntryListScreen.this)); + } + + @Override + public void updateSize(int i, HeaderAndFooterLayout headerAndFooterLayout) { + super.updateSize(i, headerAndFooterLayout); + for (var entry : this.children()) { + entry.layout.arrangeElements(); + } + } + + void clear() { + super.clearEntries(); + } + } + + static final class Entry extends ContainerObjectSelectionList.Entry { + private final Layout layout; + private final List narratables; + private final List listeners; + private final Screen screen; + + public static final int SPACING = 10; + + private Entry(Layout layout, Screen screen) { + this.layout = layout; + List childWidgets = new ArrayList<>(); + layout.visitWidgets(childWidgets::add); + this.narratables = childWidgets; + this.listeners = childWidgets; + this.screen = screen; + } + + @Override + public List narratables() { + return this.narratables; + } + + @Override + public void render(GuiGraphics guiGraphics, int i, int j, int k, int l, int m, int n, int o, boolean bl, float f) { + int q = this.screen.width / 2 - (Button.DEFAULT_WIDTH + SPACING / 2); + + layout.setPosition(q, j); + layout.visitWidgets((widget) -> widget.render(guiGraphics, n, o, f)); + } + + @Override + public List children() { + return this.listeners; + } + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/JsonComparator.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/JsonComparator.java new file mode 100644 index 0000000..d5d0a1e --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/JsonComparator.java @@ -0,0 +1,64 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.Map; + +class JsonComparator implements Comparator { + public static final JsonComparator INSTANCE = new JsonComparator(); + + private JsonComparator() {} + + @Override + public int compare(JsonElement o1, JsonElement o2) { + if (o1.isJsonPrimitive() && o2.isJsonPrimitive()) { + var p1 = o1.getAsJsonPrimitive(); + var p2 = o2.getAsJsonPrimitive(); + if (p1.isString() || p2.isString()) { + return p1.getAsString().compareTo(p2.getAsString()); + } else if (p1.isNumber() || p2.isNumber()) { + BigDecimal p1d = p1.isNumber() ? p1.getAsBigDecimal() : p1.getAsBoolean() ? BigDecimal.ONE : BigDecimal.ZERO; + BigDecimal p2d = p2.isNumber() ? p2.getAsBigDecimal() : p2.getAsBoolean() ? BigDecimal.ONE : BigDecimal.ZERO; + return p1d.compareTo(p2d); + } else { + return Boolean.compare(p1.getAsBoolean(), p2.getAsBoolean()); + } + } else if (o1.isJsonArray()) { + if (!o2.isJsonArray()) { + return 1; + } + var a1 = o1.getAsJsonArray(); + var a2 = o2.getAsJsonArray(); + int size = Math.min(a1.size(), a2.size()); + for (int i = 0; i < size; i++) { + int cmp = compare(a1.get(i), a2.get(i)); + if (cmp != 0) { + return cmp; + } + } + return Integer.compare(a1.size(), a2.size()); + } else if (o1.isJsonObject()) { + if (!o2.isJsonObject()) { + return 1; + } + var o1e = o1.getAsJsonObject().entrySet().stream().sorted(Map.Entry.comparingByKey()).iterator(); + var o2e = o2.getAsJsonObject().entrySet().stream().sorted(Map.Entry.comparingByKey()).iterator(); + while (o1e.hasNext() && o2e.hasNext()) { + var e1 = o1e.next(); + var e2 = o2e.next(); + int cmp = e1.getKey().compareTo(e2.getKey()); + if (cmp != 0) { + return cmp; + } + cmp = compare(e1.getValue(), e2.getValue()); + if (cmp != 0) { + return cmp; + } + } + return Boolean.compare(o1e.hasNext(), o2e.hasNext()); + } else { + return 0; + } + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/LayoutFactory.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/LayoutFactory.java new file mode 100644 index 0000000..6624732 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/LayoutFactory.java @@ -0,0 +1,10 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import java.util.function.Consumer; +import net.minecraft.client.gui.layouts.LayoutElement; +import net.minecraft.client.gui.screens.Screen; + +public interface LayoutFactory { + LayoutElement create(Screen parent, int width, EntryCreationContext context, JsonElement original, Consumer update, EntryCreationInfo creationInfo, boolean handleOptional); +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ListScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ListScreenEntryProvider.java new file mode 100644 index 0000000..5bdbcca --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ListScreenEntryProvider.java @@ -0,0 +1,100 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.mojang.logging.LogUtils; +import java.util.function.Consumer; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.slf4j.Logger; + +class ListScreenEntryProvider implements ScreenEntryProvider { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final ConfigScreenEntry entry; + private final JsonArray jsonValue; + private final Consumer update; + private final EntryCreationContext context; + + ListScreenEntryProvider(ConfigScreenEntry entry, EntryCreationContext context, JsonElement jsonValue, Consumer update) { + this.entry = entry; + if (jsonValue.isJsonArray()) { + this.jsonValue = jsonValue.getAsJsonArray(); + } else { + if (!jsonValue.isJsonNull()) { + LOGGER.error("Value {} was not a JSON array", jsonValue); + } + this.jsonValue = new JsonArray(); + } + this.update = update; + this.context = context; + } + + @Override + public void onExit(EntryCreationContext context) { + this.update.accept(jsonValue); + } + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + var fullWidth = Button.DEFAULT_WIDTH*2+ EntryListScreen.Entry.SPACING; + for (int i = 0; i < jsonValue.size(); i++) { + var index = i; + var layout = new EqualSpacingLayout(fullWidth, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + // 5 gives us good spacing here + var remainingWidth = fullWidth - (Button.DEFAULT_HEIGHT + 5)*3; + layout.addChild(Button.builder(Component.translatable("codecextras.config.list.icon.remove"), b -> { + jsonValue.remove(index); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.remove"))).build(), LayoutSettings.defaults().alignVerticallyMiddle()); + var upButton = Button.builder(Component.translatable("codecextras.config.list.icon.up"), b -> { + if (index == 0) { + return; + } + var oldAbove = jsonValue.get(index - 1); + var old = jsonValue.get(index); + jsonValue.set(index - 1, old); + jsonValue.set(index, oldAbove); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.up"))).build(); + if (index == 0) { + upButton.active = false; + } + layout.addChild(upButton, LayoutSettings.defaults().alignVerticallyMiddle()); + var downButton = Button.builder(Component.translatable("codecextras.config.list.icon.down"), b -> { + if (index == jsonValue.size()-1) { + return; + } + var oldBelow = jsonValue.get(index + 1); + var old = jsonValue.get(index); + jsonValue.set(index + 1, old); + jsonValue.set(index, oldBelow); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.down"))).build(); + if (index == jsonValue.size()-1) { + downButton.active = false; + } + layout.addChild(downButton, LayoutSettings.defaults().alignVerticallyMiddle()); + layout.addChild(entry.layout().create( + parent, + remainingWidth, + context, jsonValue.get(index), + newValue -> this.jsonValue.set(index, newValue), entry.entryCreationInfo(), + false + ), LayoutSettings.defaults().alignVerticallyMiddle()); + list.addSingle(layout); + } + var addLayout = new FrameLayout(fullWidth, 0); + addLayout.addChild(Button.builder(Component.translatable("codecextras.config.list.icon.add"), b -> { + jsonValue.add(JsonNull.INSTANCE); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.add"))).build(), LayoutSettings.defaults().alignHorizontallyLeft().alignVerticallyMiddle()); + list.addSingle(addLayout); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/RecordEntry.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/RecordEntry.java new file mode 100644 index 0000000..1b7684c --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/RecordEntry.java @@ -0,0 +1,7 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.mojang.serialization.Codec; +import dev.lukebemish.codecextras.structured.RecordStructure; +import java.util.Optional; + +record RecordEntry(String key, ConfigScreenEntry entry, Optional> missingBehavior, Codec codec) {} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/RecordScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/RecordScreenEntryProvider.java new file mode 100644 index 0000000..3c58783 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/RecordScreenEntryProvider.java @@ -0,0 +1,97 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.mojang.logging.LogUtils; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.LayoutElement; +import net.minecraft.client.gui.screens.Screen; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +class RecordScreenEntryProvider implements ScreenEntryProvider { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final List> entries; + private final JsonObject jsonValue; + private final Consumer update; + private final EntryCreationContext context; + + RecordScreenEntryProvider(List> entries, EntryCreationContext context, JsonElement jsonValue, Consumer update) { + this.entries = entries; + if (jsonValue.isJsonObject()) { + this.jsonValue = jsonValue.getAsJsonObject(); + } else { + if (!jsonValue.isJsonNull()) { + LOGGER.error("Value {} was not a JSON object", jsonValue); + } + this.jsonValue = new JsonObject(); + } + this.update = update; + this.context = context; + } + + @Override + public void onExit(EntryCreationContext context) { + this.update.accept(jsonValue); + } + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + for (var entry: this.entries) { + JsonElement specificValue = this.jsonValue.has(entry.key()) ? this.jsonValue.get(entry.key()) : JsonNull.INSTANCE; + var label = new StringWidget(Button.DEFAULT_WIDTH, Button.DEFAULT_HEIGHT, entry.entry().entryCreationInfo().componentInfo().title(), Minecraft.getInstance().font).alignLeft(); + var contents = createEntryWidget(entry, specificValue, parent); + entry.entry().entryCreationInfo().componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + label.setTooltip(tooltip); + }); + list.addPair(label, contents); + } + } + + private LayoutElement createEntryWidget(RecordEntry entry, JsonElement specificValue, Screen parent) { + // If this is missing, missing values are just not allowed + var defaultValue = entry.missingBehavior().map(behavior -> { + var value = behavior.missing().get(); + var encoded = entry.codec().encodeStart(context.ops(), value); + if (encoded.error().isPresent()) { + // The default value is unencodeable, so we have to handle missing values in the widget + return JsonNull.INSTANCE; + } + return encoded.result().orElseThrow(); + }); + JsonElement specificValueWithDefault = specificValue.isJsonNull() && defaultValue.isPresent() ? defaultValue.get() : specificValue; + return entry.entry().layout().create(parent, Button.DEFAULT_WIDTH, context, specificValueWithDefault, newValue -> { + if (shouldUpdate(newValue, entry)) { + this.jsonValue.add(entry.key(), newValue); + } else { + this.jsonValue.remove(entry.key()); + } + }, entry.entry().entryCreationInfo(), defaultValue.isPresent() && defaultValue.get().isJsonNull()); + } + + private EntryCreationContext.@Nullable ProblemMarker encodeValueProblem; + + private boolean shouldUpdate(JsonElement newValue, RecordEntry entry) { + if (newValue.isJsonNull()) { + return false; + } + if (entry.missingBehavior().isPresent()) { + var decoded = entry.codec().parse(this.context.ops(), newValue); + if (decoded.isError()) { + encodeValueProblem = context.problem(encodeValueProblem, decoded.error().orElseThrow().message()); + return false; + } + context.resolve(encodeValueProblem); + return entry.missingBehavior().get().predicate().test(decoded.result().orElseThrow()); + } + return true; + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryFactory.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryFactory.java new file mode 100644 index 0000000..9552200 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryFactory.java @@ -0,0 +1,20 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import java.util.function.Consumer; + +public interface ScreenEntryFactory { + ScreenEntryProvider open(EntryCreationContext context, JsonElement original, Consumer onClose, EntryCreationInfo entry); + + default ScreenEntryProvider openChecked(EntryCreationContext context, JsonElement original, Consumer onClose, EntryCreationInfo entry) { + EntryCreationContext.ProblemMarker[] problems = {null}; + return open(context, original, jsonElement -> { + var codec = entry.codec(); + var result = codec.parse(context.ops(), jsonElement); + if (result.isError()) { + problems[0] = context.problem(problems[0], result.error().orElseThrow().message()); + } + onClose.accept(jsonElement); + }, entry); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryList.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryList.java new file mode 100644 index 0000000..8a62571 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryList.java @@ -0,0 +1,8 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import net.minecraft.client.gui.layouts.LayoutElement; + +public interface ScreenEntryList { + void addPair(LayoutElement left, LayoutElement right); + void addSingle(LayoutElement layoutElement); +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryProvider.java new file mode 100644 index 0000000..6e4e2d3 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/ScreenEntryProvider.java @@ -0,0 +1,12 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import net.minecraft.client.gui.screens.Screen; + +public interface ScreenEntryProvider { + void onExit(EntryCreationContext context); + void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent); + + static Screen create(ScreenEntryProvider provider, Screen parent, EntryCreationContext context, ComponentInfo componentInfo) { + return new EntryListScreen(parent, componentInfo.title(), provider, context); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/SingleScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/SingleScreenEntryProvider.java new file mode 100644 index 0000000..50066ee --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/SingleScreenEntryProvider.java @@ -0,0 +1,33 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import java.util.function.Consumer; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; + +class SingleScreenEntryProvider implements ScreenEntryProvider { + private static final int FULL_WIDTH = Button.DEFAULT_WIDTH * 2 + EntryListScreen.Entry.SPACING; + private final EntryCreationContext context; + private final EntryCreationInfo creationInfo; + private final Consumer update; + private JsonElement value; + private final LayoutFactory first; + + SingleScreenEntryProvider(JsonElement original, LayoutFactory first, EntryCreationContext context, EntryCreationInfo creationInfo, Consumer update) { + this.value = original; + this.first = first; + this.context = context; + this.creationInfo = creationInfo; + this.update = update; + } + + @Override + public void onExit(EntryCreationContext context) { + update.accept(value); + } + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + list.addSingle(first.create(parent, FULL_WIDTH, context, value, newValue -> value = newValue, creationInfo, false)); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/UnboundedMapScreenEntryProvider.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/UnboundedMapScreenEntryProvider.java new file mode 100644 index 0000000..ae568e4 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/UnboundedMapScreenEntryProvider.java @@ -0,0 +1,114 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.datafixers.util.Pair; +import com.mojang.logging.LogUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.slf4j.Logger; + +class UnboundedMapScreenEntryProvider implements ScreenEntryProvider { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final ConfigScreenEntry keyEntry; + private final ConfigScreenEntry valueEntry; + private final List> values = new ArrayList<>(); + private final JsonObject jsonValue = new JsonObject(); + private final Consumer update; + private final EntryCreationContext context; + + UnboundedMapScreenEntryProvider(ConfigScreenEntry keyEntry, ConfigScreenEntry valueEntry, EntryCreationContext context, JsonElement jsonValue, Consumer update) { + this.keyEntry = keyEntry; + this.valueEntry = valueEntry; + if (jsonValue.isJsonObject()) { + jsonValue.getAsJsonObject().entrySet().stream() + .>map(e -> Pair.of(new JsonPrimitive(e.getKey()), e.getValue())) + .forEach(values::add); + updateValue(); + } else { + if (!jsonValue.isJsonNull()) { + LOGGER.error("Value {} was not a JSON object", jsonValue); + } + } + this.update = update; + this.context = context; + } + + private void updateValue() { + jsonValue.entrySet().clear(); + for (var pair : values) { + if (pair.getFirst().isJsonPrimitive()) { + if (pair.getFirst().getAsJsonPrimitive().isString()) { + if (jsonValue.has(pair.getFirst().getAsString())) { + LOGGER.warn("Duplicate key {}", pair.getFirst().getAsString()); + } + jsonValue.add(pair.getFirst().getAsString(), pair.getSecond()); + } else { + LOGGER.error("Key {} was not a JSON string", pair.getFirst()); + } + } else if (!pair.getFirst().isJsonNull()) { + LOGGER.error("Key {} was not a JSON primitive", pair.getFirst()); + } + } + } + + @Override + public void onExit(EntryCreationContext context) { + this.update.accept(jsonValue); + } + + @Override + public void addEntries(ScreenEntryList list, Runnable rebuild, Screen parent) { + var fullWidth = Button.DEFAULT_WIDTH*2 + EntryListScreen.Entry.SPACING; + for (int i = 0; i < values.size(); i++) { + var index = i; + var layout = new EqualSpacingLayout(fullWidth, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + // 5 gives us good spacing here + var keyAndValueWidth = (fullWidth - (Button.DEFAULT_HEIGHT + 5) - 5)/2; + layout.addChild(Button.builder(Component.translatable("codecextras.config.list.icon.remove"), b -> { + values.remove(index); + updateValue(); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.remove"))).build(), LayoutSettings.defaults().alignVerticallyMiddle()); + layout.addChild(keyEntry.layout().create( + parent, + keyAndValueWidth, + context, values.get(index).getFirst(), + newKey -> { + this.values.set(index, this.values.get(index).mapFirst(old -> newKey)); + updateValue(); + }, keyEntry.entryCreationInfo(), + false + ), LayoutSettings.defaults().alignVerticallyMiddle()); + layout.addChild(valueEntry.layout().create( + parent, + keyAndValueWidth, + context, values.get(index).getSecond(), + newValue -> { + this.values.set(index, this.values.get(index).mapSecond(old -> newValue)); + updateValue(); + }, valueEntry.entryCreationInfo(), + false + ), LayoutSettings.defaults().alignVerticallyMiddle()); + list.addSingle(layout); + } + var addLayout = new FrameLayout(fullWidth, 0); + addLayout.addChild(Button.builder(Component.translatable("codecextras.config.list.icon.add"), b -> { + values.add(new Pair<>(JsonNull.INSTANCE, JsonNull.INSTANCE)); + updateValue(); + rebuild.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.list.add"))).build(), LayoutSettings.defaults().alignHorizontallyLeft().alignVerticallyMiddle()); + list.addSingle(addLayout); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/VisibilityWrapperElement.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/VisibilityWrapperElement.java new file mode 100644 index 0000000..d7a20a2 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/VisibilityWrapperElement.java @@ -0,0 +1,232 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import java.util.IdentityHashMap; +import java.util.function.Consumer; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.layouts.Layout; +import net.minecraft.client.gui.layouts.LayoutElement; + +public interface VisibilityWrapperElement extends Layout { + void setVisible(boolean visible); + boolean visible(); + + void setActive(boolean visible); + boolean active(); + + static VisibilityWrapperElement ofInactive(LayoutElement child) { + return new VisibilityWrapperElement() { + private boolean visible = true; + private boolean active = true; + + private final IdentityHashMap isVisible = new IdentityHashMap<>(); + + private void visitChildren(Consumer widgetConsumer, Consumer wrapperConsumer) { + switch (child) { + case VisibilityWrapperElement wrapperElement -> wrapperConsumer.accept(wrapperElement); + case Layout layout -> layout.visitChildren(element -> visitChildren(widgetConsumer, wrapperConsumer)); + case AbstractWidget widget -> widgetConsumer.accept(widget); + default -> {} + } + } + + { + visitWidgets(widget -> { + widget.active = false; + }); + } + + @Override + public void setX(int i) { + child.setX(i); + } + + @Override + public void setY(int i) { + child.setY(i); + } + + @Override + public int getX() { + return child.getX(); + } + + @Override + public int getY() { + return child.getY(); + } + + @Override + public int getWidth() { + return child.getWidth(); + } + + @Override + public int getHeight() { + return child.getHeight(); + } + + @Override + public void visitChildren(Consumer consumer) { + consumer.accept(child); + } + + @Override + public void visitWidgets(Consumer consumer) { + child.visitWidgets(consumer); + } + + @Override + public void setVisible(boolean visible) { + if (visible) { + isVisible.forEach((element, wasVisible) -> { + if (element instanceof VisibilityWrapperElement wrapper) { + wrapper.setVisible(wasVisible); + } else if (element instanceof AbstractWidget widget) { + widget.visible = wasVisible; + } + }); + } else { + isVisible.clear(); + visitChildren(widget -> { + isVisible.put(widget, widget.visible); + widget.visible = false; + }, wrapper -> { + isVisible.put(wrapper, wrapper.visible()); + wrapper.setVisible(false); + }); + } + this.visible = visible; + } + + @Override + public boolean visible() { + return visible; + } + + @Override + public void setActive(boolean visible) { + this.active = visible; + } + + @Override + public boolean active() { + return active; + } + }; + } + + static VisibilityWrapperElement ofDirect(LayoutElement child) { + return new VisibilityWrapperElement() { + private boolean visible = true; + private boolean active = true; + + private final IdentityHashMap isVisible = new IdentityHashMap<>(); + private final IdentityHashMap isActive = new IdentityHashMap<>(); + + private void visitChildren(Consumer widgetConsumer, Consumer wrapperConsumer) { + switch (child) { + case VisibilityWrapperElement wrapperElement -> wrapperConsumer.accept(wrapperElement); + case Layout layout -> layout.visitChildren(element -> visitChildren(widgetConsumer, wrapperConsumer)); + case AbstractWidget widget -> widgetConsumer.accept(widget); + default -> {} + } + } + + @Override + public void setX(int i) { + child.setX(i); + } + + @Override + public void setY(int i) { + child.setY(i); + } + + @Override + public int getX() { + return child.getX(); + } + + @Override + public int getY() { + return child.getY(); + } + + @Override + public int getWidth() { + return child.getWidth(); + } + + @Override + public int getHeight() { + return child.getHeight(); + } + + @Override + public void visitChildren(Consumer consumer) { + consumer.accept(child); + } + + @Override + public void visitWidgets(Consumer consumer) { + child.visitWidgets(consumer); + } + + @Override + public void setVisible(boolean visible) { + if (visible) { + isVisible.forEach((element, wasVisible) -> { + if (element instanceof VisibilityWrapperElement wrapper) { + wrapper.setVisible(wasVisible); + } else if (element instanceof AbstractWidget widget) { + widget.visible = wasVisible; + } + }); + } else { + isVisible.clear(); + visitChildren(widget -> { + isVisible.put(widget, widget.visible); + widget.visible = false; + }, wrapper -> { + isVisible.put(wrapper, wrapper.visible()); + wrapper.setVisible(false); + }); + } + this.visible = visible; + } + + @Override + public boolean visible() { + return visible; + } + + @Override + public void setActive(boolean active) { + if (active) { + isActive.forEach((element, wasVisible) -> { + if (element instanceof VisibilityWrapperElement wrapper) { + wrapper.setActive(wasVisible); + } else if (element instanceof AbstractWidget widget) { + widget.active = wasVisible; + } + }); + } else { + isActive.clear(); + visitChildren(widget -> { + isActive.put(widget, widget.active); + widget.active = false; + }, wrapper -> { + isActive.put(wrapper, wrapper.active()); + wrapper.setActive(false); + }); + } + this.active = active; + } + + @Override + public boolean active() { + return active; + } + }; + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/Widgets.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/Widgets.java new file mode 100644 index 0000000..82718a5 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/Widgets.java @@ -0,0 +1,474 @@ +package dev.lukebemish.codecextras.minecraft.structured.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.datafixers.util.Either; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.structured.Range; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.components.AbstractSliderButton; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Checkbox; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.LayoutSettings; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import org.slf4j.Logger; + +public final class Widgets { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final ResourceLocation TRANSPARENT = ResourceLocation.fromNamespaceAndPath("codecextras_minecraft", "widget/transparent"); + private static final int DEFAULT_SPACING = 5; + + private Widgets() {} + + public static LayoutFactory text(Function> toData, Function> fromData, Predicate filter, boolean emptyIsMissing) { + return (parent, width, context, original, update, creationInfo, handleOptional) -> { + var widget = new EditBox(Minecraft.getInstance().font, width, Button.DEFAULT_HEIGHT, creationInfo.componentInfo().title()); + creationInfo.componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + widget.setTooltip(tooltip); + }); + + widget.setFilter(filter); + + if (original.isJsonNull()) { + original = new JsonPrimitive(""); + if (!handleOptional) { + update.accept(original); + } + } + + var decoded = creationInfo.codec().parse(context.ops(), original); + if (decoded.isError()) { + LOGGER.warn("Failed to decode `{}`: {}", original, decoded.error().orElseThrow().message()); + } else { + var decodedValue = decoded.getOrThrow(); + var stringResult = fromData.apply(decodedValue); + if (stringResult.error().isPresent()) { + LOGGER.warn("Failed to encode `{}` as string: {}", decodedValue, stringResult.error().get().message()); + } else { + widget.setValue(stringResult.getOrThrow()); + } + } + + widget.setResponder(string -> { + if (emptyIsMissing && string.isEmpty()) { + update.accept(JsonNull.INSTANCE); + return; + } + var dataResult = toData.apply(string); + if (dataResult.error().isPresent()) { + LOGGER.warn("Failed to encode `{}` as data: {}", string, dataResult.error().get().message()); + } else { + var jsonResult = creationInfo.codec().encodeStart(context.ops(), dataResult.getOrThrow()); + if (jsonResult.error().isPresent()) { + LOGGER.warn("Failed to encode `{}` as json: {}", dataResult.getOrThrow(), jsonResult.error().get().message()); + } else { + update.accept(jsonResult.getOrThrow()); + } + } + }); + + return widget; + }; + } + + public static LayoutFactory text(Function> toData, Function> fromData, boolean emptyIsMissing) { + return text(toData, fromData, s -> true, emptyIsMissing); + } + + public static LayoutFactory pickWidget(StringRepresentation representation) { + return wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + String[] stringValue = new String[1]; + if (original.isJsonPrimitive()) { + if (original.getAsJsonPrimitive().isString()) { + stringValue[0] = original.getAsJsonPrimitive().getAsString(); + } else { + LOGGER.warn("Failed to decode `{}`: not a string", original); + } + } else if (!original.isJsonNull()) { + LOGGER.warn("Failed to decode `{}`: not a primitive or null", original); + } + List values = new ArrayList<>(); + for (var value : representation.values().get()) { + var valueRepresentation = representation.representation().apply(value); + values.add(valueRepresentation); + } + Supplier calculateMessage = () -> Component.literal(stringValue[0] == null ? "" : stringValue[0]); + var holder = new Object() { + private final Button button = Button.builder(calculateMessage.get(), b -> { + Minecraft.getInstance().setScreen(new ChoiceScreen(parent, creationInfo.componentInfo().title(), values, stringValue[0], newKeyValue -> { + if (!Objects.equals(newKeyValue, stringValue[0])) { + stringValue[0] = newKeyValue; + if (newKeyValue == null) { + update.accept(JsonNull.INSTANCE); + } else { + update.accept(new JsonPrimitive(newKeyValue)); + } + this.button.setMessage(calculateMessage.get()); + } + })); + }).width(width).tooltip(Tooltip.create(creationInfo.componentInfo().description())).build(); + }; + return holder.button; + }); + } + + public static LayoutFactory wrapWithOptionalHandling(LayoutFactory assumesNonOptional) { + return (parent, fullWidth, context, original, update, creationInfo, handleOptional) -> { + if (!handleOptional) { + return assumesNonOptional.create(parent, fullWidth, context, original, update, creationInfo, false); + } + var remainingWidth = fullWidth - Button.DEFAULT_HEIGHT - DEFAULT_SPACING; + var layout = new EqualSpacingLayout(fullWidth, 0, EqualSpacingLayout.Orientation.HORIZONTAL); + var object = new Object() { + private JsonElement value = original; + private final VisibilityWrapperElement wrapped = VisibilityWrapperElement.ofDirect(assumesNonOptional.create(parent, remainingWidth, context, original, json -> { + this.value = json; + update.accept(json); + }, creationInfo, false)); + private final Button disabled = Button.builder(Component.translatable("codecextras.config.missing"), b -> {}) + .width(remainingWidth) + .build(); + boolean missing = original.isJsonNull(); + private final Checkbox lock = Checkbox.builder(Component.empty(), Minecraft.getInstance().font) + .maxWidth(Button.DEFAULT_HEIGHT) + .onValueChange((checkbox, b) -> { + missing = !b; + if (missing) { + update.accept(JsonNull.INSTANCE); + wrapped.setVisible(false); + wrapped.setActive(false); + var maxHeight = Math.max(disabled.getHeight(), wrapped.getHeight()); + disabled.setHeight(maxHeight); + disabled.visible = true; + } else { + update.accept(value); + wrapped.setVisible(true); + wrapped.setActive(true); + var maxHeight = Math.max(disabled.getHeight(), wrapped.getHeight()); + disabled.setHeight(maxHeight); + disabled.visible = false; + } + }) + .selected(!missing) + .build(); + + { + var maxHeight = Math.max(disabled.getHeight(), wrapped.getHeight()); + disabled.setHeight(maxHeight); + disabled.active = false; + disabled.visible = missing; + wrapped.setVisible(!missing); + wrapped.setActive(!missing); + + creationInfo.componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + lock.setTooltip(tooltip); + disabled.setTooltip(tooltip); + }); + + if (missing) { + update.accept(JsonNull.INSTANCE); + } + } + }; + layout.addChild(object.lock, LayoutSettings.defaults().alignVerticallyMiddle()); + var right = new FrameLayout(); + right.addChild(object.disabled, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + right.addChild(object.wrapped, LayoutSettings.defaults().alignVerticallyMiddle().alignHorizontallyCenter()); + layout.addChild(right, LayoutSettings.defaults().alignVerticallyMiddle()); + return VisibilityWrapperElement.ofDirect(layout); + }; + } + + public static LayoutFactory color(boolean includeAlpha) { + return wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonPrimitive(0); + if (!handleOptional) { + update.accept(original); + } + update.accept(original); + } + + int[] value = new int[1]; + if (original.isJsonPrimitive()) { + try { + value[0] = original.getAsInt(); + } catch (NumberFormatException e) { + LOGGER.warn("Failed to decode `{}`: {}", original, e.getMessage()); + } + } else { + LOGGER.warn("Failed to decode `{}`: not a primitive", original); + } + + return new AbstractButton(0, 0, width, Button.DEFAULT_HEIGHT, Component.empty()) { + { + setTooltip(Tooltip.create(creationInfo.componentInfo().description())); + } + + @Override + public void onPress() { + var screen = new ColorPickScreen(parent, creationInfo.componentInfo().title(), color -> { + update.accept(new JsonPrimitive(color)); + value[0] = color; + }, includeAlpha); + screen.setColor(value[0]); + Minecraft.getInstance().setScreen(screen); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + this.defaultButtonNarrationText(narrationElementOutput); + } + + @Override + protected void renderWidget(GuiGraphics guiGraphics, int i, int j, float f) { + super.renderWidget(guiGraphics, i, j, f); + int rectangleHeight = 12; + int startY = getY() + getHeight()/2 - rectangleHeight/2; + int startX = getX() + (startY - getY()); + int endY = getY() + getHeight()/2 + rectangleHeight/2; + int endX = getX() + getWidth() - (startX - getX()); + if (includeAlpha) { + guiGraphics.blitSprite(RenderType::guiTextured, TRANSPARENT, startX, startY, endX - startX, endY - startY); + } + guiGraphics.fill(startX, startY, endX, endY, includeAlpha ? value[0] : value[0] | 0xFF000000); + } + }; + }); + } + + public static > LayoutFactory slider(Range range, Function> toJson, Function> fromJson, boolean isDoubleLike) { + return wrapWithOptionalHandling((parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonPrimitive(range.min()); + if (!handleOptional) { + update.accept(original); + } + } + + var valueResult = fromJson.apply(original); + N value; + if (valueResult.error().isPresent()) { + LOGGER.warn("Failed to decode `{}`: {}", original, valueResult.error().get().message()); + value = range.min(); + } else { + value = valueResult.getOrThrow(); + } + + AbstractSliderButton widget = new AbstractSliderButton(0, 0, width, Button.DEFAULT_HEIGHT, Component.empty(), valueInRange(range, value)) { + { + this.updateMessage(); + } + + @Override + protected void updateMessage() { + N value = calculateValue(); + this.setMessage(Component.literal(isDoubleLike ? String.format("%.2f", value.doubleValue()) : String.valueOf(value.intValue()))); + } + + private N calculateValue() { + JsonElement valueElement; + var realValue = this.value * (range.max().doubleValue() - range.min().doubleValue()) + range.min().doubleValue(); + if (isDoubleLike) { + valueElement = new JsonPrimitive(realValue); + } else { + valueElement = new JsonPrimitive(Math.round(realValue)); + } + var valueResult = fromJson.apply(valueElement); + N value; + if (valueResult.error().isPresent()) { + LOGGER.warn("Failed to decode `{}`: {}", valueElement, valueResult.error().get().message()); + value = range.min(); + } else { + value = valueResult.getOrThrow(); + } + return value; + } + + @Override + protected void applyValue() { + N value = calculateValue(); + var jsonResult = toJson.apply(value); + if (jsonResult.error().isPresent()) { + LOGGER.warn("Failed to encode `{}`: {}", value, jsonResult.error().get().message()); + } else { + update.accept(jsonResult.getOrThrow()); + } + this.value = valueInRange(range, value); + } + }; + widget.setTooltip(Tooltip.create(creationInfo.componentInfo().description())); + return widget; + }); + } + + private static > double valueInRange(Range range, N value) { + return (value.doubleValue() - range.min().doubleValue()) / (range.max().doubleValue() - range.min().doubleValue()); + } + + public static LayoutFactory> either(LayoutFactory left, LayoutFactory right) { + return (parent, fullWidth, context, original, update, creationInfo, handleOptional) -> { + var remainingWidth = fullWidth - Button.DEFAULT_HEIGHT - DEFAULT_SPACING; + boolean[] isLeft = new boolean[1]; + boolean[] isMissing = new boolean[1]; + if (!original.isJsonNull()) { + if (handleOptional) { + isMissing[0] = true; + } else { + var result = creationInfo.codec().parse(context.ops(), original); + if (result.error().isPresent()) { + LOGGER.warn("Failed to decode `{}`: {}", original, result.error().get().message()); + } else { + isLeft[0] = result.getOrThrow().left().isPresent(); + } + } + } + Codec leftCodec = creationInfo.codec().comapFlatMap(e -> e.left().map(DataResult::success).orElse(DataResult.error(() -> "Expected left value")), Either::left); + Codec rightCodec = creationInfo.codec().comapFlatMap(e -> e.right().map(DataResult::success).orElse(DataResult.error(() -> "Expected right value")), Either::right); + var leftElement = VisibilityWrapperElement.ofDirect(left.create(parent, remainingWidth, context, isLeft[0] ? original : JsonNull.INSTANCE, update, creationInfo.withCodec(leftCodec), false)); + var rightElement = VisibilityWrapperElement.ofDirect(right.create(parent, remainingWidth, context, isLeft[0] ? JsonNull.INSTANCE : original, update, creationInfo.withCodec(rightCodec), false)); + var missingElement = handleOptional ? Button.builder(Component.translatable("codecextras.config.missing"), b -> {}).width(remainingWidth).build() : null; + if (handleOptional) { + missingElement.active = false; + } + update.accept(original); + var frame = new FrameLayout(remainingWidth, Button.DEFAULT_HEIGHT); + frame.addChild(leftElement); + frame.addChild(rightElement); + Runnable updateVisibility = () -> { + if (isMissing[0]) { + rightElement.setVisible(false); + rightElement.setActive(false); + leftElement.setVisible(false); + leftElement.setActive(false); + if (handleOptional) { + missingElement.visible = true; + } + } else if (isLeft[0]) { + rightElement.setVisible(false); + rightElement.setActive(false); + leftElement.setVisible(true); + leftElement.setActive(true); + if (handleOptional) { + missingElement.visible = false; + } + } else { + leftElement.setVisible(false); + leftElement.setActive(false); + rightElement.setVisible(true); + rightElement.setActive(true); + if (handleOptional) { + missingElement.visible = false; + } + } + }; + updateVisibility.run(); + var layout = new EqualSpacingLayout(fullWidth, Button.DEFAULT_HEIGHT, EqualSpacingLayout.Orientation.HORIZONTAL); + var switchButton = Button.builder(Component.empty(), b -> { + if (handleOptional) { + if (isMissing[0]) { + isMissing[0] = false; + isLeft[0] = true; + update.accept(JsonNull.INSTANCE); + } else if (isLeft[0]) { + isLeft[0] = false; + } else { + isMissing[0] = true; + } + } else { + isLeft[0] = !isLeft[0]; + } + updateVisibility.run(); + }).width(Button.DEFAULT_HEIGHT).tooltip(Tooltip.create(Component.translatable("codecextras.config.either.switch"))).build(); + layout.addChild(switchButton, LayoutSettings.defaults().alignVerticallyMiddle()); + layout.addChild(frame, LayoutSettings.defaults().alignVerticallyMiddle()); + return VisibilityWrapperElement.ofDirect(layout); + }; + } + + public static LayoutFactory unit() { + return unit(Component.translatable("codecextras.config.unit")); + } + + public static LayoutFactory unit(Component text) { + return (parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonObject(); + if (!handleOptional) { + update.accept(original); + } + } + if (handleOptional) { + var w = Checkbox.builder(Component.empty(), Minecraft.getInstance().font) + .maxWidth(width) + .onValueChange((checkbox, b) -> { + update.accept(b ? new JsonObject() : JsonNull.INSTANCE); + }) + .selected(original.isJsonPrimitive() && original.getAsJsonPrimitive().getAsBoolean()) + .build(); + creationInfo.componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + w.setTooltip(tooltip); + }); + w.setMessage(creationInfo.componentInfo().title()); + return w; + } else { + var button = Button.builder(text, b -> { + }) + .width(width) + .build(); + var tooltip = Tooltip.create(creationInfo.componentInfo().description()); + button.setTooltip(tooltip); + button.active = false; + return VisibilityWrapperElement.ofInactive(button); + } + }; + } + + public static LayoutFactory bool() { + LayoutFactory widget = (parent, width, context, original, update, creationInfo, handleOptional) -> { + if (original.isJsonNull()) { + original = new JsonPrimitive(false); + if (!handleOptional) { + update.accept(original); + } + } + var w = Checkbox.builder(Component.empty(), Minecraft.getInstance().font) + .maxWidth(width) + .onValueChange((checkbox, b) -> { + update.accept(new JsonPrimitive(b)); + }) + .selected(original.isJsonPrimitive() && original.getAsJsonPrimitive().getAsBoolean()) + .build(); + creationInfo.componentInfo().maybeDescription().ifPresent(description -> { + var tooltip = Tooltip.create(description); + w.setTooltip(tooltip); + }); + w.setMessage(creationInfo.componentInfo().title()); + return w; + }; + return wrapWithOptionalHandling(widget); + } +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/package-info.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/package-info.java new file mode 100644 index 0000000..4dfe823 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/config/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Experimental +package dev.lukebemish.codecextras.minecraft.structured.config; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/package-info.java b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/package-info.java new file mode 100644 index 0000000..183b900 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/minecraft/structured/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Experimental +package dev.lukebemish.codecextras.minecraft.structured; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/stream/java/dev/lukebemish/codecextras/stream/AsymmetricalStreamCodecs.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/AsymmetricalStreamCodecs.java similarity index 100% rename from src/stream/java/dev/lukebemish/codecextras/stream/AsymmetricalStreamCodecs.java rename to src/minecraft/java/dev/lukebemish/codecextras/stream/AsymmetricalStreamCodecs.java diff --git a/src/stream/java/dev/lukebemish/codecextras/stream/StreamCodecExtras.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/StreamCodecExtras.java similarity index 100% rename from src/stream/java/dev/lukebemish/codecextras/stream/StreamCodecExtras.java rename to src/minecraft/java/dev/lukebemish/codecextras/stream/StreamCodecExtras.java diff --git a/src/stream/java/dev/lukebemish/codecextras/stream/mutable/StreamDataElementType.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/mutable/StreamDataElementType.java similarity index 100% rename from src/stream/java/dev/lukebemish/codecextras/stream/mutable/StreamDataElementType.java rename to src/minecraft/java/dev/lukebemish/codecextras/stream/mutable/StreamDataElementType.java diff --git a/src/stream/java/dev/lukebemish/codecextras/stream/mutable/package-info.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/mutable/package-info.java similarity index 100% rename from src/stream/java/dev/lukebemish/codecextras/stream/mutable/package-info.java rename to src/minecraft/java/dev/lukebemish/codecextras/stream/mutable/package-info.java diff --git a/src/stream/java/dev/lukebemish/codecextras/stream/package-info.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/package-info.java similarity index 100% rename from src/stream/java/dev/lukebemish/codecextras/stream/package-info.java rename to src/minecraft/java/dev/lukebemish/codecextras/stream/package-info.java diff --git a/src/minecraft/java/dev/lukebemish/codecextras/stream/structured/StreamCodecInterpreter.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/structured/StreamCodecInterpreter.java new file mode 100644 index 0000000..79adda3 --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/stream/structured/StreamCodecInterpreter.java @@ -0,0 +1,443 @@ +package dev.lukebemish.codecextras.stream.structured; + +import com.google.common.base.Suppliers; +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.App2; +import com.mojang.datafixers.kinds.Const; +import com.mojang.datafixers.kinds.K1; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.DataResult; +import dev.lukebemish.codecextras.StringRepresentation; +import dev.lukebemish.codecextras.structured.Interpreter; +import dev.lukebemish.codecextras.structured.Key; +import dev.lukebemish.codecextras.structured.KeyStoringInterpreter; +import dev.lukebemish.codecextras.structured.Keys; +import dev.lukebemish.codecextras.structured.Keys2; +import dev.lukebemish.codecextras.structured.ParametricKeyedValue; +import dev.lukebemish.codecextras.structured.Range; +import dev.lukebemish.codecextras.structured.RecordStructure; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.types.Identity; +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.DecoderException; +import io.netty.handler.codec.EncoderException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.VarInt; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import org.jspecify.annotations.Nullable; + +/** + * Interprets a {@link Structure} into a {@link StreamCodec} for the same type. + * @param the type of the {@link ByteBuf} to encode and decode + * @see #interpret(Structure) + */ +public class StreamCodecInterpreter extends KeyStoringInterpreter, StreamCodecInterpreter> { + private final Key> key; + private final List>> parentConsumers; + private final List> parents; + + public StreamCodecInterpreter(Key> key, List> parents, Keys, Object> keys, Keys2>, K1, K1> parametricKeys) { + super(Keys., Object>builder() + .add(Interpreter.UNIT, new Holder<>(StreamCodec.of((buf, data) -> {}, buf -> Unit.INSTANCE))) + .add(Interpreter.BOOL, new Holder<>(ByteBufCodecs.BOOL.cast())) + .add(Interpreter.BYTE, new Holder<>(ByteBufCodecs.BYTE.cast())) + .add(Interpreter.SHORT, new Holder<>(ByteBufCodecs.SHORT.cast())) + .add(Interpreter.INT, new Holder<>(ByteBufCodecs.VAR_INT.cast())) + .add(Interpreter.LONG, new Holder<>(ByteBufCodecs.VAR_LONG.cast())) + .add(Interpreter.FLOAT, new Holder<>(ByteBufCodecs.FLOAT.cast())) + .add(Interpreter.DOUBLE, new Holder<>(ByteBufCodecs.DOUBLE.cast())) + .add(Interpreter.STRING, new Holder<>(ByteBufCodecs.STRING_UTF8.cast())) + .build().join(buildCombinedKeys(parents)).join(keys), + Keys2.>, K1, K1>builder() + .add(Interpreter.INT_IN_RANGE, numberRangeCodecParameter(ByteBufCodecs.VAR_INT.cast())) + .add(Interpreter.BYTE_IN_RANGE, numberRangeCodecParameter(ByteBufCodecs.BYTE.cast())) + .add(Interpreter.SHORT_IN_RANGE, numberRangeCodecParameter(ByteBufCodecs.SHORT.cast())) + .add(Interpreter.LONG_IN_RANGE, numberRangeCodecParameter(ByteBufCodecs.VAR_LONG.cast())) + .add(Interpreter.FLOAT_IN_RANGE, numberRangeCodecParameter(ByteBufCodecs.FLOAT.cast())) + .add(Interpreter.DOUBLE_IN_RANGE, numberRangeCodecParameter(ByteBufCodecs.DOUBLE.cast())) + .add(Interpreter.STRING_REPRESENTABLE, new ParametricKeyedValue<>() { + @Override + public App, App> convert(App parameter) { + var representation = StringRepresentation.unbox(parameter); + Supplier> lazy = Suppliers.memoize(() -> { + var values = representation.values().get(); + Map toIndexMap = new IdentityHashMap<>(); + for (int i = 0; i < values.size(); i++) { + toIndexMap.put(values.get(i), i); + } + return new StreamCodec<>() { + @Override + public T decode(B buffer) { + var intValue = VarInt.read(buffer); + if (intValue < 0 || intValue >= values.size()) { + throw new DecoderException("Unknown representation value: " + intValue); + } + return values.get(intValue); + } + + @Override + public void encode(B buffer, T object) { + var index = toIndexMap.get(object); + if (index == null) { + throw new DecoderException("Unknown representation value: " + object); + } + VarInt.write(buffer, index); + } + }; + }); + return new Holder<>(new StreamCodec<>() { + @Override + public App decode(B buffer) { + return new Identity<>(lazy.get().decode(buffer)); + } + + @Override + public void encode(B buffer, App object) { + var value = Identity.unbox(object).value(); + lazy.get().encode(buffer, value); + } + }); + } + }) + .build().join(buildCombinedParametricKeys(parents)).join(parametricKeys) + ); + this.parents = parents; + this.parentConsumers = new ArrayList<>(); + for (var parent : parents) { + parent.parentConsumers.forEach(c -> addKeyConsumer(this.parentConsumers, c)); + } + this.key = key; + } + + private static Keys, Object> buildCombinedKeys(List> parents) { + var builder = Keys., Object>builder(); + for (var parent : parents) { + addConvertedKeysFromParent(builder, parent); + } + return builder.build(); + } + + private static Keys2>, K1, K1> buildCombinedParametricKeys(List> parents) { + var builder = Keys2.>, K1, K1>builder(); + for (var parent : parents) { + addConvertedParametricKeysFromParent(builder, parent); + } + return builder.build(); + } + + private static

void addConvertedParametricKeysFromParent(Keys2.Builder>, K1, K1> builder, StreamCodecInterpreter

parent) { + builder.join(parent.parametricKeys().map(new Keys2.Converter<>() { + @Override + public App2>, X, Y> convert(App2>, X, Y> input) { + var value = ParametricKeyedValue.unbox(input); + return value.map(new ParametricKeyedValue.Converter<>() { + @Override + public App, App> convert(App, App> app) { + return new Holder<>(unbox(app).cast()); + } + }); + } + })); + } + + private static

void addConvertedKeysFromParent(Keys.Builder, Object> builder, StreamCodecInterpreter

void addKeyConsumer(List>> keyConsumers, KeyConsumer> original) { + keyConsumers.add(new KeyConsumer>() { + @Override + public Key key() { + return original.key(); + } + + @Override + public App, T> convert(App input) { + var converted = original.convert(input); + var stream = unbox(converted); + return new StreamCodecInterpreter.Holder<>(stream.cast()); + } + }); + } + + public StreamCodecInterpreter(Key> key, Keys, Object> keys, Keys2>, K1, K1> parametricKeys) { + this(key, List.of(), keys, parametricKeys); + } + + public static final Key> FRIENDLY_BYTE_BUF_KEY = Key.create("StreamCodecInterpreter"); + public static final Key> REGISTRY_FRIENDLY_BYTE_BUF_KEY = Key.create("StreamCodecInterpreter"); + + private static , B extends ByteBuf> ParametricKeyedValue, Const.Mu>, Const.Mu> numberRangeCodecParameter(StreamCodec codec) { + return new ParametricKeyedValue<>() { + @Override + public App, App, T>> convert(App>, T> parameter) { + var range = Const.unbox(parameter); + return new StreamCodecInterpreter.Holder<>(new StreamCodec, T>>() { + @Override + public App, T> decode(B buffer) { + var n = codec.decode(buffer); + if (n.compareTo(range.min()) < 0) { + throw new DecoderException("Value " + n + " is larger than max " + range.max()); + } else if (n.compareTo(range.max()) > 0) { + throw new DecoderException("Value " + n + " is smaller than min " + range.min()); + } + return Const.create(n); + } + + @Override + public void encode(B buffer, App, T> object2) { + var value = Const.unbox(object2); + if (value.compareTo(range.min()) < 0) { + throw new DecoderException("Value " + value + " is larger than max " + range.max()); + } else if (value.compareTo(range.max()) > 0) { + throw new DecoderException("Value " + value + " is smaller than min " + range.min()); + } + codec.encode(buffer, value); + } + }); + } + }; + } + + public StreamCodecInterpreter(Key> key) { + this( + key, + Keys., Object>builder().build(), + Keys2.>, K1, K1>builder().build() + ); + } + + @Override + public StreamCodecInterpreter with(Keys, Object> keys, Keys2>, K1, K1> parametricKeys) { + return new StreamCodecInterpreter<>(key, parents, keys().join(keys), parametricKeys().join(parametricKeys)); + } + + @Override + public DataResult, List>> list(App, A> single) { + return DataResult.success(new Holder<>(StreamCodecInterpreter.list(unbox(single)))); + } + + private static StreamCodec> list(StreamCodec elementCodec) { + return ByteBufCodecs.list().apply(elementCodec); + } + + @Override + public DataResult, A>> record(List> fields, Function creator) { + var streamFields = new ArrayList>(); + for (var field : fields) { + DataResult, A>> result = recordSingleField(field, streamFields); + if (result != null) return result; + } + return DataResult.success(new Holder<>(StreamCodec.of( + (buf, data) -> { + for (var field : streamFields) { + encodeSingleField(buf, field, data); + } + }, + buf -> { + var builder = RecordStructure.Container.builder(); + for (var field : streamFields) { + decodeSingleField(buf, field, builder); + } + return creator.apply(builder.build()); + } + ))); + } + + @Override + public DataResult, Y>> flatXmap(App, X> input, Function> to, Function> from) { + var streamCodec = unbox(input); + return DataResult.success(new Holder<>(streamCodec.map( + x -> to.apply(x).getOrThrow(), + y -> from.apply(y).getOrThrow() + ))); + } + + @Override + public DataResult, A>> annotate(Structure original, Keys annotations) { + // No annotations handled here + return original.interpret(this); + } + + @Override + public DataResult, E>> dispatch(String key, Structure keyStructure, Function> function, Supplier> keys, Function>> structures) { + return keyStructure.interpret(this).flatMap(keyCodecApp -> { + var keyStreamCodec = unbox(keyCodecApp); + var map = new ConcurrentHashMap>>(); + Function>> cache = k -> map.computeIfAbsent(k , structures.andThen(result -> result.flatMap(s -> s.interpret(this)).map(StreamCodecInterpreter::unbox))); + return DataResult.success(new Holder<>( + keyStreamCodec.dispatch(function.andThen(DataResult::getOrThrow), cache.andThen(DataResult::getOrThrow)) + )); + }); + } + + @Override + public DataResult, Map>> dispatchedMap(Structure keyStructure, Supplier> keys, Function>> valueStructures) { + return keyStructure.interpret(this).map(StreamCodecInterpreter::unbox).flatMap(keyCodec -> { + var map = new ConcurrentHashMap>>(); + Function>> cache = k -> map.computeIfAbsent(k , valueStructures.andThen(result -> result.flatMap(s -> s.interpret(this)).map(StreamCodecInterpreter::unbox))); + return DataResult.success(new Holder<>(new StreamCodec<>() { + @Override + public Map decode(B buffer) { + var map = new HashMap(); + var size = VarInt.read(buffer); + for (int i = 0; i < size; i++) { + var key = keyCodec.decode(buffer); + var valueCodec = cache.apply(key).getOrThrow(s -> new DecoderException("Could not find StreamCodec for key "+key+": "+s)); + var value = valueCodec.decode(buffer); + map.put(key, value); + } + return map; + } + + @Override + public void encode(B buffer, Map object) { + buffer.writeInt(object.size()); + object.forEach((key, value) -> { + keyCodec.encode(buffer, key); + var valueCodec = cache.apply(key).getOrThrow(s -> new EncoderException("Could not find StreamCodec for key "+ key +": "+s)); + encodeValue(buffer, valueCodec, value); + }); + } + + @SuppressWarnings("unchecked") + private void encodeValue(B buffer, StreamCodec valueCodec, V value) { + valueCodec.encode(buffer, (X) value); + } + })); + }); + } + + private static void encodeSingleField(B buf, Field field, A data) { + var missingBehaviour = field.missingBehavior(); + if (missingBehaviour.isEmpty()) { + field.codec.encode(buf, field.getter.apply(data)); + } else { + var behavior = missingBehaviour.get(); + if (behavior.predicate().test(field.getter.apply(data))) { + buf.writeBoolean(true); + field.codec.encode(buf, field.getter.apply(data)); + } else { + buf.writeBoolean(false); + } + } + } + + private static void decodeSingleField(B buf, Field field, RecordStructure.Container.Builder builder) { + var missingBehaviour = field.missingBehavior(); + if (missingBehaviour.isEmpty()) { + var value = field.codec.decode(buf); + builder.add(field.key(), value); + } else { + if (buf.readBoolean()) { + var value = field.codec.decode(buf); + builder.add(field.key(), value); + } else { + builder.add(field.key(), missingBehaviour.get().missing().get()); + } + } + } + + private @Nullable DataResult, A>> recordSingleField(RecordStructure.Field field, ArrayList> streamFields) { + var result = field.structure().interpret(this); + if (result.error().isPresent()) { + return DataResult.error(result.error().orElseThrow().messageSupplier()); + } + streamFields.add(new Field<>(unbox(result.result().orElseThrow()), field.key(), field.getter(), field.missingBehavior())); + return null; + } + + public static StreamCodec unbox(App, T> box) { + return Holder.unbox(box).streamCodec(); + } + + public DataResult> interpret(Structure structure) { + return structure.interpret(this).map(StreamCodecInterpreter::unbox); + } + + @Override + public Stream>> keyConsumers() { + return Stream.concat( + Stream.of(new KeyConsumer, Holder.Mu>() { + @Override + public Key> key() { + return key; + } + + @Override + public App, T> convert(App, T> input) { + return input; + } + }), + parentConsumers.stream() + ); + } + + @Override + public DataResult, Map>> unboundedMap(App, K> k, App, V> v) { + return DataResult.success(new Holder<>(new StreamCodec<>() { + @Override + public Map decode(B buffer) { + var map = new HashMap(); + int size = VarInt.read(buffer); + for (int i = 0; i < size; i++) { + K key = unbox(k).decode(buffer); + V value = unbox(v).decode(buffer); + map.put(key, value); + } + return map; + } + + @Override + public void encode(B buffer, Map object) { + VarInt.write(buffer, object.size()); + object.forEach((key, value) -> { + unbox(k).encode(buffer, key); + unbox(v).encode(buffer, value); + }); + } + })); + } + + @Override + public DataResult, Either>> either(App, L> left, App, R> right) { + var leftCodec = unbox(left); + var rightCodec = unbox(right); + return DataResult.success(new Holder<>(ByteBufCodecs.either(leftCodec, rightCodec))); + } + + @Override + public DataResult, Either>> xor(App, L> left, App, R> right) { + // For stream codecs, xor is just either + return either(left, right); + } + + public record Holder(StreamCodec streamCodec) implements App, T> { + public static final class Mu implements K1 {} + + static StreamCodecInterpreter.Holder unbox(App, T> box) { + return (StreamCodecInterpreter.Holder) box; + } + } + + private record Field(StreamCodec codec, RecordStructure.Key key, Function getter, Optional> missingBehavior) {} +} diff --git a/src/minecraft/java/dev/lukebemish/codecextras/stream/structured/package-info.java b/src/minecraft/java/dev/lukebemish/codecextras/stream/structured/package-info.java new file mode 100644 index 0000000..1cc6bcd --- /dev/null +++ b/src/minecraft/java/dev/lukebemish/codecextras/stream/structured/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Experimental +package dev.lukebemish.codecextras.stream.structured; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/minecraft/resources/assets/codecextras_minecraft/lang/en_us.json b/src/minecraft/resources/assets/codecextras_minecraft/lang/en_us.json new file mode 100644 index 0000000..7301cf7 --- /dev/null +++ b/src/minecraft/resources/assets/codecextras_minecraft/lang/en_us.json @@ -0,0 +1,29 @@ +{ + "codecextras.config.configurerecord": "Configure...", + "codecextras.config.configurelist": "Configure...", + "codecextras.config.missing": "Missing", + "codecextras.config.unit": "Not configurable", + "codecextras.config.list.icon.add": "+", + "codecextras.config.list.icon.up": "\u23f6", + "codecextras.config.list.icon.down": "\u23f7", + "codecextras.config.list.icon.remove": "\u274c", + "codecextras.config.list.add": "Add entry", + "codecextras.config.list.up": "Move up", + "codecextras.config.list.down": "Move down", + "codecextras.config.list.remove": "Remove entry", + "codecextras.config.either.switch": "Switch entry type", + "codecextras.config.dispatchedmap.icon.disabled": "Pick a type to configure", + "codecextras.config.datacomponent.keytoggle": "Toggle remove/add", + "codecextras.config.datacomponent.keytoggle.removes": "!", + "codecextras.config.datacomponent.notpersistent": "%s is not a persistent component", + "codecextras.config.json.object": "Object", + "codecextras.config.json.array": "Array", + "codecextras.config.json.string": "String", + "codecextras.config.json.number": "Number", + "codecextras.config.json.boolean": "Boolean", + "codecextras.config.json.raw": "Raw JSON", + "codecextras.config.json.type": "Change type", + "codecextras.config.issue": "Issues with configuration!", + "codecextras.config.issue.message": "Found issues:\n%s\nDo you wish to continue? Data may be lost if you do.", + "codecextras.config.unit.empty": "Empty" +} diff --git a/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/hue.png b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/hue.png new file mode 100644 index 0000000..69bae57 Binary files /dev/null and b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/hue.png differ diff --git a/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/hue.png.mcmeta b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/hue.png.mcmeta new file mode 100644 index 0000000..6409a7e --- /dev/null +++ b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/hue.png.mcmeta @@ -0,0 +1,7 @@ +{ + "gui": { + "scaling": { + "type": "stretch" + } + } +} diff --git a/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/transparent.png b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/transparent.png new file mode 100644 index 0000000..a51bf9d Binary files /dev/null and b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/transparent.png differ diff --git a/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/transparent.png.mcmeta b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/transparent.png.mcmeta new file mode 100644 index 0000000..9997ba9 --- /dev/null +++ b/src/minecraft/resources/assets/codecextras_minecraft/textures/gui/sprites/widget/transparent.png.mcmeta @@ -0,0 +1,10 @@ +{ + "gui": { + "scaling": { + "type": "tile", + "width": 8, + "height": 8 + } + } +} + diff --git a/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/CodecExtrasRegistriesRegistrar.java b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/CodecExtrasRegistriesRegistrar.java new file mode 100644 index 0000000..931a3b1 --- /dev/null +++ b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/CodecExtrasRegistriesRegistrar.java @@ -0,0 +1,18 @@ +package dev.lukebemish.codecextras.minecraft.fabric; + +import com.google.auto.service.AutoService; +import dev.lukebemish.codecextras.minecraft.structured.CodecExtrasRegistries; +import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder; + +@AutoService(CodecExtrasRegistries.RegistryRegistrar.class) +public final class CodecExtrasRegistriesRegistrar implements CodecExtrasRegistries.RegistryRegistrar { + public static Runnable PREPARE_DATA_COMPONENT_STRUCTURES = () -> { + throw new IllegalStateException("Registry not created yet"); + }; + + @Override + public void setup(RegistriesImpl registries) { + FabricRegistryBuilder.createSimple(CodecExtrasRegistries.DATA_COMPONENT_STRUCTURES).buildAndRegister(); + PREPARE_DATA_COMPONENT_STRUCTURES = registries.dataComponentStructures::prepare; + } +} diff --git a/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/mixin/MappedRegistryMixin.java b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/mixin/MappedRegistryMixin.java new file mode 100644 index 0000000..0d3ea75 --- /dev/null +++ b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/mixin/MappedRegistryMixin.java @@ -0,0 +1,23 @@ +package dev.lukebemish.codecextras.minecraft.fabric.mixin; + +import dev.lukebemish.codecextras.minecraft.fabric.CodecExtrasRegistriesRegistrar; +import dev.lukebemish.codecextras.minecraft.structured.CodecExtrasRegistries; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.Registry; +import net.minecraft.core.WritableRegistry; +import net.minecraft.resources.ResourceKey; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(MappedRegistry.class) +public abstract class MappedRegistryMixin implements WritableRegistry { + @SuppressWarnings({"RedundantCast", "rawtypes"}) + @Inject(method = "freeze()Lnet/minecraft/core/Registry;", at = @At("HEAD")) + private void onFreeze(CallbackInfoReturnable> cir) { + if ((ResourceKey) this.key() == CodecExtrasRegistries.DATA_COMPONENT_STRUCTURES) { + CodecExtrasRegistriesRegistrar.PREPARE_DATA_COMPONENT_STRUCTURES.run(); + } + } +} diff --git a/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/mixin/package-info.java b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/mixin/package-info.java new file mode 100644 index 0000000..c8660ed --- /dev/null +++ b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/mixin/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Internal +package dev.lukebemish.codecextras.minecraft.fabric.mixin; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/package-info.java b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/package-info.java new file mode 100644 index 0000000..3e08c0d --- /dev/null +++ b/src/minecraftFabric/java/dev/lukebemish/codecextras/minecraft/fabric/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Internal +package dev.lukebemish.codecextras.minecraft.fabric; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/minecraftFabric/resources/codecextras.minecraft.mixin.json b/src/minecraftFabric/resources/codecextras.minecraft.mixin.json new file mode 100644 index 0000000..4fe4439 --- /dev/null +++ b/src/minecraftFabric/resources/codecextras.minecraft.mixin.json @@ -0,0 +1,11 @@ +{ + "required": true, + "package": "dev.lukebemish.codecextras.minecraft.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "MappedRegistryMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/minecraftFabric/resources/fabric.mod.json b/src/minecraftFabric/resources/fabric.mod.json new file mode 100644 index 0000000..0fd15ac --- /dev/null +++ b/src/minecraftFabric/resources/fabric.mod.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "codecextras_minecraft", + "version": "${version}", + "name": "CodecExtras - Minecraft Adapters", + "license": "LGPL-3.0-only", + "description": "Minecraft-specific adapters for CodecExtras", + "authors": [ + "Luke Bemish" + ], + "depends": { + "minecraft": ">=${minecraft_version}" + }, + "mixins": [ + "codecextras.minecraft.mixin.json" + ] +} diff --git a/src/minecraftNeoforge/java/dev/lukebemish/codecextras/minecraft/neoforge/CodecExtrasNeoforge.java b/src/minecraftNeoforge/java/dev/lukebemish/codecextras/minecraft/neoforge/CodecExtrasNeoforge.java new file mode 100644 index 0000000..64e9ab7 --- /dev/null +++ b/src/minecraftNeoforge/java/dev/lukebemish/codecextras/minecraft/neoforge/CodecExtrasNeoforge.java @@ -0,0 +1,25 @@ +package dev.lukebemish.codecextras.minecraft.neoforge; + +import dev.lukebemish.codecextras.minecraft.structured.CodecExtrasRegistries; +import dev.lukebemish.codecextras.structured.Structure; +import net.minecraft.core.Registry; +import net.minecraft.core.component.DataComponentType; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.neoforge.registries.NewRegistryEvent; +import net.neoforged.neoforge.registries.RegistryBuilder; + +@Mod("codecextras_minecraft") +public final class CodecExtrasNeoforge { + private static final Registry>> DATA_COMPONENT_STRUCTURE_REGISTRY = new RegistryBuilder<>(CodecExtrasRegistries.DATA_COMPONENT_STRUCTURES).create(); + + public CodecExtrasNeoforge(IEventBus modBus) { + modBus.addListener(NewRegistryEvent.class, event -> { + event.register(DATA_COMPONENT_STRUCTURE_REGISTRY); + }); + modBus.addListener(FMLCommonSetupEvent.class, event -> { + event.enqueueWork(((CodecExtrasRegistries.RegistryRegistrar.RegistriesImpl) CodecExtrasRegistries.REGISTRIES).dataComponentStructures::prepare); + }); + } +} diff --git a/src/minecraftNeoforge/java/dev/lukebemish/codecextras/minecraft/neoforge/package-info.java b/src/minecraftNeoforge/java/dev/lukebemish/codecextras/minecraft/neoforge/package-info.java new file mode 100644 index 0000000..e63db2a --- /dev/null +++ b/src/minecraftNeoforge/java/dev/lukebemish/codecextras/minecraft/neoforge/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@ApiStatus.Internal +package dev.lukebemish.codecextras.minecraft.neoforge; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/src/minecraftNeoforge/resources/META-INF/neoforge.mods.toml b/src/minecraftNeoforge/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..531499e --- /dev/null +++ b/src/minecraftNeoforge/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,17 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="LGPL-3.0-only" +authors="Luke Bemish" + +[[mods]] +modId="codecextras_minecraft" +version="${version}" +displayName="CodecExtras - Minecraft Adapters" +description="Minecraft-specific adapters for CodecExtras" + +[[dependencies.codecextras_minecraft]] +modId="minecraft" +type="required" +versionRange="[${minecraft_version},)" +ordering="NONE" +side="BOTH" diff --git a/src/streamIntermediary/resources/fabric.mod.json b/src/streamIntermediary/resources/fabric.mod.json deleted file mode 100644 index 30f1d83..0000000 --- a/src/streamIntermediary/resources/fabric.mod.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev_lukebemish_codecextras-stream", - "version": "${version}", - "name": "CodecExtras - StreamCodecs" -} diff --git a/src/test/java/dev/lukebemish/codecextras/test/CodecAssertions.java b/src/test/java/dev/lukebemish/codecextras/test/CodecAssertions.java index 0c73632..781e12f 100644 --- a/src/test/java/dev/lukebemish/codecextras/test/CodecAssertions.java +++ b/src/test/java/dev/lukebemish/codecextras/test/CodecAssertions.java @@ -19,10 +19,22 @@ public static void assertDecodes(DynamicOps jsonOps, String jso public static void assertDecodes(DynamicOps ops, T data, O expected, Codec codec) { DataResult dataResult = codec.parse(ops, data); - Assertions.assertTrue(dataResult.result().isPresent()); + Assertions.assertTrue(dataResult.result().isPresent(), () -> dataResult.error().orElseThrow().message()); Assertions.assertEquals(expected, dataResult.result().get()); } + public static void assertDecodesOrPartial(DynamicOps jsonOps, String json, O expected, Codec codec) { + Gson gson = new GsonBuilder().create(); + JsonElement jsonElement = gson.fromJson(json, JsonElement.class); + assertDecodesOrPartial(jsonOps, jsonElement, expected, codec); + } + + public static void assertDecodesOrPartial(DynamicOps ops, T data, O expected, Codec codec) { + DataResult dataResult = codec.parse(ops, data); + Assertions.assertTrue(dataResult.resultOrPartial().isPresent(), () -> dataResult.error().orElseThrow().message()); + Assertions.assertEquals(expected, dataResult.resultOrPartial().get()); + } + public static void assertEncodes(DynamicOps jsonOps, O value, String json, Codec codec) { Gson gson = new GsonBuilder().create(); JsonElement jsonElement = gson.fromJson(json, JsonElement.class); @@ -31,7 +43,7 @@ public static void assertEncodes(DynamicOps jsonOps, O value, S public static void assertEncodes(DynamicOps ops, O value, T expected, Codec codec) { DataResult dataResult = codec.encodeStart(ops, value); - Assertions.assertTrue(dataResult.result().isPresent()); + Assertions.assertTrue(dataResult.result().isPresent(), () -> dataResult.error().orElseThrow().message()); Assertions.assertEquals(expected, dataResult.result().get()); } diff --git a/src/test/java/dev/lukebemish/codecextras/test/config/ConfigTypeTest.java b/src/test/java/dev/lukebemish/codecextras/test/config/ConfigTypeTest.java index c053407..7ccdfd3 100644 --- a/src/test/java/dev/lukebemish/codecextras/test/config/ConfigTypeTest.java +++ b/src/test/java/dev/lukebemish/codecextras/test/config/ConfigTypeTest.java @@ -92,9 +92,9 @@ protected TypeRewriteRule makeRule() { var a = dynamic.get("e").asInt(TestRecord.DEFAULT.a()); var b = dynamic.get("f").asInt(TestRecord.DEFAULT.b()); var c = dynamic.get("g").asFloat(TestRecord.DEFAULT.c()); - dynamic.remove("e"); - dynamic.remove("f"); - dynamic.remove("g"); + dynamic = dynamic.remove("e"); + dynamic = dynamic.remove("f"); + dynamic = dynamic.remove("g"); return dynamic .set("a", dynamic.createInt(a)) .set("b", dynamic.createInt(b)) diff --git a/src/test/java/dev/lukebemish/codecextras/test/record/TestExtendedRecords.java b/src/test/java/dev/lukebemish/codecextras/test/record/TestCurriedRecords.java similarity index 84% rename from src/test/java/dev/lukebemish/codecextras/test/record/TestExtendedRecords.java rename to src/test/java/dev/lukebemish/codecextras/test/record/TestCurriedRecords.java index c634dda..275ad65 100644 --- a/src/test/java/dev/lukebemish/codecextras/test/record/TestExtendedRecords.java +++ b/src/test/java/dev/lukebemish/codecextras/test/record/TestCurriedRecords.java @@ -2,13 +2,13 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.JsonOps; -import dev.lukebemish.codecextras.ExtendedRecordCodecBuilder; +import dev.lukebemish.codecextras.record.CurriedRecordCodecBuilder; import dev.lukebemish.codecextras.test.CodecAssertions; import org.junit.jupiter.api.Test; -class TestExtendedRecords { +class TestCurriedRecords { private record TestRecord(int a, int b, float c) { - public static final Codec CODEC = ExtendedRecordCodecBuilder + public static final Codec CODEC = CurriedRecordCodecBuilder .start(Codec.INT.fieldOf("a"), TestRecord::a) .field(Codec.INT.fieldOf("b"), TestRecord::b) .field(Codec.FLOAT.fieldOf("c"), TestRecord::c) diff --git a/src/test/java/dev/lukebemish/codecextras/test/record/package-info.java b/src/test/java/dev/lukebemish/codecextras/test/record/package-info.java new file mode 100644 index 0000000..faf86ef --- /dev/null +++ b/src/test/java/dev/lukebemish/codecextras/test/record/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.lukebemish.codecextras.test.record; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/dev/lukebemish/codecextras/test/structured/TestDispatch.java b/src/test/java/dev/lukebemish/codecextras/test/structured/TestDispatch.java new file mode 100644 index 0000000..8c7c02d --- /dev/null +++ b/src/test/java/dev/lukebemish/codecextras/test/structured/TestDispatch.java @@ -0,0 +1,178 @@ +package dev.lukebemish.codecextras.test.structured; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.structured.schema.JsonSchemaInterpreter; +import dev.lukebemish.codecextras.structured.schema.SchemaAnnotations; +import dev.lukebemish.codecextras.test.CodecAssertions; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class TestDispatch { + private interface Dispatches { + Map> MAP = new HashMap<>(); + Structure STRUCTURE = Structure.STRING.dispatch( + "type", + d -> DataResult.success(d.key()), + MAP::keySet, + k -> DataResult.success(MAP.get(k)) + ).annotate(SchemaAnnotations.REUSE_KEY, "dispatches"); + String key(); + } + + private record Abc(int a, String b, float c) implements Dispatches { + private static final Structure STRUCTURE = Structure.record(i -> { + var a = i.add("a", Structure.INT, Abc::a); + var b = i.add("b", Structure.STRING, Abc::b); + var c = i.add("c", Structure.FLOAT, Abc::c); + return container -> new Abc(a.apply(container), b.apply(container), c.apply(container)); + }).annotate(SchemaAnnotations.REUSE_KEY, "abc"); + + @Override + public String key() { + return "abc"; + } + } + + private record Xyz(String x, int y, float z) implements Dispatches { + private static final Structure STRUCTURE = Structure.record(i -> { + var x = i.add("x", Structure.STRING, Xyz::x); + var y = i.add("y", Structure.INT, Xyz::y); + var z = i.add("z", Structure.FLOAT, Xyz::z); + return container -> new Xyz(x.apply(container), y.apply(container), z.apply(container)); + }).annotate(SchemaAnnotations.REUSE_KEY, "xyz"); + + @Override + public String key() { + return "xyz"; + } + } + + static { + Dispatches.MAP.put("abc", Abc.STRUCTURE); + Dispatches.MAP.put("xyz", Xyz.STRUCTURE); + } + + private static final Structure> LIST_STRUCTURE = Dispatches.STRUCTURE.listOf(); + private static final Codec> CODEC = CodecInterpreter.create().interpret(LIST_STRUCTURE).getOrThrow(); + + private final String json = """ + [ + { + "type": "abc", + "a": 1, + "b": "test", + "c": 1.0 + }, + { + "type": "xyz", + "x": "test", + "y": 1, + "z": 1.0 + } + ]"""; + + private final String schema = """ + { + "$ref": "#/$defs/dispatches", + "$defs": { + "dispatches": { + "properties": { + "type": { + "type": "string", + "enum":["abc","xyz"] + } + }, + "required": [ + "type" + ], + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "abc" + } + } + }, + "then": { + "$ref": "#/$defs/abc" + } + }, + { + "if": { + "properties": { + "type": { + "const": "xyz" + } + } + }, + "then": { + "$ref": "#/$defs/xyz" + } + } + ] + }, + "xyz": { + "type": "object", + "properties": { + "x": { + "type": "string" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ] + }, + "abc": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "string" + }, + "c": { + "type": "number" + } + }, + "required": [ + "a", + "b", + "c" + ] + } + } + }"""; + + private final List list = List.of(new Abc(1, "test", 1.0f), new Xyz("test", 1, 1.0f)); + + @Test + void testDecoding() { + CodecAssertions.assertDecodes(JsonOps.INSTANCE, json, list, CODEC); + } + + @Test + void testEncoding() { + CodecAssertions.assertEncodes(JsonOps.INSTANCE, list, json, CODEC); + } + + @Test + void testJsonSchema() { + CodecAssertions.assertJsonEquals(schema, new JsonSchemaInterpreter().interpret(Dispatches.STRUCTURE).getOrThrow().toString()); + } +} diff --git a/src/test/java/dev/lukebemish/codecextras/test/structured/TestParametricKeys.java b/src/test/java/dev/lukebemish/codecextras/test/structured/TestParametricKeys.java new file mode 100644 index 0000000..d231fcc --- /dev/null +++ b/src/test/java/dev/lukebemish/codecextras/test/structured/TestParametricKeys.java @@ -0,0 +1,80 @@ +package dev.lukebemish.codecextras.test.structured; + +import com.mojang.datafixers.kinds.App; +import com.mojang.datafixers.kinds.K1; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Key2; +import dev.lukebemish.codecextras.structured.Keys; +import dev.lukebemish.codecextras.structured.Keys2; +import dev.lukebemish.codecextras.structured.MapCodecInterpreter; +import dev.lukebemish.codecextras.structured.ParametricKeyedValue; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.test.CodecAssertions; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +public class TestParametricKeys { + private record WithType(String string) implements App { + public static final class Mu implements K1 { private Mu() {} } + + private static WithType unbox(App box) { + return (WithType) box; + } + } + + private record Prefix(String string) implements App { + public static final class Mu implements K1 { private Mu() {} } + + private static Prefix unbox(App box) { + return (Prefix) box; + } + } + + private static Codec> withTypeCodec(Prefix prefix) { + return Codec.STRING.comapFlatMap( + s -> s.startsWith(prefix.string()) ? + DataResult.success(new WithType<>(s.substring(prefix.string().length()))) : + DataResult.error(() -> "Provided string \""+s+"\" does not start with prefix \""+prefix.string()+"\""), + w -> prefix.string()+w.string() + ); + } + + private static final Key2 WITH_TYPE = Key2.create("with_type"); + + private static final Structure> STRUCTURE = Structure.parametricallyKeyed( + WITH_TYPE, + new Prefix<>("prefix:"), + WithType::unbox + ); + + private static final Codec> CODEC = CodecInterpreter.create( + Keys.builder().build(), + Keys.builder().build(), + Keys2., K1, K1>builder() + .add(WITH_TYPE, new ParametricKeyedValue<>() { + @Override + public App> convert(App parameter) { + return new CodecInterpreter.Holder<>(withTypeCodec(Prefix.unbox(parameter)).xmap(Function.identity(), WithType::unbox)); + } + }) + .build(), + Keys2., K1, K1>builder().build() + ).interpret(STRUCTURE).getOrThrow(); + + private final String json = "\"prefix:123\""; + + private final WithType data = new WithType<>("123"); + + @Test + void testEncodingCodec() { + CodecAssertions.assertEncodes(JsonOps.INSTANCE, data, json, CODEC); + } + + @Test + void testDecodingCodec() { + CodecAssertions.assertDecodes(JsonOps.INSTANCE, json, data, CODEC); + } +} diff --git a/src/test/java/dev/lukebemish/codecextras/test/structured/TestPartialResults.java b/src/test/java/dev/lukebemish/codecextras/test/structured/TestPartialResults.java new file mode 100644 index 0000000..37f179f --- /dev/null +++ b/src/test/java/dev/lukebemish/codecextras/test/structured/TestPartialResults.java @@ -0,0 +1,43 @@ +package dev.lukebemish.codecextras.test.structured; + +import static dev.lukebemish.codecextras.test.CodecAssertions.*; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Structure; +import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +public class TestPartialResults { + @Test + void testPartialResults() { + final var codec = RecordCodecBuilder.>create( + instance -> instance.group( + Codec.STRING.listOf().fieldOf("example").forGetter(Function.identity()) + ).apply(instance, Function.identity()) + ); + + final var structure = Structure.>record(i -> + i.add("example", Structure.STRING.listOf(),Function.identity()) + ); + + final var json = """ + { + "example": [ + "abc", + 123, + "def" + ] + }"""; + final var expected = List.of("abc", "def"); + + assertDecodesOrPartial(JsonOps.INSTANCE, json, expected, codec); + assertDecodesOrPartial(JsonOps.INSTANCE, json, expected, CodecInterpreter.create() + .interpret(structure) + .getOrThrow() + ); + } +} diff --git a/src/test/java/dev/lukebemish/codecextras/test/structured/TestStructured.java b/src/test/java/dev/lukebemish/codecextras/test/structured/TestStructured.java new file mode 100644 index 0000000..18149b2 --- /dev/null +++ b/src/test/java/dev/lukebemish/codecextras/test/structured/TestStructured.java @@ -0,0 +1,81 @@ +package dev.lukebemish.codecextras.test.structured; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import dev.lukebemish.codecextras.structured.Annotation; +import dev.lukebemish.codecextras.structured.CodecInterpreter; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.structured.schema.JsonSchemaInterpreter; +import dev.lukebemish.codecextras.test.CodecAssertions; +import dev.lukebemish.codecextras.types.Identity; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TestStructured { + private record TestRecord(int a, String b, List c, Optional d, Identity e) { + private static final Structure STRUCTURE = Structure.record(i -> { + var a = i.add("a", Structure.INT.annotate(Annotation.COMMENT, "Field A"), TestRecord::a); + var b = i.add(Structure.STRING.fieldOf("b"), TestRecord::b); + var c = i.add("c", Structure.BOOL.listOf(), TestRecord::c); + var d = i.add(Structure.STRING.optionalFieldOf("d"), TestRecord::d); + var e = i.addOptional("e", Structure.STRING.annotate(Annotation.PATTERN, "^[a-z]+$").xmap(Identity::new, Identity::value), TestRecord::e, () -> new Identity<>("default")); + return container -> new TestRecord(a.apply(container), b.apply(container), c.apply(container), d.apply(container), e.apply(container)); + }); + + private static final Codec CODEC = CodecInterpreter.create().interpret(STRUCTURE).getOrThrow(); + } + + private final String json = """ + { + "a": 1, + "b": "test", + "c": [true, false, true] + }"""; + + private final String schema = """ + { + "type": "object", + "properties": { + "a": { + "type": "integer", + "description": "Field A" + }, + "b": { + "type": "string" + }, + "c": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "d": { + "type": "string" + }, + "e": { + "type": "string", + "default": "default", + "pattern": "^[a-z]+$" + } + }, + "required": ["a", "b", "c"] + }"""; + + private final TestRecord record = new TestRecord(1, "test", List.of(true, false, true), Optional.empty(), new Identity<>("default")); + + @Test + void testDecodingCodec() { + CodecAssertions.assertDecodes(JsonOps.INSTANCE, json, record, TestRecord.CODEC); + } + + @Test + void testEncodingCodec() { + CodecAssertions.assertEncodes(JsonOps.INSTANCE, record, json, TestRecord.CODEC); + } + + @Test + void testJsonSchema() { + CodecAssertions.assertJsonEquals(schema, new JsonSchemaInterpreter().interpret(TestRecord.STRUCTURE).getOrThrow().toString()); + } +} diff --git a/src/test/java/dev/lukebemish/codecextras/test/structured/package-info.java b/src/test/java/dev/lukebemish/codecextras/test/structured/package-info.java new file mode 100644 index 0000000..8bf892b --- /dev/null +++ b/src/test/java/dev/lukebemish/codecextras/test/structured/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.lukebemish.codecextras.test.structured; + +import org.jspecify.annotations.NullMarked; diff --git a/src/testCommon/java/dev/lukebemish/codecextras/test/common/TestConfig.java b/src/testCommon/java/dev/lukebemish/codecextras/test/common/TestConfig.java new file mode 100644 index 0000000..6019151 --- /dev/null +++ b/src/testCommon/java/dev/lukebemish/codecextras/test/common/TestConfig.java @@ -0,0 +1,125 @@ +package dev.lukebemish.codecextras.test.common; + +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Unit; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import dev.lukebemish.codecextras.config.ConfigType; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftInterpreters; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftStructures; +import dev.lukebemish.codecextras.structured.Annotation; +import dev.lukebemish.codecextras.structured.IdentityInterpreter; +import dev.lukebemish.codecextras.structured.Structure; +import dev.lukebemish.codecextras.structured.schema.SchemaAnnotations; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.registries.Registries; +import net.minecraft.references.Items; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Rarity; + +public record TestConfig( + int a, float b, boolean c, + String d, Optional e, Optional f, + Unit g, List strings, Dispatches dispatches, + int intInRange, float floatInRange, int argb, + int rgb, ResourceKey item, Rarity rarity, + Map unbounded, Either either, Map dispatchedMap, + DataComponentPatch patch, ItemStack itemStack +) { + private static final Map> DISPATCHES = new HashMap<>(); + + public interface Dispatches { + Structure STRUCTURE = Structure.STRING.dispatch( + "type", + d -> DataResult.success(d.key()), + DISPATCHES::keySet, + k -> DataResult.success(DISPATCHES.get(k)) + ); + String key(); + } + + private record Abc(int a, String b, float c) implements Dispatches { + private static final Structure STRUCTURE = Structure.record(i -> { + var a = i.addOptional("a", Structure.INT, Abc::a, () -> 123); + var b = i.addOptional("b", Structure.STRING, Abc::b, () ->"gizmo"); + var c = i.addOptional("c", Structure.FLOAT, Abc::c, () -> 1.23f); + return container -> new Abc(a.apply(container), b.apply(container), c.apply(container)); + }).annotate(SchemaAnnotations.REUSE_KEY, "abc"); + + @Override + public String key() { + return "abc"; + } + } + + private record Xyz(String x, int y, float z) implements Dispatches { + private static final Structure STRUCTURE = Structure.record(i -> { + var x = i.addOptional("x", Structure.STRING, Xyz::x, () -> "gadget"); + var y = i.addOptional("y", Structure.INT, Xyz::y, () -> 345); + var z = i.addOptional("z", Structure.FLOAT, Xyz::z, () -> 3.45f); + return container -> new Xyz(x.apply(container), y.apply(container), z.apply(container)); + }).annotate(SchemaAnnotations.REUSE_KEY, "xyz"); + + @Override + public String key() { + return "xyz"; + } + } + + static { + DISPATCHES.put("abc", Abc.STRUCTURE); + DISPATCHES.put("xyz", Xyz.STRUCTURE); + } + + public static final Structure STRUCTURE = Structure.record(builder -> { + var a = builder.addOptional("a", Structure.INT.annotate(Annotation.DESCRIPTION, "Describes the field!").annotate(Annotation.TITLE, "Field A"), TestConfig::a, () -> 34); + var b = builder.addOptional("b", Structure.FLOAT, TestConfig::b, () -> 1.2f); + var c = builder.addOptional("c", Structure.BOOL, TestConfig::c, () -> true); + var d = builder.addOptional("d", Structure.STRING, TestConfig::d, () -> "test"); + var e = builder.addOptional("e", Structure.BOOL, TestConfig::e); + var f = builder.addOptional("f", Structure.STRING, TestConfig::f); + var g = builder.addOptional("g", Structure.UNIT, TestConfig::g, () -> Unit.INSTANCE); + var strings = builder.addOptional("strings", Structure.STRING.listOf(), TestConfig::strings, () -> List.of("test1", "test2")); + var dispatches = builder.addOptional("dispatches", Dispatches.STRUCTURE, TestConfig::dispatches, () -> IdentityInterpreter.INSTANCE.interpret(Abc.STRUCTURE).getOrThrow()); + var intInRange = builder.addOptional("intInRange", Structure.intInRange(10, 60), TestConfig::intInRange, () -> 50); + var floatInRange = builder.addOptional("floatInRange", Structure.floatInRange(1.0f, 5.0f), TestConfig::floatInRange, () -> 3.0f); + var argb = builder.addOptional("argb", MinecraftStructures.ARGB_COLOR, TestConfig::argb, () -> 0xFF0000FF); + var rgb = builder.addOptional("rgb", MinecraftStructures.RGB_COLOR, TestConfig::rgb, () -> 0xFF0000); + var item = builder.addOptional("item", MinecraftStructures.resourceKey(Registries.ITEM), TestConfig::item, () -> Items.MELON_SEEDS); + var rarity = builder.addOptional("rarity", Structure.stringRepresentable(Rarity::values, Rarity::getSerializedName), TestConfig::rarity, () -> Rarity.COMMON); + var unbounded = builder.addOptional("unbounded", Structure.unboundedMap(Structure.STRING, MinecraftStructures.RGB_COLOR), TestConfig::unbounded, () -> Map.of("test", 123)); + var either = builder.addOptional("either", Structure.either(Structure.STRING, MinecraftStructures.RGB_COLOR), TestConfig::either, () -> Either.right(0x00FFAA)); + var dispatchedMap = builder.addOptional("dispatchedMap", Structure.STRING.dispatchedMap(DISPATCHES::keySet, k -> DataResult.success(DISPATCHES.get(k))), TestConfig::dispatchedMap, Map::of); + var patch = builder.addOptional("patch", MinecraftStructures.DATA_COMPONENT_PATCH, TestConfig::patch, () -> DataComponentPatch.EMPTY); + var itemStack = builder.addOptional("itemStack", MinecraftStructures.OPTIONAL_ITEM_STACK, TestConfig::itemStack, () -> ItemStack.EMPTY); + return container -> new TestConfig( + a.apply(container), b.apply(container), c.apply(container), + d.apply(container), e.apply(container), f.apply(container), + g.apply(container), strings.apply(container), dispatches.apply(container), + intInRange.apply(container), floatInRange.apply(container), argb.apply(container), + rgb.apply(container), item.apply(container), rarity.apply(container), + unbounded.apply(container), either.apply(container), dispatchedMap.apply(container), + patch.apply(container), itemStack.apply(container) + ); + }); + + public static final Codec CODEC = MinecraftInterpreters.CODEC_INTERPRETER.interpret(STRUCTURE).getOrThrow(); + + public static final ConfigType CONFIG = new ConfigType<>() { + @Override + public Codec codec() { + return TestConfig.CODEC; + } + + @Override + public TestConfig defaultConfig() { + return IdentityInterpreter.INSTANCE.interpret(TestConfig.STRUCTURE).getOrThrow(); + } + }; +} diff --git a/src/testFabric/java/dev/lukebemish/codecextras/test/fabric/CodecExtrasModMenu.java b/src/testFabric/java/dev/lukebemish/codecextras/test/fabric/CodecExtrasModMenu.java new file mode 100644 index 0000000..dbab268 --- /dev/null +++ b/src/testFabric/java/dev/lukebemish/codecextras/test/fabric/CodecExtrasModMenu.java @@ -0,0 +1,29 @@ +package dev.lukebemish.codecextras.test.fabric; + +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; +import dev.lukebemish.codecextras.config.ConfigType; +import dev.lukebemish.codecextras.config.GsonOpsIo; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftInterpreters; +import dev.lukebemish.codecextras.minecraft.structured.config.ConfigScreenBuilder; +import dev.lukebemish.codecextras.minecraft.structured.config.ConfigScreenEntry; +import dev.lukebemish.codecextras.minecraft.structured.config.ConfigScreenInterpreter; +import dev.lukebemish.codecextras.minecraft.structured.config.EntryCreationContext; +import dev.lukebemish.codecextras.test.common.TestConfig; +import net.fabricmc.loader.api.FabricLoader; + +public class CodecExtrasModMenu implements ModMenuApi { + private static final ConfigType.ConfigHandle CONFIG = TestConfig.CONFIG + .handle(FabricLoader.getInstance().getConfigDir().resolve("codecextras_testmod.json"), GsonOpsIo.INSTANCE); + + @Override + public ConfigScreenFactory getModConfigScreenFactory() { + ConfigScreenEntry entry = new ConfigScreenInterpreter( + MinecraftInterpreters.CODEC_INTERPRETER + ).interpret(TestConfig.STRUCTURE).getOrThrow(); + + return parent -> ConfigScreenBuilder.create() + .add(entry, CONFIG::save, () -> EntryCreationContext.builder().build(), CONFIG::load) + .factory().apply(parent); + } +} diff --git a/src/testFabric/resources/fabric.mod.json b/src/testFabric/resources/fabric.mod.json new file mode 100644 index 0000000..5c7f5c7 --- /dev/null +++ b/src/testFabric/resources/fabric.mod.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "id": "codecextras_testmod", + "version": "1.0.0", + "name": "CodecExtras Test Mod", + "entrypoints": { + "modmenu": [ + "dev.lukebemish.codecextras.test.fabric.CodecExtrasModMenu" + ] + } +} diff --git a/src/testNeoforge/java/dev/lukebemish/codecextras/test/neoforge/CodecExtrasTest.java b/src/testNeoforge/java/dev/lukebemish/codecextras/test/neoforge/CodecExtrasTest.java new file mode 100644 index 0000000..bd2f896 --- /dev/null +++ b/src/testNeoforge/java/dev/lukebemish/codecextras/test/neoforge/CodecExtrasTest.java @@ -0,0 +1,31 @@ +package dev.lukebemish.codecextras.test.neoforge; + +import dev.lukebemish.codecextras.config.ConfigType; +import dev.lukebemish.codecextras.config.GsonOpsIo; +import dev.lukebemish.codecextras.minecraft.structured.MinecraftInterpreters; +import dev.lukebemish.codecextras.minecraft.structured.config.ConfigScreenBuilder; +import dev.lukebemish.codecextras.minecraft.structured.config.ConfigScreenEntry; +import dev.lukebemish.codecextras.minecraft.structured.config.ConfigScreenInterpreter; +import dev.lukebemish.codecextras.minecraft.structured.config.EntryCreationContext; +import dev.lukebemish.codecextras.test.common.TestConfig; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.neoforge.client.gui.IConfigScreenFactory; + +@Mod("codecextras_testmod") +public class CodecExtrasTest { + private static final ConfigType.ConfigHandle CONFIG = TestConfig.CONFIG + .handle(FMLPaths.CONFIGDIR.get().resolve("codecextras_testmod.json"), GsonOpsIo.INSTANCE); + + public CodecExtrasTest(ModContainer modContainer) { + modContainer.registerExtensionPoint(IConfigScreenFactory.class, (container, parent) -> { + var interpreter = new ConfigScreenInterpreter(MinecraftInterpreters.CODEC_INTERPRETER); + ConfigScreenEntry entry = interpreter.interpret(TestConfig.STRUCTURE).getOrThrow(); + + return ConfigScreenBuilder.create() + .add(entry, CONFIG::save, () -> EntryCreationContext.builder().build(), CONFIG::load) + .factory().apply(parent); + }); + } +} diff --git a/src/testNeoforge/java/dev/lukebemish/codecextras/test/neoforge/package-info.java b/src/testNeoforge/java/dev/lukebemish/codecextras/test/neoforge/package-info.java new file mode 100644 index 0000000..18c61a7 --- /dev/null +++ b/src/testNeoforge/java/dev/lukebemish/codecextras/test/neoforge/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.lukebemish.codecextras.test.neoforge; + +import org.jspecify.annotations.NullMarked; diff --git a/src/testNeoforge/resources/META-INF/neoforge.mods.toml b/src/testNeoforge/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..aa2b813 --- /dev/null +++ b/src/testNeoforge/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,9 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="LGPL-3.0-only" + +[[mods]] +modId="codecextras_testmod" +version="1.0.0" +displayName="CodecExtras Test Mod" +description="A test mod for CodecExtras" diff --git a/testFabric/build.gradle b/testFabric/build.gradle new file mode 100644 index 0000000..0a1313e --- /dev/null +++ b/testFabric/build.gradle @@ -0,0 +1,22 @@ +configurations { + normalRuntimeModClasses + runsImplementation.extendsFrom normalRuntimeModClasses +} + +loom { + runs { + configureEach { + runDir "runs/${it.name}" + } + } + + mods.register("codecextras") { + configuration configurations.normalRuntimeModClasses + } +} + +dependencies { + normalRuntimeModClasses(project(path: ':', configuration: 'minecraftFabricRuntimeModClasses')) { + transitive = false + } +} diff --git a/testNeoforge/build.gradle b/testNeoforge/build.gradle new file mode 100644 index 0000000..0264af2 --- /dev/null +++ b/testNeoforge/build.gradle @@ -0,0 +1,31 @@ +configurations { + normalRuntimeModClasses + runsImplementation.extendsFrom normalRuntimeModClasses + minecraftRuntimeModClasses + runsImplementation.extendsFrom minecraftRuntimeModClasses +} + +loom { + runs { + configureEach { + runDir "runs/${it.name}" + } + } + + mods.register("codecextras_minecraft") { + configuration configurations.minecraftRuntimeModClasses + } + + mods.register("codecextras") { + configuration configurations.normalRuntimeModClasses + } +} + +dependencies { + normalRuntimeModClasses(project(path: ':', configuration: 'runtimeModClasses')) { + transitive = false + } + minecraftRuntimeModClasses(project(path: ':', configuration: 'minecraftNeoforgeRuntimeModClasses')) { + transitive = false + } +} diff --git a/version.properties b/version.properties index 2e81983..4950f0d 100644 --- a/version.properties +++ b/version.properties @@ -1 +1 @@ -version=2.3.1 +version=3.0.0

parent) { + builder.join(parent.keys().map(new Keys.Converter<>() { + @Override + public App, A> convert(App, A> input) { + return new Holder<>(unbox(input).cast()); + } + })); + } + + private static