diff --git a/CHANGELOG.md b/CHANGELOG.md index 92de6f6..04e373a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve - Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46. - Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées. +## Non distribué + +Module modifié : **Sanctuary `0.0.0-alpha.123`**. Le pack public reste temporairement en `26.2.0-alpha.221` jusqu'à la publication. + +- ajoute un suivi d'avancement des quêtes, affichable ou masquable par une touche configurable (`J` par défaut) ; +- synchronise un snapshot borné des quêtes acceptées et des contrats réservés, sans les demandes que le joueur a lui-même créées, ni changer les données Sanctuary v11 ni le protocole réseau 22 ; +- propose une saisie assistée des IDs d'item et de mob dans l'éditeur de demande, avec icône et noms proches. + ## `26.2.0-alpha.221` Module modifié : **Ambiance `0.0.0-alpha.22`**. Les neuf autres modules conservent leur version issue de l'alpha.220. diff --git a/build.gradle b/build.gradle index 49778a6..2301090 100644 --- a/build.gradle +++ b/build.gradle @@ -3438,7 +3438,7 @@ tasks.register("verifyProgressivePauseMenuRelease") { "sanctuary/src/main/resources/assets/sanctuary/lang/${locale}.json"))] } if (rootProject.pack_version != "26.2.0-alpha.221" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || rootProject.onlyfun_version != "0.0.0-alpha.25" || 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"] @@ -4279,7 +4279,7 @@ tasks.register("verifyShopPricingLayoutRelease") { def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json")) if (rootProject.pack_version != "26.2.0-alpha.221" || rootProject.onlyfun_version != "0.0.0-alpha.25" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || 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" @@ -4373,7 +4373,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.221" || rootProject.onlyfun_version != "0.0.0-alpha.25" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || 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"] @@ -4465,7 +4465,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.221" || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || 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"] @@ -4565,7 +4565,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.221" || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || 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" @@ -4685,7 +4685,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.221" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || 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" @@ -4742,7 +4742,7 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") { || rootProject.anotherworld_version != "0.0.0-alpha.55" || rootProject.itsalive_version != "0.0.0-alpha.36" || rootProject.iliketomoveit_version != "0.0.0-alpha.16" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || migration.source?.pack_version != "26.2.0-alpha.201" || migration.source?.modules != [ itsalive: "0.0.0-alpha.30", @@ -4900,7 +4900,7 @@ tasks.register("verifySafeVaultFeature") { }.keySet() if (rootProject.pack_version != "26.2.0-alpha.221" || rootProject.anotherworld_version != "0.0.0-alpha.55" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || migration.schema_version != 1 || migration.source?.pack_version != "26.2.0-alpha.201" || migration.target?.pack_version != "26.2.0-alpha.202" @@ -5759,7 +5759,7 @@ tasks.register("verifyAlpha210Release") { it.path == "mods/jei-26.2-fabric-30.28.0.193.jar" } if (rootProject.pack_version != "26.2.0-alpha.221" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || rootProject.onlyfun_version != "0.0.0-alpha.25" || rootProject.iliketomoveit_version != currentModules.iliketomoveit || rootProject.itsalive_version != "0.0.0-alpha.36" @@ -5858,7 +5858,7 @@ tasks.register("verifyAlpha211Release") { onlyfun: "0.0.0-alpha.25", ambiance: "0.0.0-alpha.22", iliketomoveit: "0.0.0-alpha.16"] - def workspaceModules = currentModules + def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.123"] def expectedDataVersions = [ambiance: 2, itsalive: 3, onlyfun: 2, ouch: 1, sanctuary: 11] def expectedProtocols = [ambiance: 6, itsalive: 4, onlyfun: 3, ouch: 1, sanctuary: 22] def manifests = expectedTarget.collectEntries { id, version -> @@ -5956,7 +5956,7 @@ tasks.register("verifyAlpha212Release") { it.path == "mods/jei-26.2-fabric-30.28.0.193.jar" } if (rootProject.pack_version != "26.2.0-alpha.221" - || rootProject.sanctuary_version != "0.0.0-alpha.122" + || rootProject.sanctuary_version != "0.0.0-alpha.123" || 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" @@ -6446,9 +6446,10 @@ tasks.register("verifyAlpha218Release") { } if (rootProject.pack_version != "26.2.0-alpha.221" || rootProject.redstoner_version != "0.0.0-alpha.13" - || currentModules.findAll { id, version -> id != "redstoner" }.any { + || currentModules.findAll { id, version -> id != "redstoner" && id != "sanctuary" }.any { id, version -> rootProject.property("${id}_version") != version } + || rootProject.sanctuary_version != "0.0.0-alpha.123" || migration.source?.pack_version != "26.2.0-alpha.217" || migration.source?.modules != sourceModules || migration.target?.pack_version != "26.2.0-alpha.218" @@ -6546,7 +6547,10 @@ tasks.register("verifyAlpha219Release") { exclude "**/build/**", "**/.gradle/**", "**/.git/**" }.files if (rootProject.pack_version != "26.2.0-alpha.221" - || currentModules.any { id, version -> rootProject.property("${id}_version") != version } + || currentModules.findAll { id, version -> id != "sanctuary" }.any { + id, version -> rootProject.property("${id}_version") != version + } + || rootProject.sanctuary_version != "0.0.0-alpha.123" || migration.source?.pack_version != "26.2.0-alpha.218" || migration.source?.modules != sourceModules || migration.target?.pack_version != "26.2.0-alpha.219" diff --git a/gradle.properties b/gradle.properties index 8ff1633..361eb69 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,7 +28,7 @@ iliketomoveit_version=0.0.0-alpha.16 iliketomoveit_lifecycle=active onlyfun_version=0.0.0-alpha.25 onlyfun_lifecycle=active -sanctuary_version=0.0.0-alpha.122 +sanctuary_version=0.0.0-alpha.123 sanctuary_lifecycle=active pack_version=26.2.0-alpha.221 maven_group=fr.koka99cab.sanctuary26 diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index e39658d..9747572 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -68,6 +68,22 @@ tasks.register("questRequestModelSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestModelSmoke" } +tasks.register("questTrackerModelSmoke", JavaExec) { + group = "verification" + description = "Checks the bounded quest-tracker snapshot used by the HUD concept." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerModelSmoke" +} + +tasks.register("questIdSuggestionsSmoke", JavaExec) { + group = "verification" + description = "Checks accent-folded quest-panel item name ranking and inventory pins." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestIdSuggestionsSmoke" +} + tasks.register("shopHeaderLayoutSmoke", JavaExec) { group = "verification" description = "Checks that the daily Shop timer and unlocked-offer status cannot overlap." @@ -135,7 +151,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || !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") @@ -278,7 +294,9 @@ tasks.register("verifySanctuary") { inputs.files(fileTree("tools")) dependsOn tasks.named("progressionModelSmoke"), tasks.named("serverIdentityModelSmoke"), tasks.named("shopDeliveryTimingSmoke"), tasks.named("blackMarketModelSmoke"), - tasks.named("questBoardModelSmoke"), tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"), + tasks.named("questBoardModelSmoke"), tasks.named("questTrackerModelSmoke"), + tasks.named("questIdSuggestionsSmoke"), + tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"), tasks.named("capeJoinSyncSmoke"), tasks.named("verifyBlackMarket") @@ -2832,7 +2850,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || 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 @@ -2998,7 +3016,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || 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 @@ -3036,7 +3054,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || 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] @@ -3067,7 +3085,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || migration.target?.pack_version != "26.2.0-alpha.195" || migration.target?.modules?.sanctuary != "0.0.0-alpha.111" || migration.compatibility?.sanctuary_data_version != 11 @@ -3098,7 +3116,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || migration.target?.modules?.sanctuary != "0.0.0-alpha.112" || migration.mailbox?.id != "sanctuary:delivery_box" || migration.mailbox?.horizontal_facing != true @@ -3126,7 +3144,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || !transfer.contains("shouldAppendOverflow") || !transfer.contains("isVanillaStorageRange") || !transfer.contains("hotbarStart") @@ -3172,7 +3190,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || migration.target?.modules?.sanctuary != "0.0.0-alpha.113" || migration.quest_requests?.save_key != "quest_requests" || migration.quest_requests?.data_version != 1 @@ -3230,7 +3248,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || 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" @@ -3299,7 +3317,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || 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" @@ -3371,7 +3389,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.122" + if (project.version.toString() != "0.0.0-alpha.123" || migration.target?.modules?.sanctuary != "0.0.0-alpha.122" || migration.alpha_world?.dimension != "sanctuary:alpha" || migration.alpha_world?.legacy_dimension != "portals:alpha" @@ -3402,6 +3420,8 @@ tasks.named("check") { dependsOn tasks.named("verifyShopCurrencyRowsRelease") dependsOn tasks.named("celestialGraphSmoke") dependsOn tasks.named("questRequestModelSmoke") + dependsOn tasks.named("questTrackerModelSmoke") + dependsOn tasks.named("questIdSuggestionsSmoke") dependsOn tasks.named("alphaIndevGeneratorSmoke") dependsOn tasks.named("verifyAlphaIndev") } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestRequestEditorScreen.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestRequestEditorScreen.java index f41fa45..0a903b3 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestRequestEditorScreen.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestRequestEditorScreen.java @@ -1,14 +1,29 @@ package fr.koka99cab.sanctuary26.sanctuary.client; +import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView; import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestEditorPayload; import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestSubmitPayload; +import fr.koka99cab.sanctuary26.sanctuary.shop.QuestIdSuggestions; import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.lwjgl.glfw.GLFW; /** Complete bounded editor for mission target and promised reward contracts. */ public final class QuestRequestEditorScreen extends Screen { @@ -19,8 +34,12 @@ public final class QuestRequestEditorScreen extends Screen { QuestRequestService.REWARD_GIFT, QuestRequestService.REWARD_PAYMENT, QuestRequestService.REWARD_LOAN }; + private static final int SUGGESTION_ROW = 16; + private static final int SUGGESTION_MARGIN = 4; private final Screen parent; private final QuestRequestEditorPayload payload; + private final List itemCatalog = new ArrayList<>(); + private final List mobCatalog = new ArrayList<>(); private EditBox alias; private EditBox objective; private EditBox target; @@ -34,6 +53,10 @@ public final class QuestRequestEditorScreen extends Screen { private int actionIndex; private int rewardIndex; private int killTargetIndex; + private int selectedSuggestion; + private boolean applyingCompletion; + private boolean suggestionsOpen; + private List suggestions = List.of(); public QuestRequestEditorScreen(Screen parent, QuestRequestEditorPayload payload) { super(Component.translatable("screen.sanctuary.quest_request.title")); @@ -51,6 +74,7 @@ public final class QuestRequestEditorScreen extends Screen { @Override protected void init() { int center = width / 2; + fillCatalogs(); alias = addRenderableWidget(new EditBox(font, center - 152, 48, 304, 20, Component.translatable("screen.sanctuary.quest_request.alias"))); alias.setMaxLength(QuestRequestService.MAX_ALIAS_LENGTH); @@ -80,15 +104,17 @@ public final class QuestRequestEditorScreen extends Screen { quantity.setMaxLength(4); quantity.setValue(Integer.toString(payload.quantity())); - target = addRenderableWidget(new EditBox(font, center + 52, 118, 100, 20, + target = addRenderableWidget(new EditBox(font, center + 70, 118, 82, 20, Component.translatable("screen.sanctuary.quest_request.target"))); target.setMaxLength(QuestRequestService.MAX_TARGET_LENGTH); target.setValue(payload.target()); + target.setResponder(value -> onIdTyped()); reward = addRenderableWidget(Button.builder(rewardLabel(), button -> { rewardIndex = (rewardIndex + 1) % REWARDS.length; button.setMessage(rewardLabel()); rewardTarget.setHint(rewardHint()); + closeSuggestions(); }).bounds(center - 152, 157, 84, 20).build()); rewardQuantity = addRenderableWidget(new EditBox(font, center - 64, 157, 46, 20, @@ -96,11 +122,12 @@ public final class QuestRequestEditorScreen extends Screen { rewardQuantity.setMaxLength(4); rewardQuantity.setValue(Integer.toString(payload.rewardQuantity())); - rewardTarget = addRenderableWidget(new EditBox(font, center - 14, 157, 166, 20, + rewardTarget = addRenderableWidget(new EditBox(font, center + 8, 157, 144, 20, Component.translatable("screen.sanctuary.quest_request.reward_target"))); rewardTarget.setMaxLength(QuestRequestService.MAX_TARGET_LENGTH); rewardTarget.setValue(payload.rewardTarget()); rewardTarget.setHint(rewardHint()); + rewardTarget.setResponder(value -> onIdTyped()); submit = addRenderableWidget(Button.builder(Component.translatable("screen.sanctuary.quest_request.publish"), button -> submit()).bounds(center - 152, 199, 149, 20).build()); @@ -112,9 +139,16 @@ public final class QuestRequestEditorScreen extends Screen { @Override public void tick() { + if (suggestionsOpen) refreshSuggestions(); submit.active = values() != null; } + @Override + public void setFocused(GuiEventListener listener) { + super.setFocused(listener); + if (listener != target && listener != rewardTarget) closeSuggestions(); + } + @Override public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) { int center = width / 2; @@ -129,9 +163,53 @@ public final class QuestRequestEditorScreen extends Screen { center - 152, 107, 0xFFDBB7CA); graphics.text(font, Component.translatable("screen.sanctuary.quest_request.reward"), center - 152, 146, 0xFFF2C4D7); - graphics.centeredText(font, Component.translatable("screen.sanctuary.quest_request.help"), - center, 183, 0xFF9F8B95); + graphics.centeredText(font, Component.translatable(idFieldFocused() + ? "screen.sanctuary.quest_request.id_help" + : "screen.sanctuary.quest_request.help"), center, 183, 0xFF9F8B95); + drawPreview(graphics, target, targetPreview(mouseX, mouseY), center + 52, 120); + drawPreview(graphics, rewardTarget, rewardPreview(mouseX, mouseY), center - 14, 159); super.extractRenderState(graphics, mouseX, mouseY, tickDelta); + renderSuggestions(graphics, mouseX, mouseY); + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (!suggestions.isEmpty()) { + if (event.key() == GLFW.GLFW_KEY_DOWN) { + selectedSuggestion = (selectedSuggestion + 1) % suggestions.size(); + return true; + } + if (event.key() == GLFW.GLFW_KEY_UP) { + selectedSuggestion = (selectedSuggestion + suggestions.size() - 1) % suggestions.size(); + return true; + } + } + return super.keyPressed(event); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + int index = suggestionIndexAt(event.x(), event.y()); + if (index >= 0) { + applySuggestion(index); + return true; + } + boolean handled = super.mouseClicked(event, doubleClick); + if (clickedSearchField(event.x(), event.y())) { + suggestionsOpen = true; + refreshSuggestions(); + } else closeSuggestions(); + return handled; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (!suggestions.isEmpty() && suggestionPanel().contains(mouseX, mouseY)) { + int delta = scrollY > 0 ? -1 : 1; + selectedSuggestion = Math.floorMod(selectedSuggestion + delta, suggestions.size()); + return true; + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); } private void refreshMissionControls() { @@ -139,6 +217,7 @@ public final class QuestRequestEditorScreen extends Screen { targetKind.setMessage(targetKindLabel()); targetKind.active = QuestRequestService.KILL.equals(currentAction()); target.setHint(targetHint()); + closeSuggestions(); } private String currentAction() { return ACTIONS[actionIndex]; } @@ -172,14 +251,16 @@ public final class QuestRequestEditorScreen extends Screen { } private Values values() { - if (objective.getValue().isBlank() || target.getValue().isBlank() - || rewardTarget.getValue().isBlank()) return null; + if (objective.getValue().isBlank()) return null; + String resolvedTarget = resolveTarget(); + String resolvedReward = QuestIdSuggestions.resolve(itemCatalog, rewardTarget.getValue(), inventoryIds()); + if (resolvedTarget == null || resolvedReward == null) return null; try { int amount = Integer.parseInt(quantity.getValue()); int rewardAmount = Integer.parseInt(rewardQuantity.getValue()); if (amount < 1 || amount > QuestRequestService.MAX_QUANTITY || rewardAmount < 1 || rewardAmount > QuestRequestService.MAX_QUANTITY) return null; - return new Values(amount, rewardAmount); + return new Values(resolvedTarget, amount, resolvedReward, rewardAmount); } catch (NumberFormatException exception) { return null; } @@ -189,13 +270,232 @@ public final class QuestRequestEditorScreen extends Screen { Values values = values(); if (values == null || !ClientPlayNetworking.canSend(QuestRequestSubmitPayload.ID)) return; ClientPlayNetworking.send(new QuestRequestSubmitPayload(payload.pos(), alias.getValue(), - objective.getValue(), currentAction(), currentTargetKind(), target.getValue(), values.quantity(), - currentReward(), rewardTarget.getValue(), values.rewardQuantity())); + objective.getValue(), currentAction(), currentTargetKind(), values.target(), values.quantity(), + currentReward(), values.rewardTarget(), values.rewardQuantity())); onClose(); } + private void fillCatalogs() { + itemCatalog.clear(); + for (Item item : BuiltInRegistries.ITEM) { + if (item == Items.AIR) continue; + Identifier id = BuiltInRegistries.ITEM.getKey(item); + itemCatalog.add(new QuestIdSuggestions.Entry(id.toString(), id.getPath(), + new ItemStack(item).getHoverName().getString())); + } + mobCatalog.clear(); + for (var type : BuiltInRegistries.ENTITY_TYPE) { + Identifier id = BuiltInRegistries.ENTITY_TYPE.getKey(type); + if ("minecraft:player".equals(id.toString())) continue; + mobCatalog.add(new QuestIdSuggestions.Entry(id.toString(), id.getPath(), + type.getDescription().getString())); + } + } + + private void onIdTyped() { + if (applyingCompletion) return; + suggestionsOpen = !focusedSearchValue().isBlank(); + refreshSuggestions(); + } + + private void closeSuggestions() { + suggestionsOpen = false; + suggestions = List.of(); + selectedSuggestion = 0; + } + + private void refreshSuggestions() { + if (applyingCompletion || !suggestionsOpen) { + if (!suggestionsOpen) suggestions = List.of(); + return; + } + EditBox field = focusedIdField(); + if (field == null || field.getValue().isBlank()) { + closeSuggestions(); + return; + } + boolean itemField = field == rewardTarget || QuestRequestService.ITEM.equals(currentTargetKind()); + List next = QuestIdSuggestions.suggest( + itemField ? itemCatalog : mobCatalog, field.getValue(), + itemField ? inventoryIds() : Set.of(), QuestIdSuggestions.LIMIT); + if (!next.equals(suggestions)) { + suggestions = next; + selectedSuggestion = 0; + } else if (selectedSuggestion >= suggestions.size()) { + selectedSuggestion = 0; + } + } + + private void applySuggestion(int index) { + EditBox field = focusedIdField(); + if (field == null || index < 0 || index >= suggestions.size()) return; + writeId(field, suggestions.get(index).id()); + closeSuggestions(); + } + + private boolean clickedSearchField(double mouseX, double mouseY) { + if (contains(rewardTarget, mouseX, mouseY)) return true; + return contains(target, mouseX, mouseY) + && !QuestRequestService.PLAYER.equals(currentTargetKind()); + } + + private boolean contains(EditBox field, double mouseX, double mouseY) { + return field != null && mouseX >= field.getX() && mouseX < field.getX() + field.getWidth() + && mouseY >= field.getY() && mouseY < field.getY() + field.getHeight(); + } + + private String focusedSearchValue() { + EditBox field = focusedIdField(); + return field == null ? "" : field.getValue(); + } + + private void writeId(EditBox field, String id) { + applyingCompletion = true; + field.setValue(id); + field.moveCursorToEnd(false); + applyingCompletion = false; + } + + private String resolveTarget() { + if (QuestRequestService.PLAYER.equals(currentTargetKind())) { + String name = target.getValue().strip(); + return name.matches("[A-Za-z0-9_]{1,16}") ? name : null; + } + boolean item = QuestRequestService.ITEM.equals(currentTargetKind()); + return QuestIdSuggestions.resolve(item ? itemCatalog : mobCatalog, target.getValue(), + item ? inventoryIds() : Set.of()); + } + + private QuestIdSuggestions.Match targetPreview(int mouseX, int mouseY) { + if (QuestRequestService.PLAYER.equals(currentTargetKind())) return null; + return previewFor(target, mouseX, mouseY); + } + + private QuestIdSuggestions.Match rewardPreview(int mouseX, int mouseY) { + return previewFor(rewardTarget, mouseX, mouseY); + } + + private QuestIdSuggestions.Match previewFor(EditBox field, int mouseX, int mouseY) { + if (field == null) return null; + if (field.isFocused() && !suggestions.isEmpty()) { + int hover = suggestionIndexAt(mouseX, mouseY); + return suggestions.get(hover >= 0 ? hover : selectedSuggestion); + } + boolean item = field == rewardTarget || QuestRequestService.ITEM.equals(currentTargetKind()); + return QuestIdSuggestions.exact(item ? itemCatalog : mobCatalog, field.getValue(), + item ? inventoryIds() : Set.of()); + } + + private void drawPreview(GuiGraphicsExtractor graphics, EditBox field, + QuestIdSuggestions.Match match, int x, int y) { + if (field == null || match == null) return; + graphics.fill(x, y, x + 16, y + 16, 0x66101010); + graphics.outline(x, y, 16, 16, 0x88E8A8C3); + ItemStack stack = iconFor(match.id(), field == rewardTarget + || QuestRequestService.ITEM.equals(currentTargetKind())); + if (!stack.isEmpty()) graphics.item(stack, x, y); + } + + private void renderSuggestions(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + if (suggestions.isEmpty()) return; + Panel panel = suggestionPanel(); + graphics.fill(panel.x(), panel.y(), panel.x() + panel.width(), panel.y() + panel.height(), 0xF2181417); + graphics.outline(panel.x(), panel.y(), panel.width(), panel.height(), 0xFFE8A8C3); + boolean itemField = focusedIdField() == rewardTarget + || QuestRequestService.ITEM.equals(currentTargetKind()); + int hover = suggestionIndexAt(mouseX, mouseY); + for (int index = 0; index < suggestions.size(); index++) { + QuestIdSuggestions.Match match = suggestions.get(index); + int top = panel.y() + 1 + index * SUGGESTION_ROW; + if (index == selectedSuggestion || index == hover) { + graphics.fill(panel.x() + 1, top, panel.x() + panel.width() - 1, top + SUGGESTION_ROW, 0x55E8A8C3); + } + ItemStack stack = iconFor(match.id(), itemField); + if (!stack.isEmpty()) graphics.item(stack, panel.x() + 2, top); + graphics.text(font, Component.literal(ellipsize(match.displayName(), panel.width() - 24)), + panel.x() + 20, top + 4, 0xFFF2C4D7); + } + } + + private ItemStack iconFor(String id, boolean item) { + Identifier identifier = Identifier.tryParse(id); + if (identifier == null) return ItemStack.EMPTY; + if (item) { + return BuiltInRegistries.ITEM.getOptional(identifier).map(ItemStack::new).orElse(ItemStack.EMPTY); + } + Identifier egg = Identifier.fromNamespaceAndPath(identifier.getNamespace(), + identifier.getPath() + "_spawn_egg"); + return BuiltInRegistries.ITEM.getOptional(egg).map(ItemStack::new).orElse(ItemStack.EMPTY); + } + + private Set inventoryIds() { + Set ids = new LinkedHashSet<>(); + if (minecraft == null || minecraft.player == null) return ids; + ItemStack held = minecraft.player.getMainHandItem(); + if (!held.isEmpty()) ids.add(BuiltInRegistries.ITEM.getKey(held.getItem()).toString()); + for (var slot : SanctuaryPlayerInventoryView.unlockedRows(minecraft.player)) { + if (!slot.stack().isEmpty()) { + ids.add(BuiltInRegistries.ITEM.getKey(slot.stack().getItem()).toString()); + } + } + return ids; + } + + private EditBox focusedIdField() { + if (target != null && target.isFocused() && !QuestRequestService.PLAYER.equals(currentTargetKind())) { + return target; + } + if (rewardTarget != null && rewardTarget.isFocused()) return rewardTarget; + return null; + } + + private boolean idFieldFocused() { + return focusedIdField() != null; + } + + private Panel suggestionPanel() { + EditBox field = focusedIdField(); + if (field == null) return new Panel(0, 0, 0, 0); + int rows = Math.max(1, suggestions.size()); + int height = rows * SUGGESTION_ROW + 2; + int width = 200; + int x = Math.max(SUGGESTION_MARGIN, Math.min(this.width / 2 - width / 2, + this.width - width - SUGGESTION_MARGIN)); + int below = field.getY() + field.getHeight() + 2; + int above = field.getY() - 2 - height; + boolean preferAbove = field == rewardTarget; + int y = preferAbove && above >= SUGGESTION_MARGIN ? above : below; + if (y + height > this.height - SUGGESTION_MARGIN) y = above; + y = Math.max(SUGGESTION_MARGIN, Math.min(y, this.height - height - SUGGESTION_MARGIN)); + return new Panel(x, y, width, height); + } + + private int suggestionIndexAt(double mouseX, double mouseY) { + if (suggestions.isEmpty()) return -1; + Panel panel = suggestionPanel(); + if (!panel.contains(mouseX, mouseY)) return -1; + int index = (int) ((mouseY - panel.y() - 1) / SUGGESTION_ROW); + return index >= 0 && index < suggestions.size() ? index : -1; + } + + private String ellipsize(String value, int maxWidth) { + if (font.width(value) <= maxWidth) return value; + String suffix = "…"; + int suffixWidth = font.width(suffix); + StringBuilder cut = new StringBuilder(value); + while (cut.length() > 0 && font.width(cut.toString()) + suffixWidth > maxWidth) { + cut.deleteCharAt(cut.length() - 1); + } + return cut + suffix; + } + @Override public void onClose() { if (minecraft != null) minecraft.gui.setScreen(parent); } @Override public boolean isPauseScreen() { return false; } - private record Values(int quantity, int rewardQuantity) {} + private record Values(String target, int quantity, String rewardTarget, int rewardQuantity) {} + private record Panel(int x, int y, int width, int height) { + boolean contains(double mouseX, double mouseY) { + return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height; + } + } } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestTrackerHud.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestTrackerHud.java new file mode 100644 index 0000000..80d42c5 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/QuestTrackerHud.java @@ -0,0 +1,105 @@ +package fr.koka99cab.sanctuary26.sanctuary.client; + +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload; +import fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerSync; +import java.util.List; +import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; + +/** Compact toggleable overlay for accepted quests. */ +final class QuestTrackerHud { + private static final int PANEL_WIDTH = 168; + private static final int MARGIN = 6; + private static final int HEADER_HEIGHT = 14; + private static final int ENTRY_HEIGHT = 22; + private static final int EMPTY_HEIGHT = 22; + private static final int BACKGROUND = 0xD0181417; + private static final int OUTLINE = 0xFFE8A8C3; + private static final int TITLE = 0xFFF2C4D7; + private static final int BODY = 0xFFE9DDE2; + private static final int MUTED = 0xFF9F8B95; + private static final int BAR_BACK = 0xFF3A2A30; + + private QuestTrackerHud() {} + + static void register() { + HudElementRegistry.addLast(SanctuaryMod.id("quest_tracker"), QuestTrackerHud::render); + } + + private static void render(GuiGraphicsExtractor graphics, DeltaTracker tickCounter) { + Minecraft client = Minecraft.getInstance(); + if (client.player == null || client.level == null || client.gui.screen() != null) return; + if (!SanctuaryClientQuests.visible()) return; + List entries = SanctuaryClientQuests.entries(); + int height = HEADER_HEIGHT + 6 + (entries.isEmpty() ? EMPTY_HEIGHT : entries.size() * ENTRY_HEIGHT); + int left = MARGIN; + int top = MARGIN; + int right = left + PANEL_WIDTH; + int bottom = top + height; + graphics.fill(left, top, right, bottom, BACKGROUND); + graphics.outline(left, top, PANEL_WIDTH, height, OUTLINE); + graphics.text(client.font, Component.translatable("hud.sanctuary.quest_tracker.title"), + left + 6, top + 4, TITLE); + if (entries.isEmpty()) { + graphics.text(client.font, Component.translatable("hud.sanctuary.quest_tracker.empty"), + left + 6, top + HEADER_HEIGHT + 4, MUTED); + return; + } + int y = top + HEADER_HEIGHT + 2; + for (QuestTrackerSnapshotPayload.Entry entry : entries) { + drawEntry(graphics, client.font, entry, left + 6, y, PANEL_WIDTH - 12); + y += ENTRY_HEIGHT; + } + } + + private static void drawEntry(GuiGraphicsExtractor graphics, Font font, + QuestTrackerSnapshotPayload.Entry entry, int x, int y, int width) { + int accent = 0xFF000000 | (entry.accent() & 0xFFFFFF); + graphics.text(font, ellipsize(font, title(entry), width), x, y, accent); + Component progress = Component.translatable("hud.sanctuary.quest_tracker.progress", + action(entry), describeTarget(entry), entry.progress(), entry.required()); + graphics.text(font, ellipsize(font, progress, width), x, y + 9, BODY); + int barTop = y + 18; + int barRight = x + width; + graphics.fill(x, barTop, barRight, barTop + 2, BAR_BACK); + int filled = Math.round(width * Math.min(1.0F, entry.progress() / (float) entry.required())); + if (filled > 0) graphics.fill(x, barTop, x + filled, barTop + 2, accent); + } + + private static Component title(QuestTrackerSnapshotPayload.Entry entry) { + if (!entry.title().isBlank()) return Component.literal(entry.title()); + return Component.translatable("hud.sanctuary.quest_tracker.source." + entry.source()); + } + + private static Component action(QuestTrackerSnapshotPayload.Entry entry) { + if (QuestTrackerSync.SOURCE_BOARD.equals(entry.source())) { + return Component.translatable("sign.sanctuary.quest_board.action." + entry.action()); + } + return Component.translatable("message.sanctuary.quest_request.action." + entry.action()); + } + + private static Component describeTarget(QuestTrackerSnapshotPayload.Entry entry) { + Identifier id = Identifier.tryParse(entry.target()); + if (id == null) return Component.literal(entry.target()); + return BuiltInRegistries.ENTITY_TYPE.getOptional(id).map(type -> type.getDescription()) + .or(() -> BuiltInRegistries.ITEM.getOptional(id).map(item -> new ItemStack(item).getHoverName())) + .orElse(Component.literal(id.getPath())); + } + + private static Component ellipsize(Font font, Component text, int maxWidth) { + if (font.width(text) <= maxWidth) return text; + String value = text.getString(); + while (!value.isEmpty() && font.width(value + "…") > maxWidth) { + value = value.substring(0, value.length() - 1); + } + return Component.literal(value + "…"); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java index 04b25f4..22a9189 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java @@ -14,6 +14,7 @@ import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketEditorPayload; import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketInteractPayload; import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestEditorPayload; import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestConfirmPayload; +import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload; import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionCosts; import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryUnlock; import net.fabricmc.api.ClientModInitializer; @@ -48,6 +49,7 @@ public final class SanctuaryClient implements ClientModInitializer { private static KeyMapping zoomKey; private static KeyMapping celestialKey; private static KeyMapping switchHotbarRowKey; + private static KeyMapping questTrackerKey; private static boolean zoomWasHeld; private static boolean smokeScreenOpened; private static boolean inventorySmokeScreenOpened; @@ -91,7 +93,14 @@ public final class SanctuaryClient implements ClientModInitializer { GLFW.GLFW_KEY_TAB, SANCTUARY_CATEGORY )); + questTrackerKey = KeyMappingHelper.registerKeyMapping(new KeyMapping( + "key.sanctuary.quest_tracker", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_J, + SANCTUARY_CATEGORY + )); SanctuaryAtlasOptions.load(); + SanctuaryClientQuests.load(); SanctuaryBulkActionsClient.initialize(); SanctuaryHealthPreview.register(); CelestialSpyglassSelection.register(); @@ -124,6 +133,8 @@ public final class SanctuaryClient implements ClientModInitializer { ClientPlayNetworking.registerGlobalReceiver(QuestRequestConfirmPayload.ID, (payload, context) -> context.client().execute(() -> context.client().gui.setScreen( new QuestRequestConfirmScreen(context.client().gui.screen(), payload)))); + ClientPlayNetworking.registerGlobalReceiver(QuestTrackerSnapshotPayload.ID, (payload, context) -> + context.client().execute(() -> SanctuaryClientQuests.update(payload))); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { SanctuaryAtlasVisitedChunks.saveIfDirty(client); SanctuaryAtlasMapCache.clear(); @@ -135,6 +146,7 @@ public final class SanctuaryClient implements ClientModInitializer { CelestialSpyglassSelection.reset(); SanctuaryClientShop.clear(); SanctuaryClientCapes.clear(); + SanctuaryClientQuests.clear(); zoomWasHeld = false; }); ClientHotbarScrollEvents.ALLOW.register((inventory, selectedSlot, scrollDirection, horizontalAmount, verticalAmount) -> { @@ -142,6 +154,7 @@ public final class SanctuaryClient implements ClientModInitializer { return !SanctuaryAtlasHud.handleScroll(amount); }); SanctuaryAtlasHud.register(); + QuestTrackerHud.register(); SanctuaryMod.LOGGER.info("[{}] Atlas ready on M; Mouse 5 controls the HUD preview.", SanctuaryMod.DISPLAY_NAME); } @@ -181,6 +194,10 @@ public final class SanctuaryClient implements ClientModInitializer { } } while (openAtlasKey.consumeClick()) toggleAtlas(client); + while (questTrackerKey.consumeClick()) { + if (client.player != null && client.level != null && client.gui.screen() == null) + toggleQuestTracker(client); + } while (celestialKey.consumeClick()) { if (client.player != null && client.level != null && client.gui.screen() == null) client.gui.setScreen(new CelestialSkyScreen(null)); @@ -275,6 +292,14 @@ public final class SanctuaryClient implements ClientModInitializer { && client.level != null && client.gui.screen() == null; } + private static void toggleQuestTracker(Minecraft client) { + boolean visible = SanctuaryClientQuests.toggle(); + if (client.player == null) return; + client.player.sendOverlayMessage(Component.translatable(visible + ? "hud.sanctuary.quest_tracker.toggle_on" + : "hud.sanctuary.quest_tracker.toggle_off")); + } + private static void toggleAtlas(Minecraft client) { if (client.gui.screen() instanceof SanctuaryAtlasScreen) { client.gui.setScreen(null); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientQuests.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientQuests.java new file mode 100644 index 0000000..d7dc27b --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientQuests.java @@ -0,0 +1,79 @@ +package fr.koka99cab.sanctuary26.sanctuary.client; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.fabricmc.loader.api.FabricLoader; + +/** Client-side quest tracker snapshot and visibility preference. */ +final class SanctuaryClientQuests { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path FILE = FabricLoader.getInstance().getConfigDir() + .resolve("sanctuary-quest-tracker.json"); + private static Settings settings = new Settings(); + private static boolean loaded; + private static List entries = List.of(); + + private SanctuaryClientQuests() {} + + static void load() { + if (loaded) return; + loaded = true; + if (!Files.isRegularFile(FILE)) return; + try (Reader reader = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) { + Settings decoded = GSON.fromJson(reader, Settings.class); + if (decoded != null) settings = decoded; + } catch (IOException | RuntimeException exception) { + SanctuaryMod.LOGGER.warn("[{}] Could not read quest tracker options.", + SanctuaryMod.DISPLAY_NAME, exception); + } + } + + static void update(QuestTrackerSnapshotPayload payload) { + entries = payload.entries(); + } + + static void clear() { + entries = List.of(); + } + + static List entries() { + return entries; + } + + static boolean visible() { + load(); + return settings.visible; + } + + static boolean toggle() { + load(); + settings.visible = !settings.visible; + save(); + return settings.visible; + } + + private static void save() { + try { + Files.createDirectories(FILE.getParent()); + try (Writer writer = Files.newBufferedWriter(FILE, StandardCharsets.UTF_8)) { + GSON.toJson(settings, writer); + } + } catch (IOException exception) { + SanctuaryMod.LOGGER.warn("[{}] Could not save quest tracker options.", + SanctuaryMod.DISPLAY_NAME, exception); + } + } + + private static final class Settings { + boolean visible = true; + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/QuestTrackerSnapshotPayload.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/QuestTrackerSnapshotPayload.java new file mode 100644 index 0000000..1a989b8 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/QuestTrackerSnapshotPayload.java @@ -0,0 +1,70 @@ +package fr.koka99cab.sanctuary26.sanctuary.network; + +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** Bounded snapshot of the player's tracked community requests and reserved boards. */ +public record QuestTrackerSnapshotPayload(List entries) implements CustomPacketPayload { + public static final int MAX_ENTRIES = 8; + public static final Type ID = + new Type<>(SanctuaryMod.id("s2c/quest_tracker")); + public static final StreamCodec CODEC = + CustomPacketPayload.codec(QuestTrackerSnapshotPayload::write, QuestTrackerSnapshotPayload::new); + + public QuestTrackerSnapshotPayload { + entries = List.copyOf(entries == null ? List.of() + : entries.stream().limit(MAX_ENTRIES).toList()); + } + + private QuestTrackerSnapshotPayload(RegistryFriendlyByteBuf buffer) { + this(readEntries(buffer)); + } + + private void write(RegistryFriendlyByteBuf buffer) { + buffer.writeVarInt(Math.min(MAX_ENTRIES, entries.size())); + for (int index = 0; index < Math.min(MAX_ENTRIES, entries.size()); index++) { + Entry entry = entries.get(index); + buffer.writeUtf(entry.source(), 8); + buffer.writeUtf(entry.id(), 36); + buffer.writeUtf(entry.title(), 96); + buffer.writeUtf(entry.action(), 16); + buffer.writeUtf(entry.target(), 64); + buffer.writeVarInt(entry.progress()); + buffer.writeVarInt(entry.required()); + buffer.writeInt(entry.accent()); + } + } + + private static List readEntries(RegistryFriendlyByteBuf buffer) { + int count = Math.clamp(buffer.readVarInt(), 0, MAX_ENTRIES); + List values = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + values.add(new Entry(buffer.readUtf(8), buffer.readUtf(36), buffer.readUtf(96), + buffer.readUtf(16), buffer.readUtf(64), Math.max(0, buffer.readVarInt()), + Math.max(1, buffer.readVarInt()), buffer.readInt())); + } + return values; + } + + @Override + public Type type() { + return ID; + } + + public record Entry(String source, String id, String title, String action, String target, + int progress, int required, int accent) { + public Entry { + source = source == null ? "" : source; + id = id == null ? "" : id; + title = title == null ? "" : title; + action = action == null ? "" : action; + target = target == null ? "" : target; + progress = Math.max(0, progress); + required = Math.max(1, required); + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java index 7e665fb..1cffe80 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java @@ -12,6 +12,7 @@ import fr.koka99cab.sanctuary26.sanctuary.celestial.CelestialSkyService; import fr.koka99cab.sanctuary26.sanctuary.shop.ShopService; import fr.koka99cab.sanctuary26.sanctuary.shop.BlackMarketService; import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService; +import fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerSync; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCapeService; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; @@ -42,6 +43,7 @@ public final class SanctuaryNetworking { PayloadTypeRegistry.clientboundPlay().register(BlackMarketEditorPayload.ID, BlackMarketEditorPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(QuestRequestEditorPayload.ID, QuestRequestEditorPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(QuestRequestConfirmPayload.ID, QuestRequestConfirmPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(QuestTrackerSnapshotPayload.ID, QuestTrackerSnapshotPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(PurchaseProgressionPayload.ID, PurchaseProgressionPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(BuildControlPayload.ID, BuildControlPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(MiningControlPayload.ID, MiningControlPayload.CODEC); @@ -95,6 +97,7 @@ public final class SanctuaryNetworking { SanctuaryMealHeartsService.join(handler.player); CelestialSkyService.join(handler.player); SanctuaryCapeService.join(handler.player); + QuestTrackerSync.sync(handler.player); }); SanctuaryMod.LOGGER.debug("[{}] Networking contract v{} ready; Atlas snapshot registered.", SanctuaryMod.DISPLAY_NAME, PROTOCOL_VERSION); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java index 0d30899..1eb5495 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java @@ -167,6 +167,7 @@ public final class QuestBoardService { data.audit(new QuestBoardData.AuditEntry(now, "reserve", player.getUUID().toString(), board.id(), contract.id(), deposit.currency + ":" + deposit.amount)); writeSign(level, sign, updated); + QuestTrackerSync.sync(player); level.playSound(null, sign.getBlockPos(), SoundEvents.VILLAGER_YES, SoundSource.BLOCKS, 0.7F, 1.1F); player.sendSystemMessage(Component.translatable("message.sanctuary.quest_board.reserved", contract.objective().required(), describeTarget(contract.objective()), minutes(board.questTier().reservationMillis()))); @@ -213,6 +214,7 @@ public final class QuestBoardService { QuestBoardData.Board updated = board.withContract(Optional.of(advanced)); data.replace(updated); refreshSign(level.getServer(), updated); + QuestTrackerSync.sync(player); if (advanced.objective().complete()) complete(player, level.getServer(), data, updated, advanced); } } @@ -234,6 +236,7 @@ public final class QuestBoardService { data.audit(new QuestBoardData.AuditEntry(now, "complete", player.getUUID().toString(), board.id(), contract.id(), rewardDetail(contract.reward()))); refreshSign(server, completedBoard); + QuestTrackerSync.sync(player); ServerLevel boardLevel = level(server, board.dimension()); BlockPos boardPos = BlockPos.of(board.pos()); if (boardLevel != null && boardLevel.isLoaded(boardPos)) @@ -252,6 +255,7 @@ public final class QuestBoardService { data.audit(new QuestBoardData.AuditEntry(System.currentTimeMillis(), "release", player.getUUID().toString(), board.id(), contract.id(), reason)); writeSign(level, sign, released); + QuestTrackerSync.sync(player); message(player, "abandoned"); } @@ -276,7 +280,9 @@ public final class QuestBoardService { data.replace(released); data.audit(new QuestBoardData.AuditEntry(now, "expire", claim.player(), board.id(), contract.id(), claim.depositCurrency() + ":" + claim.depositAmount())); - refreshSign(server, released); continue; + refreshSign(server, released); + QuestTrackerSync.sync(server, claim.playerId()); + continue; } if (!board.frozen() && now >= board.nextRefreshAt() && (contract == null || contract.claim().isEmpty())) { @@ -303,6 +309,7 @@ public final class QuestBoardService { }); data.audit(new QuestBoardData.AuditEntry(now, "remove_board", actor, board.id(), board.contract().map(QuestBoardData.Contract::id).orElse(""), reason)); + QuestTrackerSync.syncBoard(server, board); } static boolean setFrozen(MinecraftServer server, String id, boolean frozen, String actor) { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestIdSuggestions.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestIdSuggestions.java new file mode 100644 index 0000000..77acbb7 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestIdSuggestions.java @@ -0,0 +1,84 @@ +package fr.koka99cab.sanctuary26.sanctuary.shop; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** Client-side ranking for quest-panel item and mob identifiers. */ +public final class QuestIdSuggestions { + public static final int LIMIT = 5; + + public record Entry(String id, String path, String displayName) { + public Entry(String id, String displayName) { + this(id, pathOf(id), displayName); + } + } + + public record Match(String id, String displayName, boolean preferred, int rank) {} + + private QuestIdSuggestions() {} + + public static List suggest(List catalog, String query, Collection preferred, + int limit) { + String needle = normalize(query); + if (needle.isEmpty()) return List.of(); + Set pinned = preferred == null ? Set.of() : Set.copyOf(preferred); + List matches = new ArrayList<>(); + for (Entry entry : catalog) { + Integer rank = rank(entry, needle); + if (rank == null) continue; + boolean pin = pinned.contains(entry.id()); + matches.add(new Match(entry.id(), entry.displayName(), pin, pin ? rank - 10 : rank)); + } + matches.sort(Comparator.comparingInt(Match::rank) + .thenComparing(Match::displayName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(Match::id)); + return matches.size() <= limit ? List.copyOf(matches) : List.copyOf(matches.subList(0, limit)); + } + + public static String resolve(List catalog, String query, Collection preferred) { + Match match = exact(catalog, query, preferred); + return match == null ? null : match.id(); + } + + public static Match exact(List catalog, String query, Collection preferred) { + String raw = query == null ? "" : query.strip(); + if (raw.isEmpty()) return null; + String folded = normalize(raw); + for (Match match : suggest(catalog, raw, preferred, LIMIT)) { + if (normalize(match.id()).equals(folded) || normalize(pathOf(match.id())).equals(folded) + || normalize(match.displayName()).equals(folded)) { + return match; + } + } + return null; + } + + public static String normalize(String value) { + if (value == null || value.isBlank()) return ""; + String folded = Normalizer.normalize(value.strip().toLowerCase(Locale.ROOT), Normalizer.Form.NFD) + .replaceAll("\\p{M}+", ""); + return folded.startsWith("minecraft:") ? folded.substring("minecraft:".length()) : folded; + } + + private static Integer rank(Entry entry, String needle) { + String id = normalize(entry.id()); + String path = normalize(entry.path()); + String name = normalize(entry.displayName()); + if (id.equals(needle) || path.equals(needle) || name.equals(needle)) return 0; + if (path.startsWith(needle) || name.startsWith(needle)) return 1; + if (id.startsWith(needle)) return 2; + if (name.contains(needle) || path.contains(needle)) return 3; + if (id.contains(needle)) return 4; + return null; + } + + private static String pathOf(String id) { + int colon = id.indexOf(':'); + return colon < 0 ? id : id.substring(colon + 1); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestRequestService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestRequestService.java index e86b504..2fbf388 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestRequestService.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestRequestService.java @@ -118,6 +118,7 @@ public final class QuestRequestService { return; } writeSign(level, sign, draft); + QuestTrackerSync.sync(player); openEditor(player, draft); } @@ -165,6 +166,8 @@ public final class QuestRequestService { QuestRequestData.Request released = request.withClaim(Optional.empty()); if (data.replace(released)) { writeSign(level, sign, released); + QuestTrackerSync.syncRequest(level.getServer(), released); + QuestTrackerSync.sync(player); message(player, "abandoned"); } } else if (BRING.equals(request.action())) { @@ -225,6 +228,7 @@ public final class QuestRequestService { return; } writeSign(level, sign, claimed); + QuestTrackerSync.syncRequest(level.getServer(), claimed); level.playSound(null, sign.getBlockPos(), SoundEvents.VILLAGER_YES, SoundSource.BLOCKS, 0.7F, 1.1F); player.sendSystemMessage(Component.translatable("message.sanctuary.quest_request.claimed", describeAction(claimed.action()), claimed.quantity(), describeTarget(claimed))); @@ -298,6 +302,7 @@ public final class QuestRequestService { queueStacks(level.getServer(), request.ownerId(), plan.refund(), request.id(), "quest_request_escrow_adjustment", player); writeSign(level, sign, configured); + QuestTrackerSync.sync(player); level.playSound(null, payload.pos(), SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.BLOCKS, 0.8F, 1.0F); message(player, "created"); @@ -336,6 +341,7 @@ public final class QuestRequestService { complete(killer, level.getServer(), data, advanced, List.of()); } else if (data.replace(advanced)) { refreshSign(level.getServer(), advanced); + QuestTrackerSync.syncRequest(level.getServer(), advanced); killer.sendSystemMessage(Component.translatable("message.sanctuary.quest_request.progress", advanced.progress(), advanced.quantity())); } @@ -362,6 +368,7 @@ public final class QuestRequestService { queueStacks(server, claim.playerId(), reward, request.id(), "quest_request_reward", actor); refreshSign(server, completed); + QuestTrackerSync.syncRequest(server, completed); ServerLevel signLevel = level(server, request.dimension()); BlockPos signPos = BlockPos.of(request.pos()); if (signLevel != null && signLevel.isLoaded(signPos)) signLevel.playSound(null, signPos, @@ -519,6 +526,7 @@ public final class QuestRequestService { if (!data.remove(request)) return; queueStacks(server, request.ownerId(), request.rewardEscrow(), request.id(), "quest_request_" + reason, null); + QuestTrackerSync.syncRequest(server, request); refreshedLayouts.remove(request.id()); ServerPlayer owner = server.getPlayerList().getPlayer(request.ownerId()); if (owner != null && !request.rewardEscrow().isEmpty()) message(owner, "reward_refunded"); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestTrackerSync.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestTrackerSync.java new file mode 100644 index 0000000..9a7c065 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestTrackerSync.java @@ -0,0 +1,90 @@ +package fr.koka99cab.sanctuary26.sanctuary.shop; + +import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +/** Pushes a bounded tracker snapshot of quests the player accepted. */ +public final class QuestTrackerSync { + public static final String SOURCE_REQUEST = "request"; + public static final String SOURCE_OWNED = "owned"; + public static final String SOURCE_BOARD = "board"; + + private QuestTrackerSync() {} + + public static void sync(ServerPlayer player) { + if (!ServerPlayNetworking.canSend(player, QuestTrackerSnapshotPayload.ID)) return; + ServerPlayNetworking.send(player, new QuestTrackerSnapshotPayload(entries(player))); + } + + public static void sync(MinecraftServer server, UUID... players) { + if (server == null || players == null) return; + for (UUID id : players) { + if (id == null) continue; + ServerPlayer player = server.getPlayerList().getPlayer(id); + if (player != null) sync(player); + } + } + + public static void syncRequest(MinecraftServer server, QuestRequestData.Request request) { + if (request == null) return; + sync(server, request.ownerId(), + request.claim().map(QuestRequestData.Claim::playerId).orElse(null)); + } + + public static void syncBoard(MinecraftServer server, QuestBoardData.Board board) { + if (board == null) return; + sync(server, board.contract().flatMap(QuestBoardData.Contract::claim) + .map(QuestBoardData.Claim::playerId).orElse(null)); + } + + private static List entries(ServerPlayer player) { + List entries = new ArrayList<>(); + UUID playerId = player.getUUID(); + MinecraftServer server = player.level().getServer(); + for (QuestRequestData.Request request : QuestRequestData.get(server).requests()) { + if (entries.size() >= QuestTrackerSnapshotPayload.MAX_ENTRIES) break; + if (!tracksAcceptedRequest(request, playerId)) continue; + entries.add(new QuestTrackerSnapshotPayload.Entry( + SOURCE_REQUEST, clip(request.id(), 36), clip(request.objective(), 96), + clip(request.action(), 16), clip(request.target(), 64), + request.progress(), request.quantity(), request.ownerColor())); + } + for (QuestBoardData.Board board : QuestBoardData.get(server).boards()) { + if (entries.size() >= QuestTrackerSnapshotPayload.MAX_ENTRIES) break; + QuestBoardData.Contract contract = board.contract().orElse(null); + if (contract == null || contract.claim().isEmpty() + || !contract.claim().get().playerId().equals(playerId)) continue; + QuestBoardData.Objective objective = contract.objective(); + entries.add(new QuestTrackerSnapshotPayload.Entry( + SOURCE_BOARD, clip(board.id(), 36), "", + clip(objective.kind(), 16), clip(objective.target(), 64), + objective.progress(), objective.required(), tierColor(board.questTier()))); + } + return entries; + } + + static boolean tracksAcceptedRequest(QuestRequestData.Request request, UUID playerId) { + if (request == null || playerId == null || !request.configured() || request.completed()) { + return false; + } + return request.claim().map(claim -> claim.playerId().equals(playerId)).orElse(false); + } + + private static int tierColor(QuestBoardTier tier) { + return switch (tier) { + case RUBY -> 0xFF5555; + case SAPPHIRE -> 0x55FFFF; + case EMERALD -> 0x55FF55; + }; + } + + private static String clip(String value, int maximumLength) { + if (value == null) return ""; + return value.length() <= maximumLength ? value : value.substring(0, maximumLength); + } +} diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index e81ed09..c745088 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -38,6 +38,7 @@ "screen.sanctuary.quest_request.reward_hint.loan": "minecraft:elytra", "screen.sanctuary.quest_request.publish": "Publish", "screen.sanctuary.quest_request.help": "The reward is removed from your inventory when published", + "screen.sanctuary.quest_request.id_help": "Type a name, then click the item to fill its ID", "screen.sanctuary.quest_request.confirm.title": "Confirm request", "screen.sanctuary.quest_request.confirm.accept": "Accept quest", "screen.sanctuary.quest_request.confirm.objective": "Objective: %s", @@ -242,6 +243,15 @@ "key.sanctuary.companion_power": "Companion power", "key.sanctuary.zoom": "Telephoto zoom", "key.sanctuary.switch_hotbar_row": "Cycle hotbar row", + "key.sanctuary.quest_tracker": "Quest tracker", + "hud.sanctuary.quest_tracker.title": "Quests", + "hud.sanctuary.quest_tracker.empty": "No quest in progress", + "hud.sanctuary.quest_tracker.toggle_on": "Quest tracker shown", + "hud.sanctuary.quest_tracker.toggle_off": "Quest tracker hidden", + "hud.sanctuary.quest_tracker.source.request": "Accepted request", + "hud.sanctuary.quest_tracker.source.owned": "Your request", + "hud.sanctuary.quest_tracker.source.board": "Daily contract", + "hud.sanctuary.quest_tracker.progress": "%s · %s · %s/%s", "message.sanctuary.zoom": "Zoom · FOV %s", "message.sanctuary.companion.passive_only": "This essence only has a passive power.", "message.sanctuary.companion.cooldown": "Power ready in %s s.", diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index a2554c4..85ad3f6 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -38,6 +38,7 @@ "screen.sanctuary.quest_request.reward_hint.loan": "minecraft:elytra", "screen.sanctuary.quest_request.publish": "Publier", "screen.sanctuary.quest_request.help": "La récompense est retirée de ton inventaire à la publication", + "screen.sanctuary.quest_request.id_help": "Tape un nom puis clique l'item pour remplir l'ID", "screen.sanctuary.quest_request.confirm.title": "Confirmer la demande", "screen.sanctuary.quest_request.confirm.accept": "Accepter la quête", "screen.sanctuary.quest_request.confirm.objective": "Objectif : %s", @@ -242,6 +243,15 @@ "key.sanctuary.companion_power": "Pouvoir du familier", "key.sanctuary.zoom": "Zoom téléobjectif", "key.sanctuary.switch_hotbar_row": "Changer de rangée de hotbar", + "key.sanctuary.quest_tracker": "Suivi des quêtes", + "hud.sanctuary.quest_tracker.title": "Quêtes", + "hud.sanctuary.quest_tracker.empty": "Aucune quête en cours", + "hud.sanctuary.quest_tracker.toggle_on": "Suivi des quêtes affiché", + "hud.sanctuary.quest_tracker.toggle_off": "Suivi des quêtes masqué", + "hud.sanctuary.quest_tracker.source.request": "Demande acceptée", + "hud.sanctuary.quest_tracker.source.owned": "Votre demande", + "hud.sanctuary.quest_tracker.source.board": "Contrat du jour", + "hud.sanctuary.quest_tracker.progress": "%s · %s · %s/%s", "message.sanctuary.zoom": "Zoom · FOV %s", "message.sanctuary.companion.passive_only": "Cette essence ne possède qu'un pouvoir passif.", "message.sanctuary.companion.cooldown": "Pouvoir disponible dans %s s.", diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json index ca6a3a7..0cb770b 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json @@ -31,6 +31,7 @@ "screen.sanctuary.quest_request.reward_hint.loan": "minecraft:elytra", "screen.sanctuary.quest_request.publish": "Опубликовать", "screen.sanctuary.quest_request.help": "При публикации награда изымается из инвентаря", + "screen.sanctuary.quest_request.id_help": "Введите название, затем кликните предмет, чтобы подставить ID", "screen.sanctuary.quest_request.confirm.title": "Подтвердить запрос", "screen.sanctuary.quest_request.confirm.accept": "Принять задание", "screen.sanctuary.quest_request.confirm.objective": "Цель: %s", @@ -154,6 +155,15 @@ "key.sanctuary.companion_power": "Способность спутника", "key.sanctuary.zoom": "Телефото-зум", "key.sanctuary.switch_hotbar_row": "Сменить ряд панели быстрого доступа", + "key.sanctuary.quest_tracker": "Трекер заданий", + "hud.sanctuary.quest_tracker.title": "Задания", + "hud.sanctuary.quest_tracker.empty": "Нет активного задания", + "hud.sanctuary.quest_tracker.toggle_on": "Трекер заданий показан", + "hud.sanctuary.quest_tracker.toggle_off": "Трекер заданий скрыт", + "hud.sanctuary.quest_tracker.source.request": "Принятый запрос", + "hud.sanctuary.quest_tracker.source.owned": "Ваш запрос", + "hud.sanctuary.quest_tracker.source.board": "Дневной контракт", + "hud.sanctuary.quest_tracker.progress": "%s · %s · %s/%s", "message.sanctuary.zoom": "Зум · FOV %s", "message.sanctuary.companion.passive_only": "У этой сущности есть только пассивная способность.", "message.sanctuary.companion.cooldown": "Способность будет готова через %s с.", diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestIdSuggestionsSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestIdSuggestionsSmoke.java new file mode 100644 index 0000000..4a3e720 --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestIdSuggestionsSmoke.java @@ -0,0 +1,40 @@ +package fr.koka99cab.sanctuary26.sanctuary.shop; + +import java.util.List; +import java.util.Set; + +/** Ranking for typed item names, accent-folded labels and inventory pins. */ +public final class QuestIdSuggestionsSmoke { + private QuestIdSuggestionsSmoke() {} + + public static void main(String[] args) { + List catalog = List.of( + new QuestIdSuggestions.Entry("minecraft:diamond", "Diamant"), + new QuestIdSuggestions.Entry("minecraft:diamond_sword", "Épée en diamant"), + new QuestIdSuggestions.Entry("minecraft:wheat", "Blé"), + new QuestIdSuggestions.Entry("anotherworld:ruby", "Rubis"), + new QuestIdSuggestions.Entry("minecraft:zombie_head", "Tête de zombie")); + require(QuestIdSuggestions.suggest(catalog, "diam", Set.of(), 7).getFirst().id() + .equals("minecraft:diamond"), "Prefix must prefer the shortest close name"); + require(QuestIdSuggestions.suggest(catalog, "epee", Set.of(), 7).getFirst().id() + .equals("minecraft:diamond_sword"), "Accent-folded display names must match"); + require(QuestIdSuggestions.suggest(catalog, "ble", Set.of(), 7).getFirst().id() + .equals("minecraft:wheat"), "Blé must resolve from ble"); + require(QuestIdSuggestions.resolve(catalog, "rubis", Set.of()) + .equals("anotherworld:ruby"), "Exact display names must resolve to their ID"); + require(QuestIdSuggestions.resolve(catalog, "diamond", Set.of()) + .equals("minecraft:diamond"), "Bare vanilla paths must resolve"); + require(QuestIdSuggestions.suggest(catalog, "", Set.of("anotherworld:ruby"), 7).isEmpty(), + "An empty query must not invent suggestions"); + require(QuestIdSuggestions.suggest(catalog, "zzz", Set.of(), 7).isEmpty(), + "Unknown names must produce no suggestion"); + require(QuestIdSuggestions.resolve(catalog, "", Set.of()) == null, + "Blank fields must not invent an ID"); + require(QuestIdSuggestions.resolve(catalog, "diam", Set.of()) == null, + "A partial name must wait for an explicit click"); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestTrackerModelSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestTrackerModelSmoke.java new file mode 100644 index 0000000..e02f5d9 --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestTrackerModelSmoke.java @@ -0,0 +1,52 @@ +package fr.koka99cab.sanctuary26.sanctuary.shop; + +import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Bounds and source tags for the concept quest-tracker snapshot. */ +public final class QuestTrackerModelSmoke { + private QuestTrackerModelSmoke() {} + + public static void main(String[] args) { + List overflow = new ArrayList<>(); + for (int index = 0; index < QuestTrackerSnapshotPayload.MAX_ENTRIES + 3; index++) { + overflow.add(new QuestTrackerSnapshotPayload.Entry(QuestTrackerSync.SOURCE_REQUEST, + "id-" + index, "Objective " + index, "kill", "minecraft:zombie", index, 10, 0xCC77AA)); + } + QuestTrackerSnapshotPayload payload = new QuestTrackerSnapshotPayload(overflow); + require(payload.entries().size() == QuestTrackerSnapshotPayload.MAX_ENTRIES, + "Tracker snapshot must stay bounded"); + require(payload.entries().getFirst().source().equals(QuestTrackerSync.SOURCE_REQUEST), + "Request source tag must be preserved"); + QuestTrackerSnapshotPayload.Entry owned = new QuestTrackerSnapshotPayload.Entry( + QuestTrackerSync.SOURCE_OWNED, "owned", "Posted", "bring", "minecraft:wheat", 0, 4, 0xFFFFFF); + QuestTrackerSnapshotPayload.Entry board = new QuestTrackerSnapshotPayload.Entry( + QuestTrackerSync.SOURCE_BOARD, "board", "", "kill_exact", "minecraft:skeleton", 2, 8, 0x55FF55); + QuestTrackerSnapshotPayload mixed = new QuestTrackerSnapshotPayload(List.of(owned, board)); + require(mixed.entries().get(0).required() == 4 && mixed.entries().get(1).progress() == 2, + "Owned and board progress must be preserved"); + require(new QuestTrackerSnapshotPayload(null).entries().isEmpty(), + "Null snapshot must become an empty tracker"); + UUID owner = UUID.fromString("6c43c35a-3498-41bb-a77e-10c679e635d1"); + UUID claimant = UUID.fromString("8ff11ade-a604-4d77-a770-d487684367a1"); + QuestRequestData.Request posted = new QuestRequestData.Request("request", "minecraft:overworld", + 42L, true, owner.toString(), 0xCC77AA, "Koka", "Posted", QuestRequestService.KILL, + QuestRequestService.MOB, "minecraft:zombie", 3, QuestRequestService.REWARD_GIFT, + "minecraft:diamond", 1, true, Optional.empty(), List.of(), 0, false); + require(!QuestTrackerSync.tracksAcceptedRequest(posted, owner), + "A posted request must stay off the author's tracker"); + QuestRequestData.Request claimed = posted.withClaim(Optional.of( + new QuestRequestData.Claim(claimant.toString(), "Hunter", 0x44AAFF, 10L))); + require(!QuestTrackerSync.tracksAcceptedRequest(claimed, owner), + "The author must not see a claimed request they posted"); + require(QuestTrackerSync.tracksAcceptedRequest(claimed, claimant), + "Only the player who accepted the request appears on the tracker"); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +}