feat(ranking): add automatic trophy panels

This commit is contained in:
2026-09-01 21:24:27 +02:00
parent 7e459e3615
commit 4a74b6dfed
37 changed files with 1732 additions and 90 deletions
+5 -3
View File
@@ -12,11 +12,11 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs |
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, Drawer et Particuleur |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, panneau de trophées, Drawer et Particuleur |
| `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux |
| `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo |
| `onlyfun` | Only Fun | règles ludiques absurdes | lootboxes, Lucky Block, raretés et règles absurdes |
| `sanctuary` | Sanctuary | progression et aventure | progression globale, Atlas et monde principal flottant |
| `sanctuary` | Sanctuary | progression et aventure | progression globale, classements publics, Atlas et monde principal flottant |
| `events` | Events | événements serveur | socle vide |
| `masterkey` | MasterKey | administration et diagnostic | laboratoire minimal |
@@ -319,7 +319,9 @@ Le guide détaillé est dans [`masterkey/tools/PANORAMA.md`](masterkey/tools/PAN
## Red-Stoner et photographie
Red-Stoner possède quatre blocs actifs sous son namespace : `eight_bit_computer`, `controller`, `personal_computer` et `drawer`. Son onglet créatif expose aussi les trois appareils Canon et les seize styles de pellicule.
Red-Stoner possède sept blocs actifs sous son namespace : `eight_bit_computer`, `controller`, `personal_computer`, `drawer`, `particuleur`, `storage_terminal` et `trophy_panel`. Son onglet créatif expose aussi les trois appareils Canon et les seize styles de pellicule.
Le panneau mural `redstoner:trophy_panel` consomme lAPI publique de classement de Sanctuary sans conserver les scores. Le joueur qui le pose peut ajouter des pages statistiques en cliquant avec un item, tandis que tous les visiteurs peuvent faire défiler les pages. Sanctuary importe une fois les statistiques vanilla existantes, garde les classements par UUID — champion déconnecté compris — et fournit seulement un podium borné à trois entrées. Les composants privés de litem servant dicône et la base complète des joueurs ne sont jamais synchronisés au client.
L'ordinateur 8 bits et le contrôleur exécutent la machine virtuelle historique : programme assembleur borné à 4096 caractères, RAM, inventaire, six ports redstone directionnels, impulsions et sortie comparateur. Le contrôleur dispose de 1024 cellules de RAM contre 256 pour l'ordinateur. L'instruction destinée à la matrice d'affichage renvoie volontairement une erreur claire tant que ce périphérique reste différé.
+146 -57
View File
@@ -1506,7 +1506,7 @@ tasks.register("verifyWorkspace") {
|| personalComputerMigration.compatibility?.sanctuary_network_protocol != 13
|| personalComputerMigration.compatibility?.redstoner_target_data_version != 3
|| personalComputerMigration.compatibility?.redstoner_target_network_protocol != 5
|| redstonerManifest.custom?.sanctuary26?.data_version != 3
|| redstonerManifest.custom?.sanctuary26?.data_version != 4
|| redstonerManifest.custom?.sanctuary26?.network_protocol != 7
|| sanctuaryManifest.custom?.sanctuary26?.data_version < 8
|| !sanctuaryIdentity.contains('SAVE_KEY = "server_identity"')
@@ -2916,11 +2916,11 @@ tasks.register("verifyNotchEncounter") {
def textureHash = MessageDigest.getInstance("SHA-256").digest(texture.bytes).encodeHex().toString()
if (rootProject.itsalive_version != "0.0.0-alpha.38"
|| rootProject.ouch_version != "0.0.0-alpha.11"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| rootProject.masterkey_version != "0.0.0-alpha.9"
|| rootProject.pack_version != "26.2.0-alpha.229"
|| manifests.itsalive.custom?.sanctuary26?.data_version != 4
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.ouch.depends?.itsalive != ">=0.0.0-alpha.37"
|| !registry.contains('Identifier.fromNamespaceAndPath("portals", "notch")')
|| !resident.contains("setCanonicalUuid(canonical.getUUID())")
@@ -3010,7 +3010,7 @@ tasks.register("verifyMagicCarpet") {
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.18"
|| rootProject.onlyfun_version != "0.0.0-alpha.27"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4
|| manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5
|| manifests.iliketomoveit.depends?.sanctuary != ">=0.0.0-alpha.127"
@@ -3018,7 +3018,7 @@ tasks.register("verifyMagicCarpet") {
|| manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3
|| manifests.onlyfun.depends?.iliketomoveit != ">=0.0.0-alpha.17"
|| manifests.onlyfun.depends?.sanctuary != ">=0.0.0-alpha.129"
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24
|| migration.status != "released"
|| migration.source?.pack_version != "26.2.0-alpha.223"
@@ -3641,7 +3641,7 @@ tasks.register("verifyProgressivePauseMenuRelease") {
"sanctuary/src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| rootProject.onlyfun_version != "0.0.0-alpha.27"
|| release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version
|| migration.source?.modules != [sanctuary: "0.0.0-alpha.108", onlyfun: "0.0.0-alpha.17"]
@@ -3692,7 +3692,7 @@ tasks.register("verifyMirrorLavenderDrawerRelease") {
def drawerRecipe = file("redstoner/src/main/resources/data/redstoner/recipe/drawer.json")
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.anotherworld_version != "0.0.0-alpha.57"
|| rootProject.redstoner_version != "0.0.0-alpha.16"
|| rootProject.redstoner_version != "0.0.0-alpha.17"
|| release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version
|| migration.source?.pack_version != "26.2.0-alpha.191"
|| migration.source?.modules != [anotherworld: "0.0.0-alpha.48", redstoner: "0.0.0-alpha.9"]
@@ -4158,6 +4158,20 @@ project(":iliketomoveit") {
}
}
project(":sanctuary") {
tasks.register("rankingModelSmoke", JavaExec) {
group = "verification"
description = "Checks bounded ranking values, stable ties, ordering and forward-version safety."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingModelSmoke"
}
tasks.named("check") {
dependsOn tasks.named("rankingModelSmoke")
}
}
project(":redstoner") {
dependencies {
implementation project(":anotherworld")
@@ -4184,6 +4198,78 @@ project(":redstoner") {
mainClass = "fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalSmoke"
}
tasks.register("trophyPanelSmoke", JavaExec) {
group = "verification"
description = "Checks trophy-panel permissions, public page cycling, de-duplication and bounds."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRulesSmoke"
}
tasks.register("verifyTrophyPanel") {
group = "verification"
description = "Checks the Sanctuary ranking API and Red-Stoner trophy-panel integration contract."
inputs.file(rootProject.file("pack/migrations/trophy-rankings-data-v15-v4.json"))
inputs.files(fileTree("src/main/java") { include "**/*TrophyPanel*.java" })
inputs.files(fileTree(rootProject.file("sanctuary/src/main/java")) { include "**/api/ranking/*.java" })
inputs.files(fileTree("src/main/resources") { include "**/*trophy_panel*", "**/lang/*.json", "fabric.mod.json" })
inputs.files(fileTree(rootProject.file("sanctuary/src/main/resources/assets/sanctuary/lang")) { include "*.json" })
inputs.file(rootProject.file("sanctuary/src/main/resources/fabric.mod.json"))
doLast {
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/trophy-rankings-data-v15-v4.json"))
def redstonerManifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def sanctuaryManifest = new JsonSlurper().parse(rootProject.file(
"sanctuary/src/main/resources/fabric.mod.json"))
def registry = file(
"src/main/java/fr/koka99cab/sanctuary26/redstoner/registry/RedStonerRegistries.java").text
def panel = file(
"src/main/java/fr/koka99cab/sanctuary26/redstoner/block/entity/TrophyPanelBlockEntity.java").text
def rankingApi = rootProject.file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/ranking/SanctuaryRankingApi.java").text
def rankingData = rootProject.file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/ranking/SanctuaryRankingData.java").text
def requiredKeys = [
"block.redstoner.trophy_panel", "message.redstoner.trophy_panel.not_owner",
"message.redstoner.trophy_panel.configured"]
def redstonerLocales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/redstoner/lang/${it}.json"))
}
def sanctuaryLocales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(rootProject.file(
"sanctuary/src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (rootProject.redstoner_version != "0.0.0-alpha.17"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| redstonerManifest.custom?.sanctuary26?.data_version != 4
|| redstonerManifest.custom?.sanctuary26?.network_protocol != 7
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 15
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24
|| migration.modules?.sanctuary?.source_module_version != "0.0.0-alpha.133"
|| migration.modules?.sanctuary?.target_module_version != "0.0.0-alpha.134"
|| migration.modules?.redstoner?.source_module_version != "0.0.0-alpha.16"
|| migration.modules?.redstoner?.target_module_version != "0.0.0-alpha.17"
|| migration.migration?.vanilla_statistics_imported_once != true
|| migration.compatibility?.existing_saves_supported != true
|| migration.compatibility?.network_protocols_changed != false
|| !registry.contains('RedStonerMod.id("trophy_panel")')
|| !panel.contains("REFRESH_INTERVAL_TICKS = 20")
|| !panel.contains("CLIENT_SNAPSHOT_TAG")
|| !panel.contains("PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES)")
|| !rankingApi.contains("public static RankingSnapshot snapshot")
|| !rankingApi.contains("!player.isCreative() && !player.isSpectator()")
|| !rankingData.contains("vanillaStatsImported")
|| !rankingData.contains("MAX_VALUE")
|| redstonerLocales.any { locale -> requiredKeys.any { !locale.containsKey(it) } }
|| sanctuaryLocales.any { !it.containsKey("metric.sanctuary.ranking.play_time") }
|| !file("src/main/resources/assets/redstoner/models/block/trophy_panel.json").isFile()
|| !file("src/main/resources/data/redstoner/loot_table/blocks/trophy_panel.json").isFile()
|| !file("src/main/resources/data/redstoner/recipe/trophy_panel.json").isFile()) {
throw new GradleException("The automatic trophy-panel ranking contract is incomplete")
}
}
}
tasks.register("verifyRedStoner") {
group = "verification"
description = "Checks Red-Stoner computation and signal-transmission contracts."
@@ -4314,8 +4400,8 @@ project(":redstoner") {
"src/main/resources/data/redstoner/recipe/controller.json"))
def terminalRecipe = new JsonSlurper().parse(file(
"src/main/resources/data/redstoner/recipe/storage_terminal.json"))
if (project.version.toString() != "0.0.0-alpha.16"
|| manifest.custom?.sanctuary26?.data_version != 3
if (project.version.toString() != "0.0.0-alpha.17"
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 7
|| terminalMigration.source?.pack_version != "26.2.0-alpha.217"
|| terminalMigration.source?.modules != [
@@ -4472,6 +4558,8 @@ project(":redstoner") {
dependsOn tasks.named("verifyRedStoner")
dependsOn tasks.named("redstoneProgramSmoke")
dependsOn tasks.named("storageTerminalSmoke")
dependsOn tasks.named("trophyPanelSmoke")
dependsOn tasks.named("verifyTrophyPanel")
}
}
@@ -4488,7 +4576,7 @@ tasks.register("verifyShopPricingLayoutRelease") {
def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json"))
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.onlyfun_version != "0.0.0-alpha.27"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version
|| migration.source?.pack_version != "26.2.0-alpha.192"
|| migration.target?.pack_version != "26.2.0-alpha.193"
@@ -4582,7 +4670,7 @@ tasks.register("verifyShopCsvBossGatesRelease") {
def offerCatalog = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.onlyfun_version != "0.0.0-alpha.27"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version
|| migration.source?.pack_version != "26.2.0-alpha.194"
|| migration.source?.modules != [onlyfun: "0.0.0-alpha.18", sanctuary: "0.0.0-alpha.110"]
@@ -4675,7 +4763,7 @@ tasks.register("verifyQuestRequestsCauldronBatchesRelease") {
def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.itsalive_version != "0.0.0-alpha.38"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.196"
|| migration.target?.pack_version != "26.2.0-alpha.197"
|| migration.target?.modules != [itsalive: "0.0.0-alpha.29", sanctuary: "0.0.0-alpha.113"]
@@ -4775,7 +4863,7 @@ tasks.register("verifyQuestRequestContractsRelease") {
def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.itsalive_version != "0.0.0-alpha.38"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|| migration.target?.pack_version != "26.2.0-alpha.199"
@@ -4895,7 +4983,7 @@ tasks.register("verifyQuestRequestEscrowRelease") {
def packwizIndex = file("pack/packwiz/index.toml").text
def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.200"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|| migration.target?.pack_version != "26.2.0-alpha.201"
@@ -4907,7 +4995,7 @@ tasks.register("verifyQuestRequestEscrowRelease") {
|| migration.quest_request_escrow?.kill_progress_automatic != true
|| migration.quest_request_escrow?.reward_delivery_to_claimant_mailbox != true
|| migration.compatibility?.sanctuary_target_network_protocol != 21
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 14
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 15
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24
|| release.pack_version != "26.2.0-alpha.229"
|| release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.229.mrpack"
@@ -4952,7 +5040,7 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") {
|| rootProject.anotherworld_version != "0.0.0-alpha.57"
|| rootProject.itsalive_version != "0.0.0-alpha.38"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.18"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.201"
|| migration.source?.modules != [
itsalive: "0.0.0-alpha.30",
@@ -4986,7 +5074,7 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") {
|| itsAliveManifest.custom?.sanctuary26?.network_protocol != 4
|| moveItManifest.custom?.sanctuary26?.data_version != 4
|| moveItManifest.custom?.sanctuary26?.network_protocol != 5
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 14
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 15
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24
|| release.pack_version != "26.2.0-alpha.229"
|| release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.229.mrpack"
@@ -5110,7 +5198,7 @@ tasks.register("verifySafeVaultFeature") {
}.keySet()
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.anotherworld_version != "0.0.0-alpha.57"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.schema_version != 1
|| migration.source?.pack_version != "26.2.0-alpha.201"
|| migration.target?.pack_version != "26.2.0-alpha.202"
@@ -5970,7 +6058,7 @@ tasks.register("verifyAlpha210Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.199.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| rootProject.onlyfun_version != "0.0.0-alpha.27"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.18"
|| rootProject.itsalive_version != "0.0.0-alpha.38"
@@ -6007,7 +6095,7 @@ tasks.register("verifyAlpha210Release") {
|| migration.distribution?.jei?.target != "30.28.0.193"
|| migration.distribution?.jei?.modrinth_version_id != "qaCQ7LGO"
|| migration.distribution?.jei?.packwiz_and_prism_synchronized != true
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24
|| manifests.onlyfun.custom?.sanctuary26?.data_version != 2
|| manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3
@@ -6072,9 +6160,9 @@ tasks.register("verifyAlpha211Release") {
itsalive: "0.0.0-alpha.38",
ouch: "0.0.0-alpha.11",
masterkey: "0.0.0-alpha.9"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.133"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.134"]
def expectedDataVersions = [ambiance: 2, itsalive: 3, onlyfun: 2, ouch: 1, sanctuary: 11]
def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 14]
def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 15]
def expectedProtocols = [ambiance: 6, itsalive: 4, onlyfun: 3, ouch: 1, sanctuary: 22]
def workspaceProtocols = expectedProtocols + [sanctuary: 24]
def manifests = expectedTarget.collectEntries { id, version ->
@@ -6172,7 +6260,7 @@ tasks.register("verifyAlpha212Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.199.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.211"
|| migration.source?.modules != [sanctuary: "0.0.0-alpha.119"]
|| migration.target?.pack_version != "26.2.0-alpha.212"
@@ -6190,7 +6278,7 @@ tasks.register("verifyAlpha212Release") {
|| migration.distribution?.packwiz_index_refreshed != true
|| migration.distribution?.prism_modrinth_index_updated != true
|| migration.distribution?.shared_client_server_mrpack != true
|| manifest.custom?.sanctuary26?.data_version != 14
|| manifest.custom?.sanctuary26?.data_version != 15
|| manifest.custom?.sanctuary26?.network_protocol != 24
|| !minecraftMixin.contains('method = "pickBlockOrEntity"')
|| release.pack_version != "26.2.0-alpha.229"
@@ -6663,7 +6751,7 @@ tasks.register("verifyAlpha218Release") {
"file = \"mods/${id}-${version}.jar\""
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.redstoner_version != "0.0.0-alpha.16"
|| rootProject.redstoner_version != "0.0.0-alpha.17"
|| currentModules.findAll { id, version ->
id != "redstoner" && id != "sanctuary" && id != "onlyfun" && id != "iliketomoveit"
}.any {
@@ -6671,7 +6759,7 @@ tasks.register("verifyAlpha218Release") {
}
|| rootProject.onlyfun_version != "0.0.0-alpha.27"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.18"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.217"
|| migration.source?.modules != sourceModules
|| migration.target?.pack_version != "26.2.0-alpha.218"
@@ -6691,11 +6779,11 @@ tasks.register("verifyAlpha218Release") {
|| migration.distribution?.pack_release_updated != true
|| migration.distribution?.packwiz_index_refreshed != true
|| migration.distribution?.prism_modrinth_index_updated != true
|| manifests.redstoner.custom?.sanctuary26?.data_version != 3
|| manifests.redstoner.custom?.sanctuary26?.data_version != 4
|| manifests.redstoner.custom?.sanctuary26?.network_protocol != 7
|| manifests.onlyfun.custom?.sanctuary26?.data_version != 2
|| manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24
|| !rules.contains("MAX_CONTAINERS = 128")
|| !rules.contains("PAGE_SIZE = 54")
@@ -6757,7 +6845,7 @@ tasks.register("verifyAlpha219Release") {
ambiance: "0.0.0-alpha.22", sanctuary: "0.0.0-alpha.132",
iliketomoveit: "0.0.0-alpha.18", onlyfun: "0.0.0-alpha.27",
itsalive: "0.0.0-alpha.38", ouch: "0.0.0-alpha.11", masterkey: "0.0.0-alpha.9"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.133"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.134"]
def requiredJars = currentModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
@@ -6797,7 +6885,7 @@ tasks.register("verifyAlpha219Release") {
|| migration.compatibility?.iliketomoveit_network_protocol != 5
|| migration.compatibility?.ambiance_data_version != 2
|| migration.compatibility?.ambiance_network_protocol != 6
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4
|| manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5
@@ -6852,7 +6940,7 @@ tasks.register("verifyAlpha220Release") {
def networking = file(
"redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/network/RedStonerNetworking.java").text
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.redstoner_version != "0.0.0-alpha.16"
|| rootProject.redstoner_version != "0.0.0-alpha.17"
|| migration.source?.pack_version != "26.2.0-alpha.219"
|| migration.source?.modules != [redstoner: "0.0.0-alpha.11"]
|| migration.target?.pack_version != "26.2.0-alpha.220"
@@ -6876,7 +6964,7 @@ tasks.register("verifyAlpha220Release") {
|| migration.compatibility?.redstoner_data_version != 3
|| migration.compatibility?.redstoner_source_network_protocol != 6
|| migration.compatibility?.redstoner_target_network_protocol != 7
|| manifest.custom?.sanctuary26?.data_version != 3
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 7
|| !menu.contains("extends AbstractContainerMenu")
|| !menu.contains("addStandardInventorySlots")
@@ -7081,8 +7169,8 @@ tasks.register("verifyAlpha222Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.199.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.redstoner_version != "0.0.0-alpha.16"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.redstoner_version != "0.0.0-alpha.17"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.221"
|| migration.source?.modules != [
redstoner: "0.0.0-alpha.12", sanctuary: "0.0.0-alpha.122"]
@@ -7122,9 +7210,9 @@ tasks.register("verifyAlpha222Release") {
|| migration.distribution?.jei?.target != "30.29.0.196"
|| migration.distribution?.jei?.modrinth_version_id != "vvwsu2BR"
|| migration.distribution?.jei?.packwiz_and_prism_synchronized != true
|| manifests.redstoner.custom?.sanctuary26?.data_version != 3
|| manifests.redstoner.custom?.sanctuary26?.data_version != 4
|| manifests.redstoner.custom?.sanctuary26?.network_protocol != 7
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24
|| !rules.contains("MAX_SEARCH_LENGTH = 32")
|| !rules.contains("compactCount(int count)")
@@ -7294,7 +7382,7 @@ tasks.register("verifyAlpha223Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.199.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.222"
|| migration.source?.modules != [sanctuary: "0.0.0-alpha.124"]
|| migration.target?.pack_version != "26.2.0-alpha.223"
@@ -7330,7 +7418,7 @@ tasks.register("verifyAlpha223Release") {
|| migration.distribution?.jei?.target != "30.29.0.198"
|| migration.distribution?.jei?.modrinth_version_id != "67Dvn8sL"
|| migration.distribution?.jei?.packwiz_and_prism_synchronized != true
|| manifest.custom?.sanctuary26?.data_version != 14
|| manifest.custom?.sanctuary26?.data_version != 15
|| manifest.custom?.sanctuary26?.network_protocol != 24
|| !inventorySort.contains("COOLDOWN_TICKS = 8")
|| !inventorySort.contains("if (row == 0) continue")
@@ -7415,7 +7503,7 @@ tasks.register("verifyAlpha224Release") {
def currentModules = targetModules + [
itsalive: "0.0.0-alpha.38", iliketomoveit: "0.0.0-alpha.18",
onlyfun: "0.0.0-alpha.27", sanctuary: "0.0.0-alpha.132"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.133"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.134"]
def inventorySort = file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java").text
def clientProgress = file(
@@ -7466,7 +7554,7 @@ tasks.register("verifyAlpha224Release") {
|| migration.distribution?.prism_modrinth_index_updated != true
|| manifests.itsalive.custom?.sanctuary26?.data_version != 4
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 14
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 15
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24
|| !inventorySort.contains("instanceof ChestMenu")
|| !inventorySort.contains("instanceof ShulkerBoxMenu")
@@ -7537,12 +7625,12 @@ tasks.register("verifyAlpha225Release") {
sanctuary: "0.0.0-alpha.129"]
def currentModules = targetModules + [sanctuary: "0.0.0-alpha.132"]
def currentWorkspaceModules = currentModules + [
anotherworld: "0.0.0-alpha.57", sanctuary: "0.0.0-alpha.133"]
anotherworld: "0.0.0-alpha.57", sanctuary: "0.0.0-alpha.134"]
def workspaceModules = moduleIds.collectEntries { id ->
[(id): project(":${id}").version.toString()]
}
def releasedModules = workspaceModules + [
sanctuary: "0.0.0-alpha.132"]
redstoner: "0.0.0-alpha.16", sanctuary: "0.0.0-alpha.132"]
def requiredJars = releasedModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
@@ -7703,12 +7791,12 @@ tasks.register("verifyAlpha226Release") {
[(id): project(":${id}").version.toString()]
}
def releasedModules = workspaceModules + [
sanctuary: "0.0.0-alpha.132"]
redstoner: "0.0.0-alpha.16", sanctuary: "0.0.0-alpha.132"]
def requiredJars = releasedModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.status != "released"
|| migration.source?.pack_version != "26.2.0-alpha.225"
|| migration.source?.modules != [sanctuary: "0.0.0-alpha.129"]
@@ -7725,7 +7813,7 @@ tasks.register("verifyAlpha226Release") {
|| migration.compatibility?.existing_saves_supported != true
|| migration.compatibility?.new_world_required != false
|| migration.compatibility?.save_reset_required != false
|| manifest.custom?.sanctuary26?.data_version != 14
|| manifest.custom?.sanctuary26?.data_version != 15
|| manifest.custom?.sanctuary26?.network_protocol != 24
|| baseRecipe.type != "minecraft:crafting_shaped"
|| baseRecipe.pattern != ["WW", "WW", "WW"]
@@ -7795,13 +7883,13 @@ tasks.register("verifyAlpha227Release") {
[(id): project(":${id}").version.toString()]
}
def releasedModules = workspaceModules + [
sanctuary: "0.0.0-alpha.132"]
redstoner: "0.0.0-alpha.16", sanctuary: "0.0.0-alpha.132"]
def requiredJars = releasedModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.anotherworld_version != "0.0.0-alpha.57"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.status != "released"
|| migration.source?.pack_version != "26.2.0-alpha.226"
|| migration.source?.modules != [
@@ -7837,7 +7925,7 @@ tasks.register("verifyAlpha227Release") {
|| anotherWorldManifest.depends?.sanctuary != ">=0.0.0-alpha.131"
|| anotherWorldManifest.custom?.sanctuary26?.data_version != 5
|| anotherWorldManifest.custom?.sanctuary26?.network_protocol != 2
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 14
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 15
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24
|| release.pack_version != "26.2.0-alpha.229"
|| release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.229.mrpack"
@@ -7894,13 +7982,13 @@ tasks.register("verifyAlpha228Release") {
[(id): project(":${id}").version.toString()]
}
def releasedModules = workspaceModules + [
sanctuary: "0.0.0-alpha.132"]
redstoner: "0.0.0-alpha.16", sanctuary: "0.0.0-alpha.132"]
def requiredJars = releasedModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.redstoner_version != "0.0.0-alpha.16"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.redstoner_version != "0.0.0-alpha.17"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.status != "released"
|| migration.source?.pack_version != "26.2.0-alpha.227"
|| migration.source?.modules != [
@@ -7932,9 +8020,9 @@ tasks.register("verifyAlpha228Release") {
|| migration.distribution?.pack_release_updated != true
|| migration.distribution?.packwiz_index_refreshed != true
|| migration.distribution?.prism_modrinth_index_updated != true
|| redStonerManifest.custom?.sanctuary26?.data_version != 3
|| redStonerManifest.custom?.sanctuary26?.data_version != 4
|| redStonerManifest.custom?.sanctuary26?.network_protocol != 7
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 14
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 15
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24
|| !terminalSearch.contains("DataComponents.CONTAINER")
|| !terminalSearch.contains("nonEmptyItemCopyStream")
@@ -7999,7 +8087,8 @@ tasks.register("verifyAlpha229Release") {
def workspaceModules = moduleIds.collectEntries { id ->
[(id): project(":${id}").version.toString()]
}
def releasedModules = workspaceModules + [sanctuary: "0.0.0-alpha.132"]
def releasedModules = workspaceModules + [
redstoner: "0.0.0-alpha.16", sanctuary: "0.0.0-alpha.132"]
def requiredJars = releasedModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
@@ -8009,8 +8098,8 @@ tasks.register("verifyAlpha229Release") {
"screen.redstoner.storage_terminal.sort.count_ascending",
"screen.redstoner.storage_terminal.sort.count_descending"]
if (rootProject.pack_version != "26.2.0-alpha.229"
|| rootProject.redstoner_version != "0.0.0-alpha.16"
|| rootProject.sanctuary_version != "0.0.0-alpha.133"
|| rootProject.redstoner_version != "0.0.0-alpha.17"
|| rootProject.sanctuary_version != "0.0.0-alpha.134"
|| migration.status != "released"
|| migration.source?.pack_version != "26.2.0-alpha.228"
|| migration.source?.modules != [redstoner: "0.0.0-alpha.15"]
@@ -8034,7 +8123,7 @@ tasks.register("verifyAlpha229Release") {
|| migration.distribution?.pack_release_updated != true
|| migration.distribution?.packwiz_index_refreshed != true
|| migration.distribution?.prism_modrinth_index_updated != true
|| manifest.custom?.sanctuary26?.data_version != 3
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 7
|| locales.any { locale -> requiredTranslationKeys.any { !locale.containsKey(it) } }
|| !sort.contains('ALPHABETICAL("alphabetical")')
+2 -2
View File
@@ -14,7 +14,7 @@ mod_version=0.0.0-alpha.0
module_lifecycle=standby
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.16
redstoner_version=0.0.0-alpha.17
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.57
anotherworld_lifecycle=active
@@ -28,7 +28,7 @@ iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.133
sanctuary_version=0.0.0-alpha.134
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.229
maven_group=fr.koka99cab.sanctuary26
@@ -0,0 +1,36 @@
{
"schema_version": 1,
"status": "released",
"modules": {
"sanctuary": {
"source_module_version": "0.0.0-alpha.133",
"target_module_version": "0.0.0-alpha.134",
"source_data_version": 14,
"target_data_version": 15
},
"redstoner": {
"source_module_version": "0.0.0-alpha.16",
"target_module_version": "0.0.0-alpha.17",
"source_data_version": 3,
"target_data_version": 4
}
},
"migration": {
"kind": "monotone_addition",
"ranking_save_key": "sanctuary:rankings",
"vanilla_statistics_imported_once": true,
"player_identity": "uuid",
"ranking_metric_index_version": 1,
"trophy_panel_id": "redstoner:trophy_panel",
"trophy_panel_data_version": 1,
"existing_redstoner_block_entities_unchanged": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"network_protocols_changed": false,
"newer_ranking_data_is_read_only": true,
"newer_panel_data_is_read_only": true
}
}
@@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory;
public final class RedStonerMod implements ModInitializer {
public static final String MOD_ID = "redstoner";
public static final String DISPLAY_NAME = "Red-Stoner";
public static final int CURRENT_DATA_VERSION = 3;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public RedStonerMod() {
@@ -29,7 +29,7 @@ public final class RedStonerMod implements ModInitializer {
RedStonerRegistries.initialize();
RedStonerMenus.initialize();
RedStonerNetworking.initialize();
LOGGER.info("[{}] Computers, Drawer, Particuleur and Storage Terminal ready (data v{}, network v{}).",
LOGGER.info("[{}] Computers, Drawer, Particuleur, Storage Terminal and Trophy Panel ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, RedStonerNetworking.PROTOCOL_VERSION);
}
}
@@ -0,0 +1,197 @@
package fr.koka99cab.sanctuary26.redstoner.block;
import com.mojang.serialization.MapCodec;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import java.util.Map;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.ScheduledTickAccess;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Mirror;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** Wall-mounted, server-authoritative trophy and ranking panel. */
public final class TrophyPanelBlock extends BaseEntityBlock {
public static final MapCodec<TrophyPanelBlock> CODEC = simpleCodec(TrophyPanelBlock::new);
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
private static final Map<Direction, VoxelShape> SHAPES = Map.of(
Direction.NORTH, Block.box(0.0D, 0.0D, 13.0D, 16.0D, 16.0D, 16.0D),
Direction.SOUTH, Block.box(0.0D, 0.0D, 0.0D, 16.0D, 16.0D, 3.0D),
Direction.WEST, Block.box(13.0D, 0.0D, 0.0D, 16.0D, 16.0D, 16.0D),
Direction.EAST, Block.box(0.0D, 0.0D, 0.0D, 3.0D, 16.0D, 16.0D)
);
public TrophyPanelBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override
public MapCodec<TrophyPanelBlock> codec() {
return CODEC;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new TrophyPanelBlockEntity(pos, state);
}
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
BlockEntityType<T> type) {
if (level.isClientSide()) return null;
return createTickerHelper(type, RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY,
TrophyPanelBlockEntity::serverTick);
}
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getClickedFace();
if (!facing.getAxis().isHorizontal()) return null;
BlockState state = defaultBlockState().setValue(FACING, facing);
return state.canSurvive(context.getLevel(), context.getClickedPos()) ? state : null;
}
@Override
protected boolean canSurvive(BlockState state, LevelReader level, BlockPos pos) {
Direction facing = state.getValue(FACING);
BlockPos support = pos.relative(facing.getOpposite());
return level.getBlockState(support).isFaceSturdy(level, support, facing);
}
@Override
protected BlockState updateShape(BlockState state, LevelReader level, ScheduledTickAccess ticks,
BlockPos pos, Direction direction, BlockPos neighborPos, BlockState neighborState, RandomSource random) {
return direction == state.getValue(FACING).getOpposite() && !state.canSurvive(level, pos)
? Blocks.AIR.defaultBlockState()
: super.updateShape(state, level, ticks, pos, direction, neighborPos, neighborState, random);
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPES.get(state.getValue(FACING));
}
@Override
public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
if (placer instanceof ServerPlayer player
&& level.getBlockEntity(pos) instanceof TrophyPanelBlockEntity panel) panel.claim(player);
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)
|| !(level.getBlockEntity(pos) instanceof TrophyPanelBlockEntity panel)) return InteractionResult.CONSUME;
if (player.isSecondaryUseActive()) return cycle(panel, serverPlayer);
boolean operator = serverPlayer.createCommandSourceStack().permissions()
.hasPermission(Permissions.COMMANDS_GAMEMASTER);
if (!panel.canConfigure(serverPlayer, operator)) {
serverPlayer.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.not_owner"));
return InteractionResult.CONSUME;
}
TrophyPanelBlockEntity.ConfigureResult result = panel.configure(serverPlayer, stack);
String key = switch (result) {
case SUCCESS -> "configured";
case NO_METRIC -> "no_metric";
case NEWER_DATA -> "newer_data";
};
serverPlayer.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel." + key));
return InteractionResult.SUCCESS_SERVER;
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)
|| !(level.getBlockEntity(pos) instanceof TrophyPanelBlockEntity panel)) return InteractionResult.CONSUME;
if (player.isSecondaryUseActive()) return cycle(panel, serverPlayer);
showDetails(panel, serverPlayer);
return InteractionResult.SUCCESS_SERVER;
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
}
@Override
protected BlockState rotate(BlockState state, Rotation rotation) {
return state.setValue(FACING, rotation.rotate(state.getValue(FACING)));
}
@Override
protected BlockState mirror(BlockState state, Mirror mirror) {
return state.rotate(mirror.getRotation(state.getValue(FACING)));
}
private static InteractionResult cycle(TrophyPanelBlockEntity panel, ServerPlayer player) {
if (!panel.cyclePage()) {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.newer_data"));
return InteractionResult.CONSUME;
}
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.page",
panel.pageNumber(), panel.pageCount()));
return InteractionResult.SUCCESS_SERVER;
}
private static void showDetails(TrophyPanelBlockEntity panel, ServerPlayer player) {
TrophyPanelBlockEntity.ClientSnapshot snapshot = panel.visibleSnapshot();
RankingUnit unit = snapshot.unit();
ItemStack icon = panel.currentPage().icon();
Component metric = icon.isEmpty() ? Component.translatable(snapshot.translationKey())
: Component.translatable(snapshot.translationKey(), icon.getHoverName());
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.header", metric,
panel.pageNumber(), panel.pageCount()));
for (TrophyPanelBlockEntity.ClientEntry entry : snapshot.podium()) {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.podium",
entry.rank(), entry.name(), unit.format(entry.value())));
}
if (snapshot.ownerRank() > 0) {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.owner_rank",
panel.ownerName(), snapshot.ownerRank(), unit.format(snapshot.ownerValue())));
} else {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.owner_unranked",
panel.ownerName()));
}
}
}
@@ -0,0 +1,274 @@
package fr.koka99cab.sanctuary26.redstoner.block.entity;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingSnapshot;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.UUIDUtil;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ItemOwner;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** Persistent panel configuration plus a transient, bounded client-visible ranking snapshot. */
public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwner {
private static final int DATA_VERSION = 1;
private static final int REFRESH_INTERVAL_TICKS = 20;
private static final String CLIENT_SNAPSHOT_TAG = "VisibleSnapshot";
private static final Codec<Page> PAGE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(Page::metricId),
ItemStack.OPTIONAL_CODEC.fieldOf("icon").forGetter(Page::icon)
).apply(instance, Page::new));
private static final Codec<ClientEntry> CLIENT_ENTRY_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("name").forGetter(ClientEntry::name),
Codec.LONG.fieldOf("value").forGetter(ClientEntry::value),
Codec.INT.fieldOf("rank").forGetter(ClientEntry::rank)
).apply(instance, ClientEntry::new));
private static final Codec<ClientSnapshot> CLIENT_SNAPSHOT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(ClientSnapshot::metricId),
Codec.STRING.fieldOf("translation_key").forGetter(ClientSnapshot::translationKey),
Codec.STRING.fieldOf("unit").forGetter(ClientSnapshot::unitKey),
CLIENT_ENTRY_CODEC.listOf(0, RankingSnapshot.MAX_PODIUM_SIZE).fieldOf("podium")
.forGetter(ClientSnapshot::podium),
Codec.LONG.fieldOf("owner_value").forGetter(ClientSnapshot::ownerValue),
Codec.INT.fieldOf("owner_rank").forGetter(ClientSnapshot::ownerRank)
).apply(instance, ClientSnapshot::new));
private int dataVersion = DATA_VERSION;
private UUID owner;
private String ownerName = "?";
private List<Page> pages = List.of(defaultPage());
private int currentPage;
private long lastRefreshTick = Long.MIN_VALUE;
private ClientSnapshot visibleSnapshot = ClientSnapshot.empty(SanctuaryRankingApi.defaultMetric());
public TrophyPanelBlockEntity(BlockPos pos, BlockState state) {
super(RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY, pos, state);
}
public static void serverTick(net.minecraft.world.level.Level level, BlockPos pos, BlockState state,
TrophyPanelBlockEntity panel) {
if (!(level instanceof ServerLevel serverLevel)) return;
long now = level.getGameTime();
if (panel.lastRefreshTick != Long.MIN_VALUE && now - panel.lastRefreshTick < REFRESH_INTERVAL_TICKS) return;
panel.lastRefreshTick = now;
panel.refresh(serverLevel);
}
public void claim(ServerPlayer player) {
if (!writable() || owner != null) return;
owner = player.getUUID();
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
}
public boolean canConfigure(ServerPlayer player, boolean operator) {
return writable() && TrophyPanelRules.canConfigure(owner, player.getUUID(), operator);
}
public ConfigureResult configure(ServerPlayer player, ItemStack stack) {
if (!writable()) return ConfigureResult.NEWER_DATA;
List<RankingMetric> offered = SanctuaryRankingApi.itemMetrics(stack);
if (offered.isEmpty()) return ConfigureResult.NO_METRIC;
List<Identifier> currentIds = pages.stream().map(Page::metricId).toList();
List<Identifier> mergedIds = TrophyPanelRules.mergeMetricIds(currentIds,
offered.stream().map(RankingMetric::id).toList());
List<Page> next = new ArrayList<>();
for (Identifier id : mergedIds) {
Page existing = pages.stream().filter(page -> page.metricId().equals(id)).findFirst().orElse(null);
next.add(existing == null ? new Page(id, stack.copyWithCount(1)) : existing);
}
pages = List.copyOf(next);
currentPage = Math.max(0, pages.indexOf(pages.stream()
.filter(page -> offered.stream().anyMatch(metric -> metric.id().equals(page.metricId())))
.findFirst().orElse(pages.getFirst())));
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return ConfigureResult.SUCCESS;
}
public boolean cyclePage() {
if (!writable() || pages.isEmpty()) return false;
currentPage = TrophyPanelRules.nextPage(currentPage, pages.size());
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return true;
}
public Page currentPage() {
return pages.get(Math.clamp(currentPage, 0, pages.size() - 1));
}
public int pageNumber() {
return currentPage + 1;
}
public int pageCount() {
return pages.size();
}
public String ownerName() {
return ownerName;
}
public ClientSnapshot visibleSnapshot() {
return visibleSnapshot;
}
private void refresh(ServerLevel level) {
Page page = currentPage();
RankingSnapshot snapshot = SanctuaryRankingApi.snapshot(level.getServer(), page.metricId(), owner);
ClientSnapshot next = ClientSnapshot.from(snapshot);
if (next.equals(visibleSnapshot)) return;
visibleSnapshot = next;
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_CLIENTS);
}
@Override
protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt("DataVersion", dataVersion);
if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner);
output.putString("OwnerName", ownerName);
output.store("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES), pages);
output.putInt("CurrentPage", currentPage);
}
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
dataVersion = Math.max(DATA_VERSION, input.getIntOr("DataVersion", 0));
owner = input.read("Owner", UUIDUtil.CODEC).orElse(null);
ownerName = cleanName(input.getStringOr("OwnerName", "?"));
pages = input.read("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES))
.map(TrophyPanelBlockEntity::sanitizePages).orElseGet(() -> List.of(defaultPage()));
currentPage = Math.clamp(input.getIntOr("CurrentPage", 0), 0, pages.size() - 1);
visibleSnapshot = input.read(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC)
.orElseGet(() -> ClientSnapshot.empty(metricFor(currentPage().metricId())));
}
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider lookup) {
CompoundTag tag = saveCustomOnly(lookup);
tag.store(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC, visibleSnapshot);
return tag;
}
@Override
public net.minecraft.world.level.Level level() {
return level;
}
@Override
public Vec3 position() {
return Vec3.atCenterOf(worldPosition);
}
@Override
public float getVisualRotationYInDegrees() {
return getBlockState().getValue(TrophyPanelBlock.FACING).toYRot();
}
private void syncPersistent() {
setChanged();
if (level != null) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_CLIENTS);
}
private boolean writable() {
return dataVersion <= DATA_VERSION;
}
private static List<Page> sanitizePages(List<Page> loaded) {
List<Page> valid = loaded.stream().filter(page -> SanctuaryRankingApi.metric(page.metricId()).isPresent())
.limit(TrophyPanelRules.MAX_PAGES).toList();
return valid.isEmpty() ? List.of(defaultPage()) : List.copyOf(valid);
}
private static Page defaultPage() {
return new Page(SanctuaryRankingApi.defaultMetric().id(), ItemStack.EMPTY);
}
private static RankingMetric metricFor(Identifier id) {
return SanctuaryRankingApi.metric(id).orElse(SanctuaryRankingApi.defaultMetric());
}
private static String cleanName(String name) {
if (name == null || name.isBlank()) return "?";
String clean = name.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
public enum ConfigureResult { SUCCESS, NO_METRIC, NEWER_DATA }
public record Page(Identifier metricId, ItemStack icon) {
public Page {
metricId = metricId == null ? SanctuaryRankingApi.defaultMetric().id() : metricId;
// The registry item is enough for an icon. Player components (containers, names, lore) never persist or sync.
icon = icon == null || icon.isEmpty() ? ItemStack.EMPTY : new ItemStack(icon.getItem());
}
}
public record ClientEntry(String name, long value, int rank) {
public ClientEntry {
name = cleanName(name);
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
}
public record ClientSnapshot(Identifier metricId, String translationKey, String unitKey,
List<ClientEntry> podium, long ownerValue, int ownerRank) {
public ClientSnapshot {
translationKey = translationKey == null || translationKey.isBlank()
? "metric.sanctuary.ranking.unknown" : translationKey;
unitKey = RankingUnit.byKey(unitKey).key();
podium = podium == null ? List.of() : List.copyOf(podium.stream()
.limit(RankingSnapshot.MAX_PODIUM_SIZE).toList());
ownerValue = Math.max(0L, ownerValue);
ownerRank = Math.max(0, ownerRank);
}
static ClientSnapshot from(RankingSnapshot snapshot) {
return new ClientSnapshot(snapshot.metric().id(), snapshot.metric().translationKey(),
snapshot.metric().unit().key(), snapshot.podium().stream()
.map(entry -> new ClientEntry(entry.playerName(), entry.value(), entry.rank())).toList(),
snapshot.owner().value(), snapshot.owner().rank());
}
static ClientSnapshot empty(RankingMetric metric) {
return new ClientSnapshot(metric.id(), metric.translationKey(), metric.unit().key(), List.of(), 0L, 0);
}
public RankingUnit unit() {
return RankingUnit.byKey(unitKey);
}
}
}
@@ -5,6 +5,7 @@ import fr.koka99cab.sanctuary26.redstoner.client.gui.ComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.PersonalComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.StorageTerminalScreen;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.DrawerBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.TrophyPanelBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.network.OpenComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.OpenPersonalComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
@@ -19,6 +20,8 @@ public final class RedStonerClient implements ClientModInitializer {
public void onInitializeClient() {
BlockEntityRendererRegistry.register(RedStonerRegistries.DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
BlockEntityRendererRegistry.register(RedStonerRegistries.LEGACY_DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
BlockEntityRendererRegistry.register(RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY,
TrophyPanelBlockEntityRenderer::new);
MenuScreens.register(RedStonerMenus.STORAGE_TERMINAL, StorageTerminalScreen::new);
ClientPlayNetworking.registerGlobalReceiver(OpenComputerPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(new ComputerScreen(payload))));
@@ -0,0 +1,104 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.state.TrophyPanelRenderState;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import net.minecraft.client.gui.Font;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.network.chat.Component;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class TrophyPanelBlockEntityRenderer
implements BlockEntityRenderer<TrophyPanelBlockEntity, TrophyPanelRenderState> {
private final ItemModelResolver itemModelResolver;
private final Font font;
public TrophyPanelBlockEntityRenderer(BlockEntityRendererProvider.Context context) {
itemModelResolver = context.itemModelResolver();
font = context.font();
}
@Override
public TrophyPanelRenderState createRenderState() {
return new TrophyPanelRenderState();
}
@Override
public void extractRenderState(TrophyPanelBlockEntity panel, TrophyPanelRenderState state,
float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) {
BlockEntityRenderer.super.extractRenderState(panel, state, tickProgress, cameraPos, crumblingOverlay);
state.facing = panel.getBlockState().getValue(TrophyPanelBlock.FACING);
state.icon.clear();
ItemStack icon = panel.currentPage().icon();
state.hasIcon = !icon.isEmpty();
if (state.hasIcon) {
itemModelResolver.updateForTopItem(state.icon, icon, ItemDisplayContext.FIXED,
panel.level(), panel, (int) panel.getBlockPos().asLong());
}
TrophyPanelBlockEntity.ClientSnapshot snapshot = panel.visibleSnapshot();
RankingUnit unit = snapshot.unit();
state.metric = state.hasIcon ? Component.translatable(snapshot.translationKey(), icon.getHoverName())
: Component.translatable(snapshot.translationKey());
if (snapshot.podium().isEmpty()) {
state.champion = Component.translatable("block.redstoner.trophy_panel.no_score");
} else {
TrophyPanelBlockEntity.ClientEntry champion = snapshot.podium().getFirst();
state.champion = Component.translatable("block.redstoner.trophy_panel.champion",
champion.name(), unit.format(champion.value()));
}
state.owner = snapshot.ownerRank() > 0
? Component.translatable("block.redstoner.trophy_panel.owner_rank", panel.ownerName(),
snapshot.ownerRank(), unit.format(snapshot.ownerValue()))
: Component.translatable("block.redstoner.trophy_panel.owner_unranked", panel.ownerName());
state.page = Component.literal(panel.pageNumber() + "/" + panel.pageCount());
}
@Override
public void submit(TrophyPanelRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) {
float yRotation = -state.facing.toYRot();
if (state.hasIcon) submitIcon(state, poseStack, submitNodeCollector, yRotation);
submitText(state.metric, 0.72F, 0.0065F, state, poseStack, submitNodeCollector, yRotation, 0xFFE8B4);
submitText(state.champion, 0.52F, 0.0060F, state, poseStack, submitNodeCollector, yRotation, 0xFFFFFF);
submitText(state.owner, 0.36F, 0.0055F, state, poseStack, submitNodeCollector, yRotation, 0xB8D9FF);
submitText(state.page, 0.20F, 0.0050F, state, poseStack, submitNodeCollector, yRotation, 0xA0A0A0);
}
private void submitIcon(TrophyPanelRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, float yRotation) {
poseStack.pushPose();
poseStack.translate(0.5F, 0.89F, 0.5F);
poseStack.mulPose(Axis.YP.rotationDegrees(yRotation));
poseStack.translate(0.0F, 0.0F, -0.33F);
poseStack.scale(0.18F, 0.18F, 0.18F);
state.icon.submit(poseStack, submitNodeCollector, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
poseStack.popPose();
}
private void submitText(Component component, float y, float scale, TrophyPanelRenderState state,
PoseStack poseStack, SubmitNodeCollector submitNodeCollector, float yRotation, int color) {
FormattedCharSequence text = component.getVisualOrderText();
float x = -font.width(text) / 2.0F;
poseStack.pushPose();
poseStack.translate(0.5F, y, 0.5F);
poseStack.mulPose(Axis.YP.rotationDegrees(yRotation));
poseStack.translate(0.0F, 0.0F, -0.331F);
poseStack.scale(scale, -scale, scale);
submitNodeCollector.submitText(poseStack, x, 0.0F, text, false, Font.DisplayMode.POLYGON_OFFSET,
color, state.lightCoords, 0, 0x30181818);
poseStack.popPose();
}
}
@@ -0,0 +1,16 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer.state;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
public final class TrophyPanelRenderState extends BlockEntityRenderState {
public final ItemStackRenderState icon = new ItemStackRenderState();
public Direction facing = Direction.NORTH;
public boolean hasIcon;
public Component metric = Component.empty();
public Component champion = Component.empty();
public Component owner = Component.empty();
public Component page = Component.empty();
}
@@ -6,10 +6,12 @@ import fr.koka99cab.sanctuary26.redstoner.block.DrawerBlock;
import fr.koka99cab.sanctuary26.redstoner.block.PersonalComputerBlock;
import fr.koka99cab.sanctuary26.redstoner.block.ParticuleurBlock;
import fr.koka99cab.sanctuary26.redstoner.block.StorageTerminalBlock;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.DrawerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ParticuleurBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import java.util.function.Function;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
@@ -71,6 +73,12 @@ public final class RedStonerRegistries {
BlockBehaviour.Properties.of().mapColor(MapColor.COLOR_GRAY).strength(4.0F, 6.0F).sound(SoundType.METAL)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("storage_terminal")))
);
public static final TrophyPanelBlock TROPHY_PANEL = registerBlock(
RedStonerMod.id("trophy_panel"),
TrophyPanelBlock::new,
BlockBehaviour.Properties.of().mapColor(MapColor.WOOD).strength(2.0F).sound(SoundType.WOOD)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("trophy_panel")))
);
public static final ComputerBlock LEGACY_EIGHT_BIT_COMPUTER = registerBlock(
LEGACY_COMPUTER_ID, ComputerBlock::new, computerProperties(LEGACY_COMPUTER_ID, MapColor.COLOR_RED));
@@ -92,6 +100,7 @@ public final class RedStonerRegistries {
public static final Item PARTICULEUR_ITEM = registerBlockItem(RedStonerMod.id("particuleur"), PARTICULEUR);
public static final Item STORAGE_TERMINAL_ITEM = registerBlockItem(
RedStonerMod.id("storage_terminal"), STORAGE_TERMINAL);
public static final Item TROPHY_PANEL_ITEM = registerBlockItem(RedStonerMod.id("trophy_panel"), TROPHY_PANEL);
public static final Item LEGACY_EIGHT_BIT_COMPUTER_ITEM = registerBlockItem(LEGACY_COMPUTER_ID, LEGACY_EIGHT_BIT_COMPUTER);
public static final Item LEGACY_CONTROLLER_ITEM = registerBlockItem(LEGACY_CONTROLLER_ID, LEGACY_CONTROLLER);
@@ -115,6 +124,8 @@ public final class RedStonerRegistries {
RedStonerMod.id("particuleur"), ParticuleurBlockEntity::new, PARTICULEUR);
public static final BlockEntityType<ParticuleurBlockEntity> LEGACY_PARTICULEUR_BLOCK_ENTITY = registerBlockEntity(
LEGACY_PARTICULEUR_ID, ParticuleurBlockEntity::new, LEGACY_PARTICULEUR);
public static final BlockEntityType<TrophyPanelBlockEntity> TROPHY_PANEL_BLOCK_ENTITY = registerBlockEntity(
RedStonerMod.id("trophy_panel"), TrophyPanelBlockEntity::new, TROPHY_PANEL);
public static final CreativeModeTab CREATIVE_TAB = Registry.register(
BuiltInRegistries.CREATIVE_MODE_TAB,
@@ -127,6 +138,7 @@ public final class RedStonerRegistries {
output.accept(CONTROLLER_ITEM);
output.accept(PARTICULEUR_ITEM);
output.accept(STORAGE_TERMINAL_ITEM);
output.accept(TROPHY_PANEL_ITEM);
})
.build()
);
@@ -142,7 +154,7 @@ public final class RedStonerRegistries {
}
initialized = true;
registerDrawerAttack();
RedStonerMod.LOGGER.info("[{}] Registered six active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
RedStonerMod.LOGGER.info("[{}] Registered seven active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
}
public static boolean isController(BlockState state) {
@@ -0,0 +1,29 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Pure bounded rules shared by the trophy panel and its smoke tests. */
public final class TrophyPanelRules {
public static final int MAX_PAGES = 16;
private TrophyPanelRules() {}
public static boolean canConfigure(UUID owner, UUID actor, boolean operator) {
return operator || owner != null && owner.equals(actor);
}
public static int nextPage(int currentPage, int pageCount) {
return pageCount <= 0 ? 0 : Math.floorMod(currentPage + 1, pageCount);
}
public static List<Identifier> mergeMetricIds(List<Identifier> current, List<Identifier> offered) {
LinkedHashSet<Identifier> merged = new LinkedHashSet<>();
if (current != null) merged.addAll(current);
if (offered != null) merged.addAll(offered);
return List.copyOf(new ArrayList<>(merged).subList(0, Math.min(MAX_PAGES, merged.size())));
}
}
@@ -0,0 +1,8 @@
{
"variants": {
"facing=north": { "model": "redstoner:block/trophy_panel" },
"facing=east": { "model": "redstoner:block/trophy_panel", "y": 90 },
"facing=south": { "model": "redstoner:block/trophy_panel", "y": 180 },
"facing=west": { "model": "redstoner:block/trophy_panel", "y": 270 }
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "redstoner:block/trophy_panel"
}
}
@@ -62,5 +62,19 @@
"screen.redstoner.storage_terminal.error.invalid_terminal": "The terminal is no longer accessible.",
"screen.redstoner.storage_terminal.error.disconnected": "No connected chest or barrel.",
"screen.redstoner.storage_terminal.error.too_large": "Network too large (maximum 128 storage blocks).",
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block."
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block.",
"block.redstoner.trophy_panel": "Trophy Panel",
"block.redstoner.trophy_panel.no_score": "No score yet",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · unranked",
"message.redstoner.trophy_panel.not_owner": "Only the owner or an operator can configure this panel.",
"message.redstoner.trophy_panel.configured": "Compatible pages added; the item was not consumed.",
"message.redstoner.trophy_panel.no_metric": "This item has no compatible statistic.",
"message.redstoner.trophy_panel.newer_data": "This panel uses a newer data format and remains read-only.",
"message.redstoner.trophy_panel.page": "Page %s/%s",
"message.redstoner.trophy_panel.header": "%s · page %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s: rank #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s: no score"
}
@@ -62,5 +62,19 @@
"screen.redstoner.storage_terminal.error.invalid_terminal": "Le terminal n'est plus accessible.",
"screen.redstoner.storage_terminal.error.disconnected": "Aucun coffre ou baril connecté.",
"screen.redstoner.storage_terminal.error.too_large": "Réseau trop grand (128 stockages maximum).",
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé."
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé.",
"block.redstoner.trophy_panel": "Panneau de trophées",
"block.redstoner.trophy_panel.no_score": "Aucun score",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · non classé",
"message.redstoner.trophy_panel.not_owner": "Seul le propriétaire ou un opérateur peut configurer ce panneau.",
"message.redstoner.trophy_panel.configured": "Pages compatibles ajoutées ; lobjet na pas été consommé.",
"message.redstoner.trophy_panel.no_metric": "Cet objet ne fournit aucune statistique compatible.",
"message.redstoner.trophy_panel.newer_data": "Ce panneau utilise un format de données plus récent et reste en lecture seule.",
"message.redstoner.trophy_panel.page": "Page %s/%s",
"message.redstoner.trophy_panel.header": "%s · page %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s : rang #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s : aucun score"
}
@@ -62,5 +62,19 @@
"screen.redstoner.storage_terminal.error.invalid_terminal": "Терминал больше недоступен.",
"screen.redstoner.storage_terminal.error.disconnected": "Нет подключённых сундуков или бочек.",
"screen.redstoner.storage_terminal.error.too_large": "Сеть слишком велика (не более 128 хранилищ).",
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище."
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище.",
"block.redstoner.trophy_panel": "Панель трофеев",
"block.redstoner.trophy_panel.no_score": "Результатов пока нет",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · без места",
"message.redstoner.trophy_panel.not_owner": "Настраивать эту панель может только владелец или оператор.",
"message.redstoner.trophy_panel.configured": "Совместимые страницы добавлены; предмет не был израсходован.",
"message.redstoner.trophy_panel.no_metric": "У этого предмета нет совместимой статистики.",
"message.redstoner.trophy_panel.newer_data": "Панель использует более новый формат данных и доступна только для чтения.",
"message.redstoner.trophy_panel.page": "Страница %s/%s",
"message.redstoner.trophy_panel.header": "%s · страница %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s: место #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s: результата нет"
}
@@ -0,0 +1,48 @@
{
"parent": "minecraft:block/block",
"textures": {
"particle": "minecraft:block/dark_oak_planks",
"wood": "minecraft:block/dark_oak_planks",
"frame": "minecraft:block/gold_block",
"screen": "minecraft:block/black_concrete"
},
"elements": [
{
"from": [0, 0, 13],
"to": [16, 16, 16],
"faces": {
"north": { "texture": "#wood" },
"south": { "texture": "#wood" },
"west": { "texture": "#wood" },
"east": { "texture": "#wood" },
"up": { "texture": "#wood" },
"down": { "texture": "#wood" }
}
},
{
"from": [1.5, 1.5, 12.5],
"to": [14.5, 14.5, 13],
"faces": { "north": { "texture": "#screen" } }
},
{
"from": [0.5, 0.5, 12.25],
"to": [15.5, 1.5, 13],
"faces": { "north": { "texture": "#frame" } }
},
{
"from": [0.5, 14.5, 12.25],
"to": [15.5, 15.5, 13],
"faces": { "north": { "texture": "#frame" } }
},
{
"from": [0.5, 1.5, 12.25],
"to": [1.5, 14.5, 13],
"faces": { "north": { "texture": "#frame" } }
},
{
"from": [14.5, 1.5, 12.25],
"to": [15.5, 14.5, 13],
"faces": { "north": { "texture": "#frame" } }
}
]
}
@@ -1 +1 @@
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstoner:trophy_panel","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
@@ -1 +1 @@
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstoner:trophy_panel","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
@@ -0,0 +1,15 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{ "type": "minecraft:item", "name": "redstoner:trophy_panel" }
],
"conditions": [
{ "condition": "minecraft:survives_explosion" }
]
}
],
"random_sequence": "redstoner:blocks/trophy_panel"
}
@@ -0,0 +1,19 @@
{
"type": "minecraft:crafting_shaped",
"category": "redstone",
"pattern": [
"NGN",
"GRG",
"NSN"
],
"key": {
"N": "minecraft:gold_nugget",
"G": "minecraft:black_stained_glass_pane",
"R": "minecraft:redstone_lamp",
"S": "minecraft:oak_sign"
},
"result": {
"id": "redstoner:trophy_panel",
"count": 1
}
}
+1 -1
View File
@@ -30,7 +30,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "redstoner",
"data_version": 3,
"data_version": 4,
"network_protocol": 7,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -0,0 +1,33 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
public final class TrophyPanelRulesSmoke {
private TrophyPanelRulesSmoke() {}
public static void main(String[] args) {
UUID owner = UUID.randomUUID();
UUID visitor = UUID.randomUUID();
require(TrophyPanelRules.canConfigure(owner, owner, false), "The owner cannot configure their panel");
require(!TrophyPanelRules.canConfigure(owner, visitor, false), "A visitor can configure another panel");
require(TrophyPanelRules.canConfigure(owner, visitor, true), "An operator cannot repair a panel");
require(TrophyPanelRules.nextPage(0, 3) == 1 && TrophyPanelRules.nextPage(2, 3) == 0,
"Public page cycling does not wrap");
Identifier first = Identifier.parse("sanctuary:first");
Identifier second = Identifier.parse("sanctuary:second");
List<Identifier> offered = java.util.stream.IntStream.range(0, TrophyPanelRules.MAX_PAGES + 5)
.mapToObj(index -> Identifier.parse("sanctuary:metric_" + index)).toList();
List<Identifier> merged = TrophyPanelRules.mergeMetricIds(List.of(first, second, first), offered);
require(merged.size() == TrophyPanelRules.MAX_PAGES, "Panel pages are not bounded");
require(merged.get(0).equals(first) && merged.get(1).equals(second),
"Existing page order was not preserved");
require(merged.stream().distinct().count() == merged.size(), "Duplicate metric pages were retained");
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
+14 -14
View File
@@ -199,7 +199,7 @@ tasks.register("verifyBlackMarket") {
def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text
def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text
def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2")
|| !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing")
|| !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5")
@@ -1208,7 +1208,7 @@ tasks.register("verifySanctuary") {
}
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 14
if (manifest.custom?.sanctuary26?.data_version != 15
|| manifest.custom?.sanctuary26?.network_protocol != 24
|| manifest.depends?.jei != ">=30.24.0.176"
|| manifest.depends?."fabric-api" != ">=0.155.0+26.2"
@@ -2915,7 +2915,7 @@ tasks.register("verifyShopCurrencyRowsRelease") {
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text
def shop = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.117"
|| migration.shop_currency_inventory?.all_unlocked_rows_counted != true
|| migration.shop_currency_inventory?.overflow_rows_counted != true
@@ -3081,7 +3081,7 @@ tasks.register("verifyProgressivePauseMenu") {
"pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json"))
def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text
def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.pack_version != "26.2.0-alpha.191"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.109"
|| migration.pause_menu?.progression_full_width != true
@@ -3119,7 +3119,7 @@ tasks.register("verifyShopPricingLayout") {
"pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json"))
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text
def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.110"
|| migration.shop_layout?.locked_offer_buttons != 6
|| migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9]
@@ -3150,7 +3150,7 @@ tasks.register("verifyShopCsvBossGates") {
"pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json"))
def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text
def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.pack_version != "26.2.0-alpha.195"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|| migration.compatibility?.sanctuary_data_version != 11
@@ -3181,7 +3181,7 @@ tasks.register("verifyDeliveryBoxOrientation") {
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json"))
def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet()
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|| migration.mailbox?.id != "sanctuary:delivery_box"
|| migration.mailbox?.horizontal_facing != true
@@ -3209,7 +3209,7 @@ tasks.register("verifyInventoryShiftClick") {
def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text
def gui = file("INVENTORY_GUI.md").text
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| !transfer.contains("shouldAppendOverflow")
|| !transfer.contains("isVanillaStorageRange")
|| !transfer.contains("hotbarStart")
@@ -3255,7 +3255,7 @@ tasks.register("verifyQuestRequests") {
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json"))
.pools[0].entries[0].name
}
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|| migration.quest_requests?.save_key != "quest_requests"
|| migration.quest_requests?.data_version != 1
@@ -3317,7 +3317,7 @@ tasks.register("verifyQuestRequestContracts") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|| migration.target?.pack_version != "26.2.0-alpha.199"
@@ -3386,7 +3386,7 @@ tasks.register("verifyQuestRequestEscrow") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.source?.pack_version != "26.2.0-alpha.200"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|| migration.target?.pack_version != "26.2.0-alpha.201"
@@ -3465,7 +3465,7 @@ tasks.register("verifyCraftableBannerCape") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.130"
|| migration.white_cape?.crafting_material_count != 6
|| migration.white_cape?.maximum_pattern_layers != 6
@@ -3520,7 +3520,7 @@ tasks.register("verifyAlphaIndev") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
@@ -3580,7 +3580,7 @@ tasks.register("verifyBackroom") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.133"
if (project.version.toString() != "0.0.0-alpha.134"
|| migration.source_data_version != 13 || migration.target_data_version != 14
|| migration.integration_modules?.anotherworld?.source != "0.0.0-alpha.56"
|| migration.integration_modules?.anotherworld?.target != "0.0.0-alpha.57"
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.sanctuary;
import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks;
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import fr.koka99cab.sanctuary26.sanctuary.network.SanctuaryNetworking;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryBulkActions;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventoryCapacity;
@@ -35,7 +36,7 @@ import org.slf4j.LoggerFactory;
public final class SanctuaryMod implements ModInitializer {
public static final String MOD_ID = "sanctuary";
public static final String DISPLAY_NAME = "Sanctuary";
public static final int CURRENT_DATA_VERSION = 14;
public static final int CURRENT_DATA_VERSION = 15;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public SanctuaryMod() {
@@ -48,6 +49,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
SanctuaryServerIdentity.register();
SanctuaryRankingApi.initialize();
SanctuaryDimensions.initialize();
SanctuaryDensityFunctions.register();
SanctuaryFeatures.register();
@@ -0,0 +1,22 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.Objects;
import net.minecraft.resources.Identifier;
/** Stable public description registered by gameplay modules, independent from SanctuaryStat ranks. */
public record RankingMetric(Identifier id, String translationKey, RankingUnit unit) {
public static final int MAX_IDENTIFIER_LENGTH = 256;
public static final int MAX_TRANSLATION_KEY_LENGTH = 128;
public RankingMetric {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(unit, "unit");
if (id.toString().length() > MAX_IDENTIFIER_LENGTH) {
throw new IllegalArgumentException("Ranking identifier must contain at most 256 characters");
}
translationKey = translationKey == null ? "" : translationKey.trim();
if (translationKey.isEmpty() || translationKey.length() > MAX_TRANSLATION_KEY_LENGTH) {
throw new IllegalArgumentException("Ranking translation key must contain 1-128 characters");
}
}
}
@@ -0,0 +1,44 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Server snapshot intentionally bounded to one visible metric and three podium entries. */
public record RankingSnapshot(RankingMetric metric, List<Entry> podium, Placement owner) {
public static final int MAX_PODIUM_SIZE = 3;
public RankingSnapshot {
podium = podium == null ? List.of() : List.copyOf(podium.stream().limit(MAX_PODIUM_SIZE).toList());
owner = owner == null ? Placement.unranked() : owner;
}
public static RankingSnapshot empty(RankingMetric metric) {
return new RankingSnapshot(metric, List.of(), Placement.unranked());
}
public record Entry(UUID playerId, String playerName, long value, int rank) {
public Entry {
playerName = sanitizeName(playerName);
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
}
public record Placement(long value, int rank) {
public Placement {
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
public static Placement unranked() {
return new Placement(0L, 0);
}
}
private static String sanitizeName(String value) {
if (value == null || value.isBlank()) return "?";
String clean = value.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
}
@@ -0,0 +1,49 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
/** Public, bounded display formats accepted by server ranking metrics. */
public enum RankingUnit {
COUNT("count"),
TICKS("ticks"),
CENTIMETERS("centimeters");
private final String key;
RankingUnit(String key) {
this.key = key;
}
public String key() {
return key;
}
public static RankingUnit byKey(String key) {
for (RankingUnit unit : values()) {
if (unit.key.equals(key)) return unit;
}
return COUNT;
}
/** Compact, locale-neutral representation suitable for signs and chat snapshots. */
public String format(long rawValue) {
long value = Math.max(0L, rawValue);
return switch (this) {
case TICKS -> formatDuration(value / 20L);
case CENTIMETERS -> value >= 100_000L ? (value / 100_000L) + " km" : (value / 100L) + " m";
case COUNT -> compact(value);
};
}
private static String formatDuration(long seconds) {
long hours = seconds / 3_600L;
long minutes = seconds % 3_600L / 60L;
if (hours > 0L) return hours + " h " + minutes + " min";
return minutes + " min";
}
private static String compact(long value) {
if (value >= 1_000_000_000L) return value / 1_000_000_000L + "G";
if (value >= 1_000_000L) return value / 1_000_000L + "M";
if (value >= 1_000L) return value / 1_000L + "K";
return Long.toString(value);
}
}
@@ -0,0 +1,102 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/**
* Public server-authoritative ranking API. Gameplay modules may register stable metric descriptions and
* increment them for real survival/adventure players; Red-Stoner only consumes bounded snapshots.
*/
public final class SanctuaryRankingApi {
private static final Map<Identifier, RankingMetric> METRICS = new ConcurrentHashMap<>();
private static final RankingMetric DEFAULT_METRIC = VanillaRankingMetric.descriptor(
VanillaRankingMetric.playTimeId());
private static boolean initialized;
private SanctuaryRankingApi() {}
public static void initialize() {
if (initialized) return;
initialized = true;
register(DEFAULT_METRIC);
ServerLifecycleEvents.SERVER_STARTED.register(server ->
SanctuaryRankingData.get(server).importVanillaStats(server));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
SanctuaryRankingData data = SanctuaryRankingData.get(server);
data.updateName(handler.player.getUUID(), handler.player.getGameProfile().name());
synchronizeObserved(server, METRICS.keySet());
});
}
public static RankingMetric register(RankingMetric metric) {
RankingMetric previous = METRICS.putIfAbsent(metric.id(), metric);
if (previous != null && !previous.equals(metric)) {
throw new IllegalStateException("Ranking metric already registered with another contract: " + metric.id());
}
return previous == null ? metric : previous;
}
public static RankingMetric defaultMetric() {
return DEFAULT_METRIC;
}
public static Optional<RankingMetric> metric(Identifier id) {
RankingMetric registered = METRICS.get(id);
if (registered != null) return Optional.of(registered);
return VanillaRankingMetric.parse(id).map(source -> {
RankingMetric descriptor = VanillaRankingMetric.descriptor(id);
METRICS.putIfAbsent(id, descriptor);
return descriptor;
});
}
public static List<RankingMetric> itemMetrics(ItemStack icon) {
List<RankingMetric> metrics = VanillaRankingMetric.itemMetrics(icon);
metrics.forEach(SanctuaryRankingApi::register);
return metrics;
}
public static boolean eligible(ServerPlayer player) {
return isRealOnlinePlayer(player) && !player.isCreative() && !player.isSpectator();
}
public static void increment(ServerPlayer player, Identifier metricId, long amount) {
if (!eligible(player) || amount <= 0L || metric(metricId).isEmpty()) return;
SanctuaryRankingData.get(player.level().getServer()).increment(player.getUUID(),
player.getGameProfile().name(), metricId, amount);
}
public static RankingSnapshot snapshot(MinecraftServer server, Identifier metricId, UUID ownerId) {
RankingMetric metric = metric(metricId).orElse(DEFAULT_METRIC);
synchronizeObserved(server, List.of(metric.id()));
return SanctuaryRankingData.get(server).snapshot(metric, ownerId);
}
public static void synchronizeObserved(MinecraftServer server, Collection<Identifier> metricIds) {
if (server == null || metricIds == null || metricIds.isEmpty()) return;
SanctuaryRankingData data = SanctuaryRankingData.get(server);
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
for (Identifier metricId : metricIds) {
VanillaRankingMetric.Source source = VanillaRankingMetric.parse(metricId).orElse(null);
if (source == null) continue;
data.observeVanilla(player.getUUID(), player.getGameProfile().name(), metricId,
VanillaRankingMetric.read(player, source), eligible(player));
}
}
}
private static boolean isRealOnlinePlayer(ServerPlayer player) {
return player != null && player.getClass() == ServerPlayer.class
&& player.level().getServer().getPlayerList().getPlayer(player.getUUID()) == player;
}
}
@@ -0,0 +1,292 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
import net.minecraft.world.level.storage.LevelResource;
/** Versioned UUID/metric index; Red-Stoner never owns or duplicates these scores. */
final class SanctuaryRankingData extends SavedData {
static final String SAVE_KEY = "rankings";
static final int DATA_VERSION = 1;
static final long MAX_VALUE = Long.MAX_VALUE / 4L;
private static final int MAX_METRICS_PER_PLAYER = 16_384;
private static final Codec<Score> SCORE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("value").forGetter(Score::value),
Codec.LONG.fieldOf("reached_order").forGetter(Score::reachedOrder)
).apply(instance, Score::new));
private static final Codec<Profile> PROFILE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.optionalFieldOf("last_name", "?").forGetter(Profile::lastName),
Codec.unboundedMap(Codec.STRING, SCORE_CODEC).optionalFieldOf("metrics", Map.of())
.forGetter(Profile::metrics),
Codec.unboundedMap(Codec.STRING, Codec.LONG).optionalFieldOf("vanilla_observed", Map.of())
.forGetter(Profile::vanillaObserved)
).apply(instance, Profile::new));
private static final Codec<SanctuaryRankingData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Codec.BOOL.optionalFieldOf("vanilla_stats_imported", false).forGetter(data -> data.vanillaStatsImported),
Codec.LONG.optionalFieldOf("next_reached_order", 1L).forGetter(data -> data.nextReachedOrder),
Codec.unboundedMap(Codec.STRING, PROFILE_CODEC).optionalFieldOf("players", Map.of())
.forGetter(data -> data.players)
).apply(instance, SanctuaryRankingData::new));
private static final SavedDataType<SanctuaryRankingData> TYPE = new SavedDataType<>(
SanctuaryMod.id(SAVE_KEY), SanctuaryRankingData::new, CODEC, null);
private int dataVersion;
private boolean vanillaStatsImported;
private long nextReachedOrder;
private final Map<String, Profile> players;
SanctuaryRankingData() {
this(DATA_VERSION, false, 1L, Map.of());
}
SanctuaryRankingData(int dataVersion, boolean vanillaStatsImported, long nextReachedOrder,
Map<String, Profile> players) {
this.dataVersion = dataVersion;
this.vanillaStatsImported = vanillaStatsImported;
this.nextReachedOrder = Math.max(1L, nextReachedOrder);
this.players = new HashMap<>();
players.forEach((player, profile) -> {
try {
UUID.fromString(player);
this.players.put(player, sanitize(profile));
} catch (IllegalArgumentException ignored) {
// A malformed external key is ignored rather than poisoning the complete save.
}
});
}
static SanctuaryRankingData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for ranking data");
SanctuaryRankingData data = overworld.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
} else if (data.dataVersion > DATA_VERSION) {
SanctuaryMod.LOGGER.warn("[{}] Ranking data v{} is newer than supported v{}; writes are disabled.",
SanctuaryMod.DISPLAY_NAME, data.dataVersion, DATA_VERSION);
}
return data;
}
boolean writable() {
return dataVersion <= DATA_VERSION;
}
void updateName(UUID playerId, String name) {
if (!writable()) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
String clean = cleanName(name);
if (current.lastName().equals(clean)) return;
players.put(key, new Profile(clean, current.metrics(), current.vanillaObserved()));
setDirty();
}
void recordValue(UUID playerId, String name, Identifier metricId, long rawValue) {
if (!writable() || rawValue < 0L) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
Map<String, Score> metrics = new HashMap<>(current.metrics());
String metricKey = metricId.toString();
Score previous = metrics.get(metricKey);
long value = Math.min(MAX_VALUE, rawValue);
if (previous != null && previous.value() >= value) {
updateName(playerId, name);
return;
}
if (previous == null && metrics.size() >= MAX_METRICS_PER_PLAYER) return;
metrics.put(metricKey, new Score(value, takeReachedOrder()));
players.put(key, new Profile(cleanName(name), metrics, current.vanillaObserved()));
setDirty();
}
void observeVanilla(UUID playerId, String name, Identifier metricId, long rawValue, boolean credited) {
if (!writable() || rawValue < 0L) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
Map<String, Long> observed = new HashMap<>(current.vanillaObserved());
String metricKey = metricId.toString();
Long previous = observed.get(metricKey);
if (previous == null && observed.size() >= MAX_METRICS_PER_PLAYER) return;
long value = Math.min(MAX_VALUE, rawValue);
if (previous == null || previous.longValue() != value || !current.lastName().equals(cleanName(name))) {
observed.put(metricKey, value);
players.put(key, new Profile(cleanName(name), current.metrics(), observed));
setDirty();
}
long creditedDelta = previous == null ? value : Math.max(0L, value - previous);
if (credited && creditedDelta > 0L) {
increment(playerId, name, metricId, creditedDelta);
}
}
void increment(UUID playerId, String name, Identifier metricId, long amount) {
if (!writable() || amount <= 0L) return;
long current = score(playerId, metricId);
long value = current >= MAX_VALUE - amount ? MAX_VALUE : current + amount;
recordValue(playerId, name, metricId, value);
}
long score(UUID playerId, Identifier metricId) {
Profile profile = players.get(playerId.toString());
Score score = profile == null ? null : profile.metrics().get(metricId.toString());
return score == null ? 0L : score.value();
}
RankingSnapshot snapshot(RankingMetric metric, UUID ownerId) {
List<Candidate> candidates = new ArrayList<>();
players.forEach((player, profile) -> {
Score score = profile.metrics().get(metric.id().toString());
if (score == null || score.value() <= 0L) return;
try {
candidates.add(new Candidate(UUID.fromString(player), profile.lastName(), score));
} catch (IllegalArgumentException ignored) {
// Already filtered during load, retained as defensive parsing.
}
});
candidates.sort(Comparator.comparingLong((Candidate candidate) -> candidate.score().value()).reversed()
.thenComparingLong(candidate -> candidate.score().reachedOrder())
.thenComparing(candidate -> candidate.playerId().toString()));
List<RankingSnapshot.Entry> podium = new ArrayList<>();
RankingSnapshot.Placement owner = RankingSnapshot.Placement.unranked();
long previousValue = -1L;
int rank = 0;
for (int index = 0; index < candidates.size(); index++) {
Candidate candidate = candidates.get(index);
if (candidate.score().value() != previousValue) {
rank = index + 1;
previousValue = candidate.score().value();
}
if (podium.size() < RankingSnapshot.MAX_PODIUM_SIZE) {
podium.add(new RankingSnapshot.Entry(candidate.playerId(), candidate.name(),
candidate.score().value(), rank));
}
if (candidate.playerId().equals(ownerId)) {
owner = new RankingSnapshot.Placement(candidate.score().value(), rank);
}
}
return new RankingSnapshot(metric, podium, owner);
}
void importVanillaStats(MinecraftServer server) {
if (!writable() || vanillaStatsImported) return;
Path statsDirectory = server.getWorldPath(LevelResource.PLAYER_STATS_DIR);
if (Files.isDirectory(statsDirectory)) {
try (Stream<Path> files = Files.list(statsDirectory)) {
files.filter(path -> path.getFileName().toString().endsWith(".json"))
.sorted().forEach(this::importVanillaFile);
} catch (IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Could not import existing vanilla statistics.",
SanctuaryMod.DISPLAY_NAME, exception);
return;
}
}
vanillaStatsImported = true;
setDirty();
}
void importVanillaFile(Path file) {
String fileName = file.getFileName().toString();
String idText = fileName.substring(0, fileName.length() - ".json".length());
UUID playerId;
try {
playerId = UUID.fromString(idText);
} catch (IllegalArgumentException ignored) {
return;
}
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
JsonObject stats = root.has("stats") && root.get("stats").isJsonObject()
? root.getAsJsonObject("stats") : new JsonObject();
for (Map.Entry<String, JsonElement> typeEntry : stats.entrySet()) {
Identifier type = Identifier.tryParse(typeEntry.getKey());
if (type == null || !typeEntry.getValue().isJsonObject()) continue;
for (Map.Entry<String, JsonElement> valueEntry : typeEntry.getValue().getAsJsonObject().entrySet()) {
Identifier target = Identifier.tryParse(valueEntry.getKey());
if (target == null || !valueEntry.getValue().isJsonPrimitive()
|| !valueEntry.getValue().getAsJsonPrimitive().isNumber()) continue;
Identifier metricId = VanillaRankingMetric.id(type, target);
long value = Math.max(0L, valueEntry.getValue().getAsLong());
recordValue(playerId, shortFallbackName(playerId), metricId, value);
observeVanilla(playerId, shortFallbackName(playerId), metricId, value, false);
}
}
} catch (RuntimeException | IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Ignoring unreadable vanilla statistic file {}.",
SanctuaryMod.DISPLAY_NAME, file.getFileName(), exception);
}
}
private long takeReachedOrder() {
long current = nextReachedOrder;
if (nextReachedOrder < Long.MAX_VALUE) nextReachedOrder++;
return current;
}
private static Profile sanitize(Profile profile) {
Map<String, Score> metrics = new HashMap<>();
profile.metrics().entrySet().stream().limit(MAX_METRICS_PER_PLAYER).forEach(entry -> {
if (Identifier.tryParse(entry.getKey()) != null) metrics.put(entry.getKey(), entry.getValue());
});
Map<String, Long> observed = new HashMap<>();
profile.vanillaObserved().entrySet().stream().limit(MAX_METRICS_PER_PLAYER).forEach(entry -> {
if (Identifier.tryParse(entry.getKey()) != null) {
observed.put(entry.getKey(), Math.clamp(entry.getValue(), 0L, MAX_VALUE));
}
});
return new Profile(cleanName(profile.lastName()), metrics, observed);
}
private static String cleanName(String value) {
if (value == null || value.isBlank()) return "?";
String clean = value.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
private static String shortFallbackName(UUID playerId) {
return playerId.toString().substring(0, 8);
}
record Score(long value, long reachedOrder) {
Score {
value = Math.clamp(value, 0L, MAX_VALUE);
reachedOrder = Math.max(1L, reachedOrder);
}
}
record Profile(String lastName, Map<String, Score> metrics, Map<String, Long> vanillaObserved) {
Profile {
lastName = cleanName(lastName);
metrics = metrics == null ? Map.of() : Map.copyOf(metrics);
vanillaObserved = vanillaObserved == null ? Map.of() : Map.copyOf(vanillaObserved);
}
}
private record Candidate(UUID playerId, String name, Score score) {}
}
@@ -0,0 +1,88 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
/** Stable encoding and server reader for persisted vanilla statistic identifiers. */
final class VanillaRankingMetric {
private static final String PREFIX = "vanilla/";
private static final List<String> ITEM_FAMILIES = List.of(
"mined", "crafted", "used", "broken", "picked_up", "dropped");
private VanillaRankingMetric() {}
static Identifier id(Identifier type, Identifier target) {
return SanctuaryMod.id(PREFIX + type.getNamespace() + "/" + type.getPath() + "/"
+ target.getNamespace() + "/" + target.getPath());
}
static Identifier playTimeId() {
return id(Identifier.withDefaultNamespace("custom"), Stats.PLAY_TIME);
}
static RankingMetric descriptor(Identifier id) {
Source source = parse(id).orElse(null);
if (source == null) {
return new RankingMetric(id, "metric.sanctuary.ranking.unknown", RankingUnit.COUNT);
}
boolean playTime = source.type().getPath().equals("custom") && source.target().equals(Stats.PLAY_TIME);
RankingUnit unit = playTime ? RankingUnit.TICKS : RankingUnit.COUNT;
return new RankingMetric(id, "metric.sanctuary.ranking."
+ (playTime ? "play_time" : source.type().getPath()), unit);
}
static List<RankingMetric> itemMetrics(ItemStack icon) {
if (icon == null || icon.isEmpty()) return List.of();
Item item = icon.getItem();
Identifier itemId = BuiltInRegistries.ITEM.getKey(item);
List<RankingMetric> metrics = new ArrayList<>();
for (String family : ITEM_FAMILIES) {
if (family.equals("mined") && !(item instanceof BlockItem)) continue;
Identifier metricId = id(Identifier.withDefaultNamespace(family), itemId);
metrics.add(new RankingMetric(metricId, "metric.sanctuary.ranking." + family, RankingUnit.COUNT));
}
return List.copyOf(metrics);
}
static Optional<Source> parse(Identifier metricId) {
if (!metricId.getNamespace().equals(SanctuaryMod.MOD_ID)
|| !metricId.getPath().startsWith(PREFIX)) return Optional.empty();
String[] parts = metricId.getPath().split("/", 5);
if (parts.length != 5) return Optional.empty();
Identifier type = Identifier.tryParse(parts[1] + ":" + parts[2]);
Identifier target = Identifier.tryParse(parts[3] + ":" + parts[4]);
return type == null || target == null ? Optional.empty() : Optional.of(new Source(type, target));
}
static long read(ServerPlayer player, Source source) {
String family = source.type().getPath();
Identifier target = source.target();
return switch (family) {
case "mined" -> BuiltInRegistries.BLOCK.getOptional(target)
.map(block -> (long) player.getStats().getValue(Stats.BLOCK_MINED, block)).orElse(0L);
case "crafted" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_CRAFTED, item)).orElse(0L);
case "used" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_USED, item)).orElse(0L);
case "broken" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_BROKEN, item)).orElse(0L);
case "picked_up" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_PICKED_UP, item)).orElse(0L);
case "dropped" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_DROPPED, item)).orElse(0L);
case "custom" -> (long) player.getStats().getValue(Stats.CUSTOM, target);
default -> 0L;
};
}
record Source(Identifier type, Identifier target) {}
}
@@ -529,5 +529,14 @@
"message.sanctuary.backroom.exited": "The bed returns you to your spawn point with your findings.",
"message.sanctuary.backroom.exited_with_losses": "The bed returns you with your findings and settles the recovered lost items.",
"screen.sanctuary.atlas.landmark.boss": "Defeated boss",
"message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world."
"message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world.",
"metric.sanctuary.ranking.play_time": "Play time",
"metric.sanctuary.ranking.mined": "%s mined",
"metric.sanctuary.ranking.crafted": "%s crafted",
"metric.sanctuary.ranking.used": "%s used",
"metric.sanctuary.ranking.broken": "%s broken",
"metric.sanctuary.ranking.picked_up": "%s picked up",
"metric.sanctuary.ranking.dropped": "%s dropped",
"metric.sanctuary.ranking.custom": "Custom statistic",
"metric.sanctuary.ranking.unknown": "Unknown statistic"
}
@@ -529,5 +529,14 @@
"message.sanctuary.backroom.exited": "Le lit te ramène à ton point dapparition avec tes trouvailles.",
"message.sanctuary.backroom.exited_with_losses": "Le lit te ramène avec tes trouvailles et valide les objets perdus récupérés.",
"screen.sanctuary.atlas.landmark.boss": "Boss vaincu",
"message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde."
"message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde.",
"metric.sanctuary.ranking.play_time": "Temps de jeu",
"metric.sanctuary.ranking.mined": "%s extrait",
"metric.sanctuary.ranking.crafted": "%s fabriqué",
"metric.sanctuary.ranking.used": "%s utilisé",
"metric.sanctuary.ranking.broken": "%s cassé",
"metric.sanctuary.ranking.picked_up": "%s ramassé",
"metric.sanctuary.ranking.dropped": "%s jeté",
"metric.sanctuary.ranking.custom": "Statistique personnalisée",
"metric.sanctuary.ranking.unknown": "Statistique inconnue"
}
@@ -485,5 +485,14 @@
"message.sanctuary.backroom.exited": "Кровать возвращает вас к точке возрождения со всеми находками.",
"message.sanctuary.backroom.exited_with_losses": "Кровать возвращает вас с находками и закрепляет найденные потерянные предметы.",
"screen.sanctuary.atlas.landmark.boss": "Побеждённый босс",
"message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире."
"message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире.",
"metric.sanctuary.ranking.play_time": "Время в игре",
"metric.sanctuary.ranking.mined": "Добыто: %s",
"metric.sanctuary.ranking.crafted": "Создано: %s",
"metric.sanctuary.ranking.used": "Использовано: %s",
"metric.sanctuary.ranking.broken": "Сломано: %s",
"metric.sanctuary.ranking.picked_up": "Подобрано: %s",
"metric.sanctuary.ranking.dropped": "Выброшено: %s",
"metric.sanctuary.ranking.custom": "Пользовательская статистика",
"metric.sanctuary.ranking.unknown": "Неизвестная статистика"
}
+1 -1
View File
@@ -32,7 +32,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "sanctuary",
"data_version": 14,
"data_version": 15,
"network_protocol": 24,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -0,0 +1,83 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import net.minecraft.SharedConstants;
import net.minecraft.resources.Identifier;
import net.minecraft.server.Bootstrap;
import net.minecraft.stats.Stats;
/** Pure ranking smoke for ordering, tied ranks, saturation and newer-save protection. */
public final class SanctuaryRankingModelSmoke {
private SanctuaryRankingModelSmoke() {}
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
Identifier metricId = Identifier.fromNamespaceAndPath("onlyfun", "opened_lootboxes");
RankingMetric metric = new RankingMetric(metricId,
"metric.onlyfun.ranking.opened_lootboxes", RankingUnit.COUNT);
UUID alex = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID bob = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID chris = UUID.fromString("00000000-0000-0000-0000-000000000003");
SanctuaryRankingData data = new SanctuaryRankingData();
data.recordValue(alex, "Alex", metricId, 10L);
data.recordValue(bob, "Bob", metricId, 10L);
data.recordValue(chris, "Chris", metricId, 5L);
RankingSnapshot tied = data.snapshot(metric, bob);
require(tied.podium().size() == 3, "Podium must remain bounded to available top three");
require(tied.podium().get(0).playerId().equals(alex),
"Earlier reached order must deterministically break a visual tie");
require(tied.podium().get(0).rank() == 1 && tied.podium().get(1).rank() == 1
&& tied.podium().get(2).rank() == 3, "Equal values must retain the same competition rank");
require(tied.owner().rank() == 1 && tied.owner().value() == 10L,
"Panel owner placement must reflect the real tied rank");
data.increment(chris, "Chris", metricId, Long.MAX_VALUE);
require(data.score(chris, metricId) == SanctuaryRankingData.MAX_VALUE,
"Public counters must saturate at the bounded long maximum");
Identifier usedStick = VanillaRankingMetric.id(Identifier.withDefaultNamespace("used"),
Identifier.withDefaultNamespace("stick"));
data.observeVanilla(alex, "Alex", usedStick, 100L, false);
data.observeVanilla(alex, "Alex", usedStick, 110L, false);
data.observeVanilla(alex, "Alex", usedStick, 115L, true);
require(data.score(alex, usedStick) == 5L,
"Creative or simulated vanilla deltas leaked into the public score");
data.observeVanilla(bob, "Bob", usedStick, 7L, true);
require(data.score(bob, usedStick) == 7L,
"A survival player's first observed vanilla statistics were discarded");
Identifier vanillaId = VanillaRankingMetric.id(
Identifier.withDefaultNamespace("custom"), Stats.PLAY_TIME);
require(VanillaRankingMetric.parse(vanillaId).orElseThrow().target().equals(Stats.PLAY_TIME)
&& VanillaRankingMetric.descriptor(vanillaId).unit() == RankingUnit.TICKS,
"Vanilla metric IDs and units must round-trip stably");
Path temporary = Files.createTempDirectory("sanctuary-ranking-migration-");
Path oldStats = temporary.resolve(alex + ".json");
Files.writeString(oldStats,
"{\"stats\":{\"minecraft:used\":{\"minecraft:stick\":12}}}", StandardCharsets.UTF_8);
SanctuaryRankingData imported = new SanctuaryRankingData();
imported.importVanillaFile(oldStats);
imported.importVanillaFile(oldStats);
require(imported.score(alex, usedStick) == 12L,
"Existing vanilla statistics were not imported monotonically exactly once");
Files.deleteIfExists(oldStats);
Files.deleteIfExists(temporary);
SanctuaryRankingData newer = new SanctuaryRankingData(
SanctuaryRankingData.DATA_VERSION + 1, true, 1L, Map.of());
newer.recordValue(alex, "Alex", metricId, 42L);
require(!newer.writable() && newer.score(alex, metricId) == 0L,
"A newer save must never be rewritten blindly");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}