diff --git a/README.md b/README.md index 048ed83..47a9dcb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Full project documentation is available in [docs/PROJECT.md](docs/PROJECT.md). ## How It Works -Players gain souls for killing other players and lose souls whenever they die, with all values driven by `config.yml`. The bounty system lets players spend souls to place timed bounties that pay killers on claim or reward survivors on expiry, while wanted players can see a bounty timer bossbar. The optional HUD sidebar can be toggled per player, and `/souls top` shows the configured leaderboard. The shop is a server-side chest GUI with a category home page, arrow pagination, optional reward display names, and item listings that can open a quantity selector. +Players gain souls for killing other players and lose souls whenever they die, with all values driven by `config.yml`. The bounty system lets players spend souls to place timed bounties that pay killers on claim or reward survivors on expiry, while wanted players can see a bounty timer bossbar. The optional HUD sidebar can be toggled per player, uses the same dark aqua title styling as the mod prefix, color-codes the visible lines, and only shows contract or bounty rows when they are actually active. `/souls top` shows the configured leaderboard. The shop is a server-side chest GUI with a category home page, arrow pagination, optional reward display names, and item listings that can open a quantity selector. ## Commands @@ -42,5 +42,5 @@ Players gain souls for killing other players and lose souls whenever they die, w | --- | --- | | `config/soulsteal/config.yml` | Economy values, death penalties, bounty limits, HUD toggles, leaderboard size, bossbar text, and command permission nodes. | | `config/soulsteal/shop.yml` | Shop categories, GUI entries, prices, cooldowns, reward display names, and optional custom-amount settings for item listings. | -| `config/soulsteal/catalog.yml` | Mining and hunting contract entries, internal ids, player-facing names, icons, targets, progress amounts, and rewards. | +| `config/soulsteal/catalog.yml` | Grouped mining and hunting contract sections, internal ids, player-facing names, icons, targets, progress amounts, rewards, and repeatable/one-time behavior. | | `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. | diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 72980b8..7a8f23d 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -86,7 +86,11 @@ Behavior: - Hunting contracts track kills against a configured entity id. - The selected contract and its progress appear in the HUD sidebar. - Completing a contract pays souls automatically. +- One-time contracts disappear from the browser after completion and cannot be selected again. - The contract browser uses `catalog.yml`, where the YAML key is the internal contract id and `name` is the player-facing label. +- Contract entries are organized under `contracts: mining:` and `contracts: hunting:` sections for readability, while still using per-entry ids. +- The contract browser uses a bottom control row like the shop, including page navigation anchored to the bottom of the inventory. +- The contract browser includes a clear-selection action on the home page and inside contract categories so players can remove their active contract from the GUI. Implemented by: @@ -122,6 +126,9 @@ The HUD layer is optional and configurable per player. Behavior: - Scoreboard sidebar can be enabled globally and toggled per player. +- The sidebar title uses the same dark aqua styling as the mod's chat prefix. +- Contract and bounty rows only appear while the player has an active selected contract or active bounties. +- Balance, contract, and bounty rows use color to make the sidebar easier to scan. - Leaderboard pages are built from stored player names and balance values. - Wanted-player bossbars show bounty value and remaining time. @@ -231,8 +238,8 @@ The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulst Contract catalog definition. It controls: -- Contract categories by type -- Internal contract ids from the YAML keys +- Grouped contract lists under `mining` and `hunting` +- Internal contract ids from each entry `id` - Player-facing contract names - Icon item ids - Target block or mob ids @@ -268,6 +275,16 @@ When a player joins: 2. Their HUD state is refreshed. 3. Any configured scoreboard or bossbar state is pushed to them. +### HUD Sidebar + +When the sidebar is visible: + +1. The title is rendered in dark aqua. +2. The soul balance row is always shown. +3. Contract rows are only shown if the player has an active selected contract. +4. Bounty rows are only shown if the player currently has active bounties. +5. The visible rows use color to distinguish balance, contract, and bounty information. + ### Player Kill When one player kills another player: diff --git a/gradle.properties b/gradle.properties index 82096af..1cad413 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ loom_version=1.16.1 fabric_api_version=0.141.3+1.21.11 # Mod Properties -mod_version=0.3.0 +mod_version=0.4.0 maven_group=com.g2806.soulsteal archives_base_name=soul-steal diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java b/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java index e42f5ab..c179f36 100644 --- a/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java +++ b/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java @@ -3,7 +3,6 @@ package com.g2806.soulsteal.contract; import com.g2806.soulsteal.config.SoulStealConfig.ContractConfig; import com.g2806.soulsteal.config.YamlConfigHelper; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -16,32 +15,20 @@ import java.util.Optional; */ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List contracts) { public static ContractCatalog fromMap(Map root, ContractConfig config) { - Map contractsSection = YamlConfigHelper.section(root, "contracts"); List contracts = new ArrayList<>(); - for (Map.Entry entry : contractsSection.entrySet()) { - if (!(entry.getValue() instanceof Map rawMap)) { - continue; + Object rawContracts = root.get("contracts"); + if (rawContracts instanceof List rawList) { + for (Object rawContract : rawList) { + addContract(contracts, rawContract); } - Map map = toStringMap(rawMap); - String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase(); - ContractType type; - try { - type = ContractType.valueOf(typeName); - } catch (IllegalArgumentException ignored) { - continue; + } else if (rawContracts instanceof Map rawSections) { + for (Map.Entry sectionEntry : rawSections.entrySet()) { + if (sectionEntry.getValue() instanceof List rawList) { + for (Object rawContract : rawList) { + addContract(contracts, rawContract); + } + } } - contracts.add(new ContractDefinition( - entry.getKey(), - YamlConfigHelper.string(map, "name", entry.getKey()), - YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"), - type, - YamlConfigHelper.string(map, "target", ""), - YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", entry.getKey())), - YamlConfigHelper.string(map, "description", ""), - Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)), - Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)), - YamlConfigHelper.bool(map, "repeatable", true) - )); } return new ContractCatalog(config.enabled(), config.autoClaim(), config.hud().enabled(), config.hud().title(), contracts); } @@ -58,30 +45,32 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna return """ contracts: mining: - name: "Mining Contracts" - icon: "minecraft:iron_pickaxe" - type: "mining" - target: "minecraft:iron_ore" - target_name: "Iron Ore" - description: "Mine iron ore to earn souls." - amount: 64 - reward: 250 - repeatable: true - zombie_hunter: - name: "Zombie Hunter" - icon: "minecraft:zombie_head" - type: "hunting" - target: "minecraft:zombie" - target_name: "Zombie" - description: "Hunt zombies to earn souls." - amount: 20 - reward: 200 - repeatable: true + - id: "iron_miner" + name: "Mining Contracts" + icon: "minecraft:iron_pickaxe" + type: "mining" + target: "minecraft:iron_ore" + target_name: "Iron Ore" + description: "Mine iron ore to earn souls." + amount: 64 + reward: 250 + repeatable: true + hunting: + - id: "zombie_hunter" + name: "Zombie Hunter" + icon: "minecraft:zombie_head" + type: "hunting" + target: "minecraft:zombie" + target_name: "Zombie" + description: "Hunt zombies to earn souls." + amount: 20 + reward: 200 + repeatable: true """; } private static Map toStringMap(Map rawMap) { - Map converted = new LinkedHashMap<>(); + Map converted = new java.util.LinkedHashMap<>(); for (Map.Entry entry : rawMap.entrySet()) { if (entry.getKey() != null) { converted.put(String.valueOf(entry.getKey()), entry.getValue()); @@ -89,4 +78,36 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna } return converted; } + + private static void addContract(List contracts, Object rawContract) { + if (!(rawContract instanceof Map rawMap)) { + return; + } + Map map = toStringMap(rawMap); + String id = YamlConfigHelper.string(map, "id", "").trim(); + if (id.isBlank()) { + return; + } + + String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase(); + ContractType type; + try { + type = ContractType.valueOf(typeName); + } catch (IllegalArgumentException ignored) { + return; + } + + contracts.add(new ContractDefinition( + id, + YamlConfigHelper.string(map, "name", id), + YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"), + type, + YamlConfigHelper.string(map, "target", ""), + YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", id)), + YamlConfigHelper.string(map, "description", ""), + Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)), + Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)), + YamlConfigHelper.bool(map, "repeatable", true) + )); + } } diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java b/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java index faff74a..2b11ea5 100644 --- a/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java +++ b/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java @@ -25,7 +25,12 @@ import net.minecraft.util.Formatting; */ public final class ContractGuiService { private static final int PAGE_ROWS = 6; - private static final int ITEM_SLOT_COUNT = 27; + private static final int ITEM_SLOT_COUNT = 45; + private static final int SLOT_HOME = 45; + private static final int SLOT_PREVIOUS = 46; + private static final int SLOT_INFO = 49; + private static final int SLOT_CLEAR = 52; + private static final int SLOT_NEXT = 53; private final Supplier bundleSupplier; private final ContractService contractService; private final RewardService rewardService; @@ -94,41 +99,47 @@ public final class ContractGuiService { private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) { SimpleInventory inventory = filledInventory(PAGE_ROWS); - List mining = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING), view.page()); - List hunting = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING), view.page()); + List mining = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING); + List hunting = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING); - inventory.setStack(11, createCategoryButton("Mining Contracts", "minecraft:iron_pickaxe", mining.size(), "Browse mining contracts")); - inventory.setStack(15, createCategoryButton("Hunting Contracts", "minecraft:zombie_head", hunting.size(), "Browse mob hunting contracts")); - inventory.setStack(4, createHomeInfoButton(player)); + inventory.setStack(0, createCategoryButton("Mining Contracts", "minecraft:iron_pickaxe", mining.size(), "Browse mining contracts")); + inventory.setStack(1, createCategoryButton("Hunting Contracts", "minecraft:zombie_head", hunting.size(), "Browse mob hunting contracts")); + inventory.setStack(SLOT_INFO, createHomeInfoButton(player)); + inventory.setStack(SLOT_CLEAR, createClearButton(player)); return inventory; } private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) { SimpleInventory inventory = filledInventory(PAGE_ROWS); - List contracts = pagedContracts(contractsFor(view.categoryKey()), view.page()); + List contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page()); for (int index = 0; index < contracts.size() && index < ITEM_SLOT_COUNT; index++) { inventory.setStack(index, createContractStack(player, contracts.get(index))); } - inventory.setStack(ITEM_SLOT_COUNT, createBackButton()); - inventory.setStack(ITEM_SLOT_COUNT + 1, createPageButton(view.page(), totalPages(view.categoryKey()), true)); - inventory.setStack(ITEM_SLOT_COUNT + 4, createCategoryInfoButton(player, view.categoryKey())); - inventory.setStack(ITEM_SLOT_COUNT + 7, createPageButton(view.page(), totalPages(view.categoryKey()), false)); + inventory.setStack(SLOT_HOME, createBackButton()); + inventory.setStack(SLOT_PREVIOUS, createPageButton(view.page(), totalPages(player, view.categoryKey()), true)); + inventory.setStack(SLOT_INFO, createCategoryInfoButton(player, view.categoryKey())); + inventory.setStack(SLOT_CLEAR, createClearButton(player)); + inventory.setStack(SLOT_NEXT, createPageButton(view.page(), totalPages(player, view.categoryKey()), false)); return inventory; } private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) { - if (slotIndex == 11) { + if (slotIndex == 0) { openCategory(player, "mining", 0); - } else if (slotIndex == 15) { + } else if (slotIndex == 1) { openCategory(player, "hunting", 0); + } else if (slotIndex == SLOT_CLEAR) { + clearSelection(player); + player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false); + openHome(player, view.page()); } } private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) { if (slotIndex < ITEM_SLOT_COUNT) { - List contracts = pagedContracts(contractsFor(view.categoryKey()), view.page()); + List contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page()); if (slotIndex >= contracts.size()) { return; } @@ -140,12 +151,16 @@ public final class ContractGuiService { return; } - if (slotIndex == ITEM_SLOT_COUNT) { + if (slotIndex == SLOT_HOME) { openContracts(player); - } else if (slotIndex == ITEM_SLOT_COUNT + 1) { + } else if (slotIndex == SLOT_PREVIOUS) { openCategory(player, view.categoryKey(), Math.max(0, view.page() - 1)); - } else if (slotIndex == ITEM_SLOT_COUNT + 7) { - openCategory(player, view.categoryKey(), Math.min(totalPages(view.categoryKey()) - 1, view.page() + 1)); + } else if (slotIndex == SLOT_NEXT) { + openCategory(player, view.categoryKey(), Math.min(totalPages(player, view.categoryKey()) - 1, view.page() + 1)); + } else if (slotIndex == SLOT_CLEAR) { + clearSelection(player); + player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false); + openCategory(player, view.categoryKey(), view.page()); } } @@ -187,7 +202,7 @@ public final class ContractGuiService { } private ItemStack createCategoryInfoButton(ServerPlayerEntity player, String categoryKey) { - List contracts = contractsFor(categoryKey); + List contracts = contractsFor(player, categoryKey); return createPreviewStack("minecraft:nether_star", categoryLabel(categoryKey), List.of( Text.literal("Contracts: " + contracts.size()).formatted(Formatting.AQUA), Text.literal("Selected: " + (selected(player) == null ? "None" : selected(player).name())).formatted(Formatting.GOLD) @@ -198,6 +213,14 @@ public final class ContractGuiService { return createPreviewStack("minecraft:barrier", "Back", List.of(Text.literal("Return to the contract browser.").formatted(Formatting.GRAY))); } + private ItemStack createClearButton(ServerPlayerEntity player) { + ContractDefinition selected = selected(player); + return createPreviewStack("minecraft:redstone_torch", "Clear Selected", List.of( + Text.literal(selected == null ? "No contract selected." : "Clear: " + selected.name()).formatted(Formatting.GRAY), + Text.literal("Remove your active contract.").formatted(Formatting.DARK_GRAY) + )); + } + private ItemStack createPageButton(int page, int totalPages, boolean previous) { boolean available = previous ? page > 0 : page < totalPages - 1; String label = previous ? "Previous Page" : "Next Page"; @@ -207,12 +230,15 @@ public final class ContractGuiService { )); } - private List contractsFor(String categoryKey) { - return switch (categoryKey) { + private List contractsFor(ServerPlayerEntity player, String categoryKey) { + List contracts = switch (categoryKey) { case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING); case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING); default -> List.of(); }; + return contracts.stream() + .filter(contract -> contract.repeatable() || !contractService.hasCompletedContract(player.getUuid(), contract.id())) + .toList(); } private List pagedContracts(List contracts, int page) { @@ -224,8 +250,8 @@ public final class ContractGuiService { return contracts.subList(from, to); } - private int totalPages(String categoryKey) { - return Math.max(1, (int) Math.ceil(contractsFor(categoryKey).size() / (double) ITEM_SLOT_COUNT)); + private int totalPages(ServerPlayerEntity player, String categoryKey) { + return Math.max(1, (int) Math.ceil(contractsFor(player, categoryKey).size() / (double) ITEM_SLOT_COUNT)); } private String categoryLabel(String categoryKey) { diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java index 5aa655d..c6d1171 100644 --- a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java @@ -24,6 +24,7 @@ public final class SoulStealData { private Map scoreboardVisibility = new HashMap<>(); private Map selectedContracts = new HashMap<>(); private Map> contractProgress = new HashMap<>(); + private Map> completedContracts = new HashMap<>(); public SoulStealData normalize() { if (souls == null) { @@ -56,6 +57,9 @@ public final class SoulStealData { if (contractProgress == null) { contractProgress = new HashMap<>(); } + if (completedContracts == null) { + completedContracts = new HashMap<>(); + } return this; } @@ -138,4 +142,8 @@ public final class SoulStealData { public Map> contractProgress() { return contractProgress; } + + public Map> completedContracts() { + return completedContracts; + } } diff --git a/src/main/java/com/g2806/soulsteal/service/ContractService.java b/src/main/java/com/g2806/soulsteal/service/ContractService.java index 4789274..cf06eeb 100644 --- a/src/main/java/com/g2806/soulsteal/service/ContractService.java +++ b/src/main/java/com/g2806/soulsteal/service/ContractService.java @@ -36,6 +36,9 @@ public final class ContractService { if (contract.isEmpty()) { return false; } + if (!contract.get().repeatable() && hasCompletedContract(player.getUuid(), contract.get().id())) { + return false; + } dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id()); dataStore.data().contractProgress().remove(key(player.getUuid())); saveQuietly(); @@ -60,6 +63,12 @@ public final class ContractService { .getOrDefault(contractId, 0L); } + public boolean hasCompletedContract(UUID playerUuid, String contractId) { + return dataStore.data().completedContracts() + .getOrDefault(key(playerUuid), java.util.Set.of()) + .contains(contractId); + } + public void recordMining(ServerPlayerEntity player, String blockId) { record(player, ContractType.MINING, blockId); } @@ -86,6 +95,11 @@ public final class ContractService { soulService.addSouls(player.getUuid(), contract.reward()); player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+" + contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false); + if (!contract.repeatable()) { + dataStore.data().completedContracts() + .computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>()) + .add(contract.id()); + } if (!contract.repeatable()) { dataStore.data().selectedContracts().remove(playerKey); } diff --git a/src/main/java/com/g2806/soulsteal/service/HudService.java b/src/main/java/com/g2806/soulsteal/service/HudService.java index fd37325..bde27fb 100644 --- a/src/main/java/com/g2806/soulsteal/service/HudService.java +++ b/src/main/java/com/g2806/soulsteal/service/HudService.java @@ -25,13 +25,14 @@ import net.minecraft.network.packet.s2c.play.ScoreboardObjectiveUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.ScoreboardScoreResetS2CPacket; import net.minecraft.network.packet.s2c.play.ScoreboardScoreUpdateS2CPacket; import net.minecraft.scoreboard.Scoreboard; -import net.minecraft.scoreboard.ScoreboardCriterion; -import net.minecraft.scoreboard.ScoreboardDisplaySlot; -import net.minecraft.scoreboard.ScoreboardObjective; -import net.minecraft.scoreboard.number.BlankNumberFormat; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.text.Text; +import net.minecraft.scoreboard.ScoreboardCriterion; +import net.minecraft.scoreboard.ScoreboardDisplaySlot; +import net.minecraft.scoreboard.ScoreboardObjective; +import net.minecraft.scoreboard.number.BlankNumberFormat; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; /** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */ public final class HudService { @@ -239,27 +240,31 @@ public final class HudService { } } - private List buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) { - List activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); - long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum(); - long remainingSeconds = activeBounties.stream() - .mapToLong(StoredBounty::expiresAtEpochMillis) - .max() - .orElse(nowEpochMillis); - remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L); - - return List.of( - Text.literal("Souls: " + soulService.balanceOf(player.getUuid())), - Text.literal(contractService.selectedContract(player.getUuid()) - .map(contract -> "Contract: " + contract.name()) - .orElse("Contract: None")), - Text.literal(contractService.selectedContract(player.getUuid()) - .map(contract -> "Progress: " + contractService.progress(player.getUuid(), contract.id()) + "/" + contract.amountRequired()) - .orElse("Progress: -")), - Text.literal("Bounties: " + activeBounties.size()), - Text.literal("Wanted Value: " + totalValue), - Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None")) - ); + private List buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) { + List activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); + List lines = new ArrayList<>(); + lines.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD)); + + contractService.selectedContract(player.getUuid()).ifPresent(contract -> { + long progress = contractService.progress(player.getUuid(), contract.id()); + lines.add(Text.literal("Contract: " + contract.name()).formatted(Formatting.AQUA)); + lines.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GRAY)); + }); + + if (!activeBounties.isEmpty()) { + long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum(); + long remainingSeconds = activeBounties.stream() + .mapToLong(StoredBounty::expiresAtEpochMillis) + .max() + .orElse(nowEpochMillis); + remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L); + + lines.add(Text.literal("Bounties: " + activeBounties.size()).formatted(Formatting.RED)); + lines.add(Text.literal("Wanted Value: " + totalValue).formatted(Formatting.GOLD)); + lines.add(Text.literal("Wanted Time: " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(Formatting.DARK_RED)); + } + + return lines; } private void clearSidebar(ServerPlayerEntity player) { @@ -286,14 +291,14 @@ public final class HudService { private ScoreboardObjective createObjective(String objectiveName) { Scoreboard scoreboard = new Scoreboard(); - return scoreboard.addObjective( - objectiveName, - ScoreboardCriterion.DUMMY, - Text.literal(configSupplier.get().hud().scoreboard().title()), - ScoreboardCriterion.RenderType.INTEGER, - false, - BlankNumberFormat.INSTANCE - ); + return scoreboard.addObjective( + objectiveName, + ScoreboardCriterion.DUMMY, + Text.literal(configSupplier.get().hud().scoreboard().title()).formatted(Formatting.DARK_AQUA), + ScoreboardCriterion.RenderType.INTEGER, + false, + BlankNumberFormat.INSTANCE + ); } private void rememberPlayer(ServerPlayerEntity player) {