feat(contracts): add server-side contract browser and progress tracking

This commit is contained in:
darwincereska
2026-05-09 11:16:46 -04:00
parent 84e05eff7f
commit 024630d96c
14 changed files with 943 additions and 216 deletions
+4
View File
@@ -30,6 +30,9 @@ Players gain souls for killing other players and lose souls whenever they die, w
| `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. | | `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. |
| `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. | | `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. |
| `/souls tracker give <player> <target>` | Admins / `soulsteal.admin` | Gives a tracker compass to one player that points at another player. | | `/souls tracker give <player> <target>` | Admins / `soulsteal.admin` | Gives a tracker compass to one player that points at another player. |
| `/souls contracts` | All players | Opens the contract browser GUI. |
| `/souls contracts selected` | All players | Shows the currently selected contract and progress. |
| `/souls contracts clear` | All players | Clears your selected contract. |
| `/souls reload` | Admins / `soulsteal.admin` or `soulsteal.admin.reload` | Reloads `config.yml` and `shop.yml` without restarting the server. | | `/souls reload` | Admins / `soulsteal.admin` or `soulsteal.admin.reload` | Reloads `config.yml` and `shop.yml` without restarting the server. |
| `/souls set|add|take <player> <amount>` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. | | `/souls set|add|take <player> <amount>` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. |
@@ -39,4 +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/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. |
+49 -1
View File
@@ -75,9 +75,25 @@ Behavior:
Admins can also grant a tracker compass directly with `/souls tracker give <player> <target>`. This uses the same tracker data and duration as the kill-granted version. Admins can also grant a tracker compass directly with `/souls tracker give <player> <target>`. This uses the same tracker data and duration as the kill-granted version.
### Contracts
Contracts let players earn souls without PvP by completing mining and hunting goals.
Behavior:
- Players pick one active contract at a time.
- Mining contracts track block breaks against a configured block id.
- 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.
- The contract browser uses `catalog.yml`, where the YAML key is the internal contract id and `name` is the player-facing label.
Implemented by: Implemented by:
- [`TrackerCompassService`](../src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java) - [`ContractService`](../src/main/java/com/g2806/soulsteal/service/ContractService.java)
- [`ContractGuiService`](../src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java)
- [`ContractCatalog`](../src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java)
- [`ContractDefinition`](../src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java)
### Shop ### Shop
@@ -161,6 +177,12 @@ The root command has two aliases:
- Shows leaderboard pages. - Shows leaderboard pages.
- `/souls tracker give <player> <target>` - `/souls tracker give <player> <target>`
- Gives a tracker compass to one player that points at another player. - Gives a tracker compass to one player that points at another player.
- `/souls contracts`
- Opens the contract browser GUI.
- `/souls contracts selected`
- Shows the currently selected contract and progress.
- `/souls contracts clear`
- Clears your selected contract.
### Admin Commands ### Admin Commands
@@ -205,6 +227,20 @@ Shop catalog definition. It controls:
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java). The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/catalog.yml`
Contract catalog definition. It controls:
- Contract categories by type
- Internal contract ids from the YAML keys
- Player-facing contract names
- Icon item ids
- Target block or mob ids
- Required amounts and rewards
- Repeatable behavior
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/soulsteal-data.json` ### `config/soulsteal/soulsteal-data.json`
Persistent runtime data. It stores: Persistent runtime data. It stores:
@@ -217,6 +253,8 @@ Persistent runtime data. It stores:
- Permission fallback grants - Permission fallback grants
- Player name history - Player name history
- Scoreboard visibility preferences - Scoreboard visibility preferences
- Selected contracts
- Contract progress
Loaded and saved by [`SoulStealDataStore`](../src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java). Loaded and saved by [`SoulStealDataStore`](../src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java).
@@ -237,6 +275,13 @@ When one player kills another player:
1. The killer receives kill reward souls if enabled. 1. The killer receives kill reward souls if enabled.
2. Any matching bounties on the victim are claimed. 2. Any matching bounties on the victim are claimed.
3. The killer receives a tracker compass if the tracker feature is enabled. 3. The killer receives a tracker compass if the tracker feature is enabled.
4. Any matching hunting contract progresses if the killer has one selected.
### Block Break
When a player breaks a block:
1. Any matching mining contract progresses if the player has one selected.
### Player Death ### Player Death
@@ -263,6 +308,8 @@ On shutdown:
- `src/main/java/com/g2806/soulsteal/` - `src/main/java/com/g2806/soulsteal/`
- Bootstrap, commands, services, config, data, shop, and utilities. - Bootstrap, commands, services, config, data, shop, and utilities.
- `src/main/java/com/g2806/soulsteal/contract/`
- Contract catalog, GUI, and screen handling classes.
- `src/main/resources/` - `src/main/resources/`
- Fabric metadata and resource files. - Fabric metadata and resource files.
- `build.gradle` - `build.gradle`
@@ -274,3 +321,4 @@ On shutdown:
- It is structured as a server-only feature set, so most behavior is driven by server lifecycle events. - It is structured as a server-only feature set, so most behavior is driven by server lifecycle events.
- Persistence is intentionally simple: one JSON data file and YAML-driven configuration. - Persistence is intentionally simple: one JSON data file and YAML-driven configuration.
- External integrations are reflected through reflection-based checks so the mod can run without hard dependencies on permission backends. - External integrations are reflected through reflection-based checks so the mod can run without hard dependencies on permission backends.
- Contract configuration is separated into `catalog.yml` so contract entries can be expanded without changing the main economy config.
@@ -5,6 +5,8 @@ import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig; import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.service.BountyService; import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.contract.ContractGuiService;
import com.g2806.soulsteal.service.HudService; import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.PermissionService; import com.g2806.soulsteal.service.PermissionService;
import com.g2806.soulsteal.service.RewardService; import com.g2806.soulsteal.service.RewardService;
@@ -21,9 +23,11 @@ import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource; import net.minecraft.entity.damage.DamageSource;
import net.minecraft.registry.Registries;
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;
@@ -46,6 +50,8 @@ public final class SoulStealMod implements ModInitializer {
private SoulService soulService; private SoulService soulService;
private PermissionService permissionService; private PermissionService permissionService;
private BountyService bountyService; private BountyService bountyService;
private ContractService contractService;
private ContractGuiService contractGuiService;
private RewardService rewardService; private RewardService rewardService;
private TrackerCompassService trackerCompassService; private TrackerCompassService trackerCompassService;
private ShopService shopService; private ShopService shopService;
@@ -71,10 +77,12 @@ public final class SoulStealMod implements ModInitializer {
permissionService = new PermissionService(dataStore); permissionService = new PermissionService(dataStore);
soulService = new SoulService(this::config, dataStore); soulService = new SoulService(this::config, dataStore);
bountyService = new BountyService(this::config, dataStore, soulService); bountyService = new BountyService(this::config, dataStore, soulService);
contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService);
rewardService = new RewardService(permissionService, soulService); rewardService = new RewardService(permissionService, soulService);
contractGuiService = new ContractGuiService(this::bundle, contractService, rewardService, soulService);
trackerCompassService = new TrackerCompassService(this::config); trackerCompassService = new TrackerCompassService(this::config);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore); shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
hudService = new HudService(this::config, dataStore, soulService, bountyService); hudService = new HudService(this::config, dataStore, soulService, bountyService, contractService);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this)); CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player)); ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
@@ -87,6 +95,16 @@ public final class SoulStealMod implements ModInitializer {
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> { ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (entity instanceof ServerPlayerEntity player) { if (entity instanceof ServerPlayerEntity player) {
onPlayerDeath(player, damageSource); onPlayerDeath(player, damageSource);
return;
}
if (damageSource.getAttacker() instanceof ServerPlayerEntity killer) {
contractService.recordHunting(killer, Registries.ENTITY_TYPE.getId(entity.getType()).toString());
}
});
PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
if (player instanceof ServerPlayerEntity serverPlayer) {
contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
} }
}); });
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick); ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
@@ -105,7 +123,6 @@ public final class SoulStealMod implements ModInitializer {
MinecraftServer server = killer.getCommandSource().getServer(); MinecraftServer server = killer.getCommandSource().getServer();
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false); server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
} }
trackerCompassService.giveTrackerCompass(killer, victim); trackerCompassService.giveTrackerCompass(killer, victim);
} }
@@ -213,6 +230,14 @@ public final class SoulStealMod implements ModInitializer {
return bountyService; return bountyService;
} }
public ContractService contractService() {
return contractService;
}
public ContractGuiService contractGuiService() {
return contractGuiService;
}
/** /**
* Returns the reward service. * Returns the reward service.
* *
@@ -1,6 +1,7 @@
package com.g2806.soulsteal.command; package com.g2806.soulsteal.command;
import com.g2806.soulsteal.SoulStealMod; import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.data.StoredBounty; import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.BountyService; import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService; import com.g2806.soulsteal.service.HudService;
@@ -108,6 +109,13 @@ public final class SoulCommandRegistrar {
.then(argument("player", EntityArgumentType.player()) .then(argument("player", EntityArgumentType.player())
.then(argument("target", EntityArgumentType.player()) .then(argument("target", EntityArgumentType.player())
.executes(context -> giveTrackerCompass(context, mod)))))) .executes(context -> giveTrackerCompass(context, mod))))))
// /soul contracts ...
.then(literal("contracts")
.executes(context -> openContracts(context, mod))
.then(literal("selected")
.executes(context -> showSelectedContract(context, mod)))
.then(literal("clear")
.executes(context -> clearSelectedContract(context, mod))))
// Admin-only maintenance and balance editing commands follow. // Admin-only maintenance and balance editing commands follow.
.then(literal("reload") .then(literal("reload")
.requires(source -> mod.permissionService().hasAny(source, 2, .requires(source -> mod.permissionService().hasAny(source, 2,
@@ -250,6 +258,32 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
private static int openContracts(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
mod.contractGuiService().openContracts(player);
return 1;
}
private static int showSelectedContract(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
java.util.Optional<ContractDefinition> selected = mod.contractService().selectedContract(player.getUuid());
if (selected.isEmpty()) {
context.getSource().sendFeedback(() -> SoulTexts.info("You do not have a selected contract."), false);
return 1;
}
ContractDefinition contract = selected.get();
long progress = mod.contractService().progress(player.getUuid(), contract.id());
context.getSource().sendFeedback(() -> SoulTexts.info("Selected contract: " + contract.name() + " " + progress + "/" + contract.amountRequired() + " souls reward " + contract.reward()), false);
return 1;
}
private static int clearSelectedContract(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
mod.contractService().clearContract(player.getUuid());
context.getSource().sendFeedback(() -> SoulTexts.success("Cleared your selected contract."), false);
return 1;
}
private static int placeBounty(CommandContext<ServerCommandSource> context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException { private static int placeBounty(CommandContext<ServerCommandSource> context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity placer = context.getSource().getPlayerOrThrow(); ServerPlayerEntity placer = context.getSource().getPlayerOrThrow();
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player");
@@ -1,13 +1,14 @@
package com.g2806.soulsteal.config; package com.g2806.soulsteal.config;
import com.g2806.soulsteal.shop.ShopCatalog; import com.g2806.soulsteal.shop.ShopCatalog;
import com.g2806.soulsteal.contract.ContractCatalog;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Map; import java.util.Map;
/** Loads and groups the editable YAML files used by the mod. */ /** Loads and groups the editable YAML files used by the mod. */
public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog) { public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog, ContractCatalog contractCatalog) {
public static ConfigBundle load(Path configDirectory) throws IOException { public static ConfigBundle load(Path configDirectory) throws IOException {
Files.createDirectories(configDirectory); Files.createDirectories(configDirectory);
@@ -17,6 +18,9 @@ public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog) {
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml()); Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop()); ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop());
return new ConfigBundle(config, shopCatalog); Map<String, Object> contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml());
ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts());
return new ConfigBundle(config, shopCatalog, contractCatalog);
} }
} }
@@ -9,6 +9,7 @@ public record SoulStealConfig(
EconomyConfig economy, EconomyConfig economy,
BountyConfig bounty, BountyConfig bounty,
TrackerConfig tracker, TrackerConfig tracker,
ContractConfig contracts,
ShopUiConfig shop, ShopUiConfig shop,
HudConfig hud, HudConfig hud,
PermissionConfig permissions PermissionConfig permissions
@@ -19,6 +20,7 @@ public record SoulStealConfig(
Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer"); Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer");
Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties"); Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties");
Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker"); Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker");
Map<String, Object> contractsSection = YamlConfigHelper.section(root, "contracts");
Map<String, Object> shopSection = YamlConfigHelper.section(root, "shop"); Map<String, Object> shopSection = YamlConfigHelper.section(root, "shop");
Map<String, Object> hudSection = YamlConfigHelper.section(root, "hud"); Map<String, Object> hudSection = YamlConfigHelper.section(root, "hud");
Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard"); Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard");
@@ -69,6 +71,15 @@ public record SoulStealConfig(
YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false) YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false)
); );
ContractConfig contractConfig = new ContractConfig(
YamlConfigHelper.bool(contractsSection, "enabled", true),
YamlConfigHelper.bool(contractsSection, "auto_claim", true),
new ContractHudConfig(
YamlConfigHelper.bool(contractsSection, "hud_enabled", true),
YamlConfigHelper.string(contractsSection, "hud_title", "Active Contract")
)
);
ShopUiConfig shopUiConfig = new ShopUiConfig( ShopUiConfig shopUiConfig = new ShopUiConfig(
YamlConfigHelper.string(shopSection, "title", "Soul Shop"), YamlConfigHelper.string(shopSection, "title", "Soul Shop"),
clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)), clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)),
@@ -106,7 +117,7 @@ public record SoulStealConfig(
YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard") YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard")
); );
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, shopUiConfig, hudConfig, permissionConfig); return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, contractConfig, shopUiConfig, hudConfig, permissionConfig);
} }
public static String defaultYaml() { public static String defaultYaml() {
@@ -142,6 +153,12 @@ public record SoulStealConfig(
update_interval_ticks: 20 update_interval_ticks: 20
expire_if_target_offline: false expire_if_target_offline: false
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
shop: shop:
title: "Soul Shop" title: "Soul Shop"
rows: 3 rows: 3
@@ -219,6 +236,12 @@ public record SoulStealConfig(
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) { public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
} }
public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud) {
}
public record ContractHudConfig(boolean enabled, String title) {
}
public record ShopUiConfig( public record ShopUiConfig(
String title, String title,
int rows, int rows,
@@ -0,0 +1,92 @@
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;
/**
* Parsed contract catalog loaded from `catalog.yml`.
*
* <p>Contracts are grouped by type in the GUI, but remain flat in the config so each entry can be
* addressed by its own id.</p>
*/
public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List<ContractDefinition> contracts) {
public static ContractCatalog fromMap(Map<String, Object> root, ContractConfig config) {
Map<String, Object> contractsSection = YamlConfigHelper.section(root, "contracts");
List<ContractDefinition> contracts = new ArrayList<>();
for (Map.Entry<String, Object> entry : contractsSection.entrySet()) {
if (!(entry.getValue() instanceof Map<?, ?> rawMap)) {
continue;
}
Map<String, Object> map = toStringMap(rawMap);
String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase();
ContractType type;
try {
type = ContractType.valueOf(typeName);
} 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);
}
public Optional<ContractDefinition> contract(String id) {
return contracts.stream().filter(contract -> contract.id().equalsIgnoreCase(id)).findFirst();
}
public List<ContractDefinition> contractsOfType(ContractType type) {
return contracts.stream().filter(contract -> contract.type() == type).toList();
}
public static String defaultYaml() {
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
""";
}
private static Map<String, Object> toStringMap(Map<?, ?> rawMap) {
Map<String, Object> converted = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() != null) {
converted.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return converted;
}
}
@@ -0,0 +1,21 @@
package com.g2806.soulsteal.contract;
/**
* Immutable definition for one contract entry loaded from `catalog.yml`.
*
* <p>The YAML key is the internal id used for selection and persistence, while {@code name} is
* the player-facing label shown in the GUI and HUD.</p>
*/
public record ContractDefinition(
String id,
String name,
String iconItemId,
ContractType type,
String targetId,
String targetName,
String description,
long amountRequired,
long reward,
boolean repeatable
) {
}
@@ -0,0 +1,271 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/**
* Builds the server-side contract browser and dispatches click handling for contract selection.
*
* <p>The contract UI follows the same chest-based presentation style as the shop GUI so players
* can scan categories, read contract details, and select an active objective without client-side
* mods.</p>
*/
public final class ContractGuiService {
private static final int PAGE_ROWS = 6;
private static final int ITEM_SLOT_COUNT = 27;
private final Supplier<ConfigBundle> bundleSupplier;
private final ContractService contractService;
private final RewardService rewardService;
private final SoulService soulService;
public ContractGuiService(Supplier<ConfigBundle> bundleSupplier, ContractService contractService, RewardService rewardService, SoulService soulService) {
this.bundleSupplier = bundleSupplier;
this.contractService = contractService;
this.rewardService = rewardService;
this.soulService = soulService;
}
public void openContracts(ServerPlayerEntity player) {
openHome(player, 0);
}
public void openHome(ServerPlayerEntity player, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new HomeView(page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public void openCategory(ServerPlayerEntity player, String categoryKey, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new CategoryView(categoryKey, page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public SimpleInventory createInventory(ServerPlayerEntity player, View view) {
return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView);
case CategoryView categoryView -> createCategoryInventory(player, categoryView);
};
}
public void handleClick(ServerPlayerEntity player, View view, int slotIndex) {
if (view instanceof HomeView homeView) {
handleHomeClick(player, homeView, slotIndex);
} else if (view instanceof CategoryView categoryView) {
handleCategoryClick(player, categoryView, slotIndex);
}
}
public ContractDefinition selected(ServerPlayerEntity player) {
return contractService.selectedContract(player.getUuid()).orElse(null);
}
public long progress(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return selected == null ? 0L : contractService.progress(player.getUuid(), selected.id());
}
public long progress(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.progress(player.getUuid(), contract.id());
}
public boolean select(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.selectContract(player, contract.id());
}
public void clearSelection(ServerPlayerEntity player) {
contractService.clearContract(player.getUuid());
}
private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> mining = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING), view.page());
List<ContractDefinition> hunting = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING), view.page());
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));
return inventory;
}
private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> contracts = pagedContracts(contractsFor(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));
return inventory;
}
private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) {
if (slotIndex == 11) {
openCategory(player, "mining", 0);
} else if (slotIndex == 15) {
openCategory(player, "hunting", 0);
}
}
private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) {
if (slotIndex < ITEM_SLOT_COUNT) {
List<ContractDefinition> contracts = pagedContracts(contractsFor(view.categoryKey()), view.page());
if (slotIndex >= contracts.size()) {
return;
}
ContractDefinition contract = contracts.get(slotIndex);
if (select(player, contract)) {
player.sendMessage(net.minecraft.text.Text.literal("Selected contract: " + contract.name()).formatted(Formatting.GREEN), false);
}
openCategory(player, view.categoryKey(), view.page());
return;
}
if (slotIndex == ITEM_SLOT_COUNT) {
openContracts(player);
} else if (slotIndex == ITEM_SLOT_COUNT + 1) {
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));
}
}
private ItemStack createContractStack(ServerPlayerEntity player, ContractDefinition contract) {
long progress = progress(player, contract);
List<Text> lore = new ArrayList<>();
if (!contract.description().isBlank()) {
lore.add(Text.literal(contract.description()).formatted(Formatting.GRAY));
}
lore.add(Text.literal("Target: " + contract.targetName()).formatted(Formatting.AQUA));
lore.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GOLD));
lore.add(Text.literal("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN));
lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY));
ContractDefinition selected = selected(player);
if (selected != null && selected.id().equalsIgnoreCase(contract.id())) {
lore.add(Text.literal("Selected").formatted(Formatting.AQUA));
}
return createPreviewStack(contract.iconItemId(), contract.name(), lore);
}
private ItemStack createCategoryButton(String name, String iconItemId, int count, String description) {
return createPreviewStack(iconItemId, name, List.of(
Text.literal(description).formatted(Formatting.GRAY),
Text.literal("Contracts: " + count).formatted(Formatting.AQUA)
));
}
private ItemStack createHomeInfoButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
List<Text> lore = new ArrayList<>();
lore.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
lore.add(Text.literal("Selected: " + (selected == null ? "None" : selected.name())).formatted(Formatting.AQUA));
if (selected != null) {
lore.add(Text.literal("Progress: " + progress(player, selected) + "/" + selected.amountRequired()).formatted(Formatting.GRAY));
}
return createPreviewStack("minecraft:nether_star", "Contract Browser", lore);
}
private ItemStack createCategoryInfoButton(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = contractsFor(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)
));
}
private ItemStack createBackButton() {
return createPreviewStack("minecraft:barrier", "Back", List.of(Text.literal("Return to the contract browser.").formatted(Formatting.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";
return createPreviewStack("minecraft:arrow", label, List.of(
Text.literal("Page " + (page + 1) + " of " + totalPages).formatted(Formatting.GRAY),
Text.literal(available ? "Click to switch pages." : "No more pages in this direction.").formatted(available ? Formatting.AQUA : Formatting.DARK_GRAY)
));
}
private List<ContractDefinition> contractsFor(String categoryKey) {
return switch (categoryKey) {
case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
default -> List.of();
};
}
private List<ContractDefinition> pagedContracts(List<ContractDefinition> contracts, int page) {
int perPage = Math.max(1, ITEM_SLOT_COUNT);
int totalPages = Math.max(1, (int) Math.ceil(contracts.size() / (double) perPage));
int actualPage = Math.max(0, Math.min(totalPages - 1, page));
int from = actualPage * perPage;
int to = Math.min(contracts.size(), from + perPage);
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 String categoryLabel(String categoryKey) {
return switch (categoryKey) {
case "mining" -> "Mining Contracts";
case "hunting" -> "Hunting Contracts";
default -> "Contracts";
};
}
private SimpleInventory filledInventory(int rows) {
SimpleInventory inventory = new SimpleInventory(rows * 9);
ItemStack filler = createPreviewStack(bundleSupplier.get().config().shop().fillerItemId(), " ", List.of());
filler.remove(DataComponentTypes.LORE);
for (int slot = 0; slot < inventory.size(); slot++) {
inventory.setStack(slot, filler.copy());
}
return inventory;
}
private ItemStack createPreviewStack(String itemId, String name, List<Text> lore) {
ItemStack stack = rewardService.createPreviewStack(itemId, name, lore);
return stack;
}
private record HomeView(int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
private record CategoryView(String categoryKey, int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
public sealed interface View permits HomeView, CategoryView {
int rows();
}
}
@@ -0,0 +1,64 @@
package com.g2806.soulsteal.contract;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.screen.ScreenHandler;
import net.minecraft.screen.ScreenHandlerType;
import net.minecraft.screen.slot.Slot;
import net.minecraft.screen.slot.SlotActionType;
import net.minecraft.server.network.ServerPlayerEntity;
/**
* Generic container handler that turns contract GUI clicks into selection actions.
*/
public final class ContractScreenHandler extends ScreenHandler {
private final SimpleInventory inventory;
private final ContractGuiService guiService;
private final ContractGuiService.View view;
public ContractScreenHandler(int syncId, PlayerInventory playerInventory, ContractGuiService guiService, ContractGuiService.View view) {
super(view.rows() == 3 ? ScreenHandlerType.GENERIC_9X3 : ScreenHandlerType.GENERIC_9X6, syncId);
this.guiService = guiService;
this.view = view;
this.inventory = guiService.createInventory((ServerPlayerEntity) playerInventory.player, view);
int rows = view.rows();
for (int row = 0; row < rows; row++) {
for (int column = 0; column < 9; column++) {
int slotIndex = row * 9 + column;
this.addSlot(new Slot(inventory, slotIndex, 8 + column * 18, 18 + row * 18));
}
}
int playerInventoryY = 18 + rows * 18 + 14;
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column + row * 9 + 9, 8 + column * 18, playerInventoryY + row * 18));
}
}
int hotbarY = playerInventoryY + 58;
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column, 8 + column * 18, hotbarY));
}
}
@Override
public ItemStack quickMove(PlayerEntity player, int slot) {
return ItemStack.EMPTY;
}
@Override
public boolean canUse(PlayerEntity player) {
return true;
}
@Override
public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) {
if (player instanceof ServerPlayerEntity serverPlayer && slotIndex >= 0 && slotIndex < inventory.size()) {
guiService.handleClick(serverPlayer, view, slotIndex);
}
}
}
@@ -0,0 +1,6 @@
package com.g2806.soulsteal.contract;
public enum ContractType {
MINING,
HUNTING
}
@@ -22,6 +22,8 @@ public final class SoulStealData {
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>(); private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>(); private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = new HashMap<>(); private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
private Map<String, String> selectedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractProgress = new HashMap<>();
public SoulStealData normalize() { public SoulStealData normalize() {
if (souls == null) { if (souls == null) {
@@ -48,6 +50,12 @@ public final class SoulStealData {
if (scoreboardVisibility == null) { if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>(); scoreboardVisibility = new HashMap<>();
} }
if (selectedContracts == null) {
selectedContracts = new HashMap<>();
}
if (contractProgress == null) {
contractProgress = new HashMap<>();
}
return this; return this;
} }
@@ -122,4 +130,12 @@ public final class SoulStealData {
public Map<String, Boolean> scoreboardVisibility() { public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility; return scoreboardVisibility;
} }
public Map<String, String> selectedContracts() {
return selectedContracts;
}
public Map<String, Map<String, Long>> contractProgress() {
return contractProgress;
}
} }
@@ -0,0 +1,109 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.contract.ContractCatalog;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.contract.ContractType;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity;
public final class ContractService {
private final Supplier<ContractCatalog> catalogSupplier;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
public ContractService(Supplier<ContractCatalog> catalogSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.catalogSupplier = catalogSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
}
public Optional<ContractDefinition> selectedContract(UUID playerUuid) {
String contractId = dataStore.data().selectedContracts().get(key(playerUuid));
if (contractId == null) {
return Optional.empty();
}
return catalogSupplier.get().contract(contractId);
}
public boolean selectContract(ServerPlayerEntity player, String contractId) {
Optional<ContractDefinition> contract = catalogSupplier.get().contract(contractId);
if (contract.isEmpty()) {
return false;
}
dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id());
dataStore.data().contractProgress().remove(key(player.getUuid()));
saveQuietly();
return true;
}
public void clearContract(UUID playerUuid) {
dataStore.data().selectedContracts().remove(key(playerUuid));
dataStore.data().contractProgress().remove(key(playerUuid));
saveQuietly();
}
public long progress(UUID playerUuid) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.values().stream().mapToLong(Long::longValue).sum();
}
public long progress(UUID playerUuid, String contractId) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.getOrDefault(contractId, 0L);
}
public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId);
}
public void recordHunting(ServerPlayerEntity player, String entityId) {
record(player, ContractType.HUNTING, entityId);
}
private void record(ServerPlayerEntity player, ContractType type, String targetId) {
Optional<ContractDefinition> selected = selectedContract(player.getUuid());
if (selected.isEmpty() || !catalogSupplier.get().enabled()) {
return;
}
ContractDefinition contract = selected.get();
if (contract.type() != type || !contract.targetId().equalsIgnoreCase(targetId)) {
return;
}
String playerKey = key(player.getUuid());
Map<String, Long> progressMap = dataStore.data().contractProgress().computeIfAbsent(playerKey, ignored -> new java.util.HashMap<>());
long updated = progressMap.getOrDefault(contract.id(), 0L) + 1L;
if (updated >= contract.amountRequired()) {
progressMap.remove(contract.id());
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().selectedContracts().remove(playerKey);
}
} else {
progressMap.put(contract.id(), updated);
}
saveQuietly();
}
private void saveQuietly() {
try {
dataStore.save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist contract data.", exception);
}
}
private static String key(UUID playerUuid) {
return playerUuid.toString();
}
}
@@ -3,6 +3,7 @@ package com.g2806.soulsteal.service;
import com.g2806.soulsteal.config.SoulStealConfig; import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty; import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.util.DurationFormatter; import com.g2806.soulsteal.util.DurationFormatter;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
@@ -38,6 +39,7 @@ public final class HudService {
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
private final SoulService soulService; private final SoulService soulService;
private final BountyService bountyService; private final BountyService bountyService;
private final ContractService contractService;
private final Map<UUID, SidebarState> sidebars = new HashMap<>(); private final Map<UUID, SidebarState> sidebars = new HashMap<>();
private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>(); private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>();
@@ -45,12 +47,14 @@ public final class HudService {
Supplier<SoulStealConfig> configSupplier, Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore, SoulStealDataStore dataStore,
SoulService soulService, SoulService soulService,
BountyService bountyService BountyService bountyService,
ContractService contractService
) { ) {
this.configSupplier = configSupplier; this.configSupplier = configSupplier;
this.dataStore = dataStore; this.dataStore = dataStore;
this.soulService = soulService; this.soulService = soulService;
this.bountyService = bountyService; this.bountyService = bountyService;
this.contractService = contractService;
} }
/** /**
@@ -246,6 +250,12 @@ public final class HudService {
return List.of( return List.of(
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())), 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("Bounties: " + activeBounties.size()),
Text.literal("Wanted Value: " + totalValue), Text.literal("Wanted Value: " + totalValue),
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None")) Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None"))