fix(contracts): wire home-page clear action and hide completed one-time contracts

This commit is contained in:
darwincereska
2026-05-09 11:44:39 -04:00
parent 024630d96c
commit 5f0085d2ce
8 changed files with 199 additions and 108 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ Full project documentation is available in [docs/PROJECT.md](docs/PROJECT.md).
## How It Works ## 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 ## 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/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/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. | | `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. |
+19 -2
View File
@@ -86,7 +86,11 @@ Behavior:
- Hunting contracts track kills against a configured entity id. - Hunting contracts track kills against a configured entity id.
- The selected contract and its progress appear in the HUD sidebar. - The selected contract and its progress appear in the HUD sidebar.
- Completing a contract pays souls automatically. - 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. - 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: Implemented by:
@@ -122,6 +126,9 @@ The HUD layer is optional and configurable per player.
Behavior: Behavior:
- Scoreboard sidebar can be enabled globally and toggled per player. - 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. - Leaderboard pages are built from stored player names and balance values.
- Wanted-player bossbars show bounty value and remaining time. - 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 catalog definition. It controls:
- Contract categories by type - Grouped contract lists under `mining` and `hunting`
- Internal contract ids from the YAML keys - Internal contract ids from each entry `id`
- Player-facing contract names - Player-facing contract names
- Icon item ids - Icon item ids
- Target block or mob ids - Target block or mob ids
@@ -268,6 +275,16 @@ When a player joins:
2. Their HUD state is refreshed. 2. Their HUD state is refreshed.
3. Any configured scoreboard or bossbar state is pushed to them. 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 ### Player Kill
When one player kills another player: When one player kills another player:
+1 -1
View File
@@ -10,7 +10,7 @@ loom_version=1.16.1
fabric_api_version=0.141.3+1.21.11 fabric_api_version=0.141.3+1.21.11
# Mod Properties # Mod Properties
mod_version=0.3.0 mod_version=0.4.0
maven_group=com.g2806.soulsteal maven_group=com.g2806.soulsteal
archives_base_name=soul-steal archives_base_name=soul-steal
@@ -3,7 +3,6 @@ package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.SoulStealConfig.ContractConfig; import com.g2806.soulsteal.config.SoulStealConfig.ContractConfig;
import com.g2806.soulsteal.config.YamlConfigHelper; import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -16,32 +15,20 @@ import java.util.Optional;
*/ */
public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List<ContractDefinition> contracts) { public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List<ContractDefinition> contracts) {
public static ContractCatalog fromMap(Map<String, Object> root, ContractConfig config) { public static ContractCatalog fromMap(Map<String, Object> root, ContractConfig config) {
Map<String, Object> contractsSection = YamlConfigHelper.section(root, "contracts");
List<ContractDefinition> contracts = new ArrayList<>(); List<ContractDefinition> contracts = new ArrayList<>();
for (Map.Entry<String, Object> entry : contractsSection.entrySet()) { Object rawContracts = root.get("contracts");
if (!(entry.getValue() instanceof Map<?, ?> rawMap)) { if (rawContracts instanceof List<?> rawList) {
continue; for (Object rawContract : rawList) {
addContract(contracts, rawContract);
} }
Map<String, Object> map = toStringMap(rawMap); } else if (rawContracts instanceof Map<?, ?> rawSections) {
String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase(); for (Map.Entry<?, ?> sectionEntry : rawSections.entrySet()) {
ContractType type; if (sectionEntry.getValue() instanceof List<?> rawList) {
try { for (Object rawContract : rawList) {
type = ContractType.valueOf(typeName); addContract(contracts, rawContract);
} catch (IllegalArgumentException ignored) { }
continue; }
} }
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); 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 """ return """
contracts: contracts:
mining: mining:
name: "Mining Contracts" - id: "iron_miner"
icon: "minecraft:iron_pickaxe" name: "Mining Contracts"
type: "mining" icon: "minecraft:iron_pickaxe"
target: "minecraft:iron_ore" type: "mining"
target_name: "Iron Ore" target: "minecraft:iron_ore"
description: "Mine iron ore to earn souls." target_name: "Iron Ore"
amount: 64 description: "Mine iron ore to earn souls."
reward: 250 amount: 64
repeatable: true reward: 250
zombie_hunter: repeatable: true
name: "Zombie Hunter" hunting:
icon: "minecraft:zombie_head" - id: "zombie_hunter"
type: "hunting" name: "Zombie Hunter"
target: "minecraft:zombie" icon: "minecraft:zombie_head"
target_name: "Zombie" type: "hunting"
description: "Hunt zombies to earn souls." target: "minecraft:zombie"
amount: 20 target_name: "Zombie"
reward: 200 description: "Hunt zombies to earn souls."
repeatable: true amount: 20
reward: 200
repeatable: true
"""; """;
} }
private static Map<String, Object> toStringMap(Map<?, ?> rawMap) { private static Map<String, Object> toStringMap(Map<?, ?> rawMap) {
Map<String, Object> converted = new LinkedHashMap<>(); Map<String, Object> converted = new java.util.LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) { for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() != null) { if (entry.getKey() != null) {
converted.put(String.valueOf(entry.getKey()), entry.getValue()); converted.put(String.valueOf(entry.getKey()), entry.getValue());
@@ -89,4 +78,36 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
} }
return converted; return converted;
} }
private static void addContract(List<ContractDefinition> contracts, Object rawContract) {
if (!(rawContract instanceof Map<?, ?> rawMap)) {
return;
}
Map<String, Object> 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)
));
}
} }
@@ -25,7 +25,12 @@ import net.minecraft.util.Formatting;
*/ */
public final class ContractGuiService { public final class ContractGuiService {
private static final int PAGE_ROWS = 6; 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<ConfigBundle> bundleSupplier; private final Supplier<ConfigBundle> bundleSupplier;
private final ContractService contractService; private final ContractService contractService;
private final RewardService rewardService; private final RewardService rewardService;
@@ -94,41 +99,47 @@ public final class ContractGuiService {
private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) { private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS); SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> mining = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING), view.page()); List<ContractDefinition> mining = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
List<ContractDefinition> hunting = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING), view.page()); List<ContractDefinition> hunting = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
inventory.setStack(11, createCategoryButton("Mining Contracts", "minecraft:iron_pickaxe", mining.size(), "Browse mining contracts")); inventory.setStack(0, 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(1, createCategoryButton("Hunting Contracts", "minecraft:zombie_head", hunting.size(), "Browse mob hunting contracts"));
inventory.setStack(4, createHomeInfoButton(player)); inventory.setStack(SLOT_INFO, createHomeInfoButton(player));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
return inventory; return inventory;
} }
private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) { private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS); SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> contracts = pagedContracts(contractsFor(view.categoryKey()), view.page()); List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
for (int index = 0; index < contracts.size() && index < ITEM_SLOT_COUNT; index++) { for (int index = 0; index < contracts.size() && index < ITEM_SLOT_COUNT; index++) {
inventory.setStack(index, createContractStack(player, contracts.get(index))); inventory.setStack(index, createContractStack(player, contracts.get(index)));
} }
inventory.setStack(ITEM_SLOT_COUNT, createBackButton()); inventory.setStack(SLOT_HOME, createBackButton());
inventory.setStack(ITEM_SLOT_COUNT + 1, createPageButton(view.page(), totalPages(view.categoryKey()), true)); inventory.setStack(SLOT_PREVIOUS, createPageButton(view.page(), totalPages(player, view.categoryKey()), true));
inventory.setStack(ITEM_SLOT_COUNT + 4, createCategoryInfoButton(player, view.categoryKey())); inventory.setStack(SLOT_INFO, createCategoryInfoButton(player, view.categoryKey()));
inventory.setStack(ITEM_SLOT_COUNT + 7, createPageButton(view.page(), totalPages(view.categoryKey()), false)); inventory.setStack(SLOT_CLEAR, createClearButton(player));
inventory.setStack(SLOT_NEXT, createPageButton(view.page(), totalPages(player, view.categoryKey()), false));
return inventory; return inventory;
} }
private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) { private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) {
if (slotIndex == 11) { if (slotIndex == 0) {
openCategory(player, "mining", 0); openCategory(player, "mining", 0);
} else if (slotIndex == 15) { } else if (slotIndex == 1) {
openCategory(player, "hunting", 0); 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) { private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) {
if (slotIndex < ITEM_SLOT_COUNT) { if (slotIndex < ITEM_SLOT_COUNT) {
List<ContractDefinition> contracts = pagedContracts(contractsFor(view.categoryKey()), view.page()); List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
if (slotIndex >= contracts.size()) { if (slotIndex >= contracts.size()) {
return; return;
} }
@@ -140,12 +151,16 @@ public final class ContractGuiService {
return; return;
} }
if (slotIndex == ITEM_SLOT_COUNT) { if (slotIndex == SLOT_HOME) {
openContracts(player); openContracts(player);
} else if (slotIndex == ITEM_SLOT_COUNT + 1) { } else if (slotIndex == SLOT_PREVIOUS) {
openCategory(player, view.categoryKey(), Math.max(0, view.page() - 1)); openCategory(player, view.categoryKey(), Math.max(0, view.page() - 1));
} else if (slotIndex == ITEM_SLOT_COUNT + 7) { } else if (slotIndex == SLOT_NEXT) {
openCategory(player, view.categoryKey(), Math.min(totalPages(view.categoryKey()) - 1, view.page() + 1)); 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) { private ItemStack createCategoryInfoButton(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = contractsFor(categoryKey); List<ContractDefinition> contracts = contractsFor(player, categoryKey);
return createPreviewStack("minecraft:nether_star", categoryLabel(categoryKey), List.of( return createPreviewStack("minecraft:nether_star", categoryLabel(categoryKey), List.of(
Text.literal("Contracts: " + contracts.size()).formatted(Formatting.AQUA), Text.literal("Contracts: " + contracts.size()).formatted(Formatting.AQUA),
Text.literal("Selected: " + (selected(player) == null ? "None" : selected(player).name())).formatted(Formatting.GOLD) 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))); 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) { private ItemStack createPageButton(int page, int totalPages, boolean previous) {
boolean available = previous ? page > 0 : page < totalPages - 1; boolean available = previous ? page > 0 : page < totalPages - 1;
String label = previous ? "Previous Page" : "Next Page"; String label = previous ? "Previous Page" : "Next Page";
@@ -207,12 +230,15 @@ public final class ContractGuiService {
)); ));
} }
private List<ContractDefinition> contractsFor(String categoryKey) { private List<ContractDefinition> contractsFor(ServerPlayerEntity player, String categoryKey) {
return switch (categoryKey) { List<ContractDefinition> contracts = switch (categoryKey) {
case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING); case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING); case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
default -> List.of(); default -> List.of();
}; };
return contracts.stream()
.filter(contract -> contract.repeatable() || !contractService.hasCompletedContract(player.getUuid(), contract.id()))
.toList();
} }
private List<ContractDefinition> pagedContracts(List<ContractDefinition> contracts, int page) { private List<ContractDefinition> pagedContracts(List<ContractDefinition> contracts, int page) {
@@ -224,8 +250,8 @@ public final class ContractGuiService {
return contracts.subList(from, to); return contracts.subList(from, to);
} }
private int totalPages(String categoryKey) { private int totalPages(ServerPlayerEntity player, String categoryKey) {
return Math.max(1, (int) Math.ceil(contractsFor(categoryKey).size() / (double) ITEM_SLOT_COUNT)); return Math.max(1, (int) Math.ceil(contractsFor(player, categoryKey).size() / (double) ITEM_SLOT_COUNT));
} }
private String categoryLabel(String categoryKey) { private String categoryLabel(String categoryKey) {
@@ -24,6 +24,7 @@ public final class SoulStealData {
private Map<String, Boolean> scoreboardVisibility = new HashMap<>(); private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
private Map<String, String> selectedContracts = new HashMap<>(); private Map<String, String> selectedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractProgress = new HashMap<>(); private Map<String, Map<String, Long>> contractProgress = new HashMap<>();
private Map<String, Set<String>> completedContracts = new HashMap<>();
public SoulStealData normalize() { public SoulStealData normalize() {
if (souls == null) { if (souls == null) {
@@ -56,6 +57,9 @@ public final class SoulStealData {
if (contractProgress == null) { if (contractProgress == null) {
contractProgress = new HashMap<>(); contractProgress = new HashMap<>();
} }
if (completedContracts == null) {
completedContracts = new HashMap<>();
}
return this; return this;
} }
@@ -138,4 +142,8 @@ public final class SoulStealData {
public Map<String, Map<String, Long>> contractProgress() { public Map<String, Map<String, Long>> contractProgress() {
return contractProgress; return contractProgress;
} }
public Map<String, Set<String>> completedContracts() {
return completedContracts;
}
} }
@@ -36,6 +36,9 @@ public final class ContractService {
if (contract.isEmpty()) { if (contract.isEmpty()) {
return false; 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().selectedContracts().put(key(player.getUuid()), contract.get().id());
dataStore.data().contractProgress().remove(key(player.getUuid())); dataStore.data().contractProgress().remove(key(player.getUuid()));
saveQuietly(); saveQuietly();
@@ -60,6 +63,12 @@ public final class ContractService {
.getOrDefault(contractId, 0L); .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) { public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId); record(player, ContractType.MINING, blockId);
} }
@@ -86,6 +95,11 @@ public final class ContractService {
soulService.addSouls(player.getUuid(), contract.reward()); soulService.addSouls(player.getUuid(), contract.reward());
player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+" player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+"
+ contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false); + 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()) { if (!contract.repeatable()) {
dataStore.data().selectedContracts().remove(playerKey); dataStore.data().selectedContracts().remove(playerKey);
} }
@@ -32,6 +32,7 @@ import net.minecraft.scoreboard.number.BlankNumberFormat;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */ /** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */
public final class HudService { public final class HudService {
@@ -241,25 +242,29 @@ public final class HudService {
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) { private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum(); List<Text> lines = new ArrayList<>();
long remainingSeconds = activeBounties.stream() lines.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
return List.of( contractService.selectedContract(player.getUuid()).ifPresent(contract -> {
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())), long progress = contractService.progress(player.getUuid(), contract.id());
Text.literal(contractService.selectedContract(player.getUuid()) lines.add(Text.literal("Contract: " + contract.name()).formatted(Formatting.AQUA));
.map(contract -> "Contract: " + contract.name()) lines.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GRAY));
.orElse("Contract: None")), });
Text.literal(contractService.selectedContract(player.getUuid())
.map(contract -> "Progress: " + contractService.progress(player.getUuid(), contract.id()) + "/" + contract.amountRequired()) if (!activeBounties.isEmpty()) {
.orElse("Progress: -")), long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
Text.literal("Bounties: " + activeBounties.size()), long remainingSeconds = activeBounties.stream()
Text.literal("Wanted Value: " + totalValue), .mapToLong(StoredBounty::expiresAtEpochMillis)
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None")) .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) { private void clearSidebar(ServerPlayerEntity player) {
@@ -289,7 +294,7 @@ public final class HudService {
return scoreboard.addObjective( return scoreboard.addObjective(
objectiveName, objectiveName,
ScoreboardCriterion.DUMMY, ScoreboardCriterion.DUMMY,
Text.literal(configSupplier.get().hud().scoreboard().title()), Text.literal(configSupplier.get().hud().scoreboard().title()).formatted(Formatting.DARK_AQUA),
ScoreboardCriterion.RenderType.INTEGER, ScoreboardCriterion.RenderType.INTEGER,
false, false,
BlankNumberFormat.INSTANCE BlankNumberFormat.INSTANCE