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
+6 -2
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 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 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 set|add|take <player> <amount>` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. |
@@ -37,6 +40,7 @@ Players gain souls for killing other players and lose souls whenever they die, w
| File | Purpose |
| --- | --- |
| `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/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/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.
### 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:
- [`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
@@ -161,6 +177,12 @@ The root command has two aliases:
- Shows leaderboard pages.
- `/souls tracker give <player> <target>`
- 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
@@ -205,6 +227,20 @@ Shop catalog definition. It controls:
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`
Persistent runtime data. It stores:
@@ -217,6 +253,8 @@ Persistent runtime data. It stores:
- Permission fallback grants
- Player name history
- Scoreboard visibility preferences
- Selected contracts
- Contract progress
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.
2. Any matching bounties on the victim are claimed.
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
@@ -263,6 +308,8 @@ On shutdown:
- `src/main/java/com/g2806/soulsteal/`
- 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/`
- Fabric metadata and resource files.
- `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.
- 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.
- Contract configuration is separated into `catalog.yml` so contract entries can be expanded without changing the main economy config.
@@ -1,147 +1,164 @@
package com.g2806.soulsteal;
import com.g2806.soulsteal.command.SoulCommandRegistrar;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.PermissionService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.ShopService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.service.TrackerCompassService;
import com.g2806.soulsteal.util.SoulTexts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
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.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Entrypoint for the Soul Steal mod.
*
* <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the
* single bootstrap location for lifecycle setup and shared constants.</p>
*/
public final class SoulStealMod implements ModInitializer {
public static final String MOD_ID = "soulsteal";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private Path configDirectory;
private ConfigBundle configBundle;
private SoulStealDataStore dataStore;
private SoulService soulService;
private PermissionService permissionService;
private BountyService bountyService;
private RewardService rewardService;
private TrackerCompassService trackerCompassService;
private ShopService shopService;
private HudService hudService;
package com.g2806.soulsteal;
import com.g2806.soulsteal.command.SoulCommandRegistrar;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
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.PermissionService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.ShopService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.service.TrackerCompassService;
import com.g2806.soulsteal.util.SoulTexts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
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.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource;
import net.minecraft.registry.Registries;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Entrypoint for the Soul Steal mod.
*
* <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the
* single bootstrap location for lifecycle setup and shared constants.</p>
*/
public final class SoulStealMod implements ModInitializer {
public static final String MOD_ID = "soulsteal";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private Path configDirectory;
private ConfigBundle configBundle;
private SoulStealDataStore dataStore;
private SoulService soulService;
private PermissionService permissionService;
private BountyService bountyService;
private ContractService contractService;
private ContractGuiService contractGuiService;
private RewardService rewardService;
private TrackerCompassService trackerCompassService;
private ShopService shopService;
private HudService hudService;
/**
* Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers.
*/
@Override
public void onInitialize() {
LOGGER.info("Initializing Soul Steal");
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
try {
configBundle = ConfigBundle.load(configDirectory);
dataStore = new SoulStealDataStore(configDirectory);
dataStore.load();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
}
permissionService = new PermissionService(dataStore);
soulService = new SoulService(this::config, dataStore);
bountyService = new BountyService(this::config, dataStore, soulService);
rewardService = new RewardService(permissionService, soulService);
trackerCompassService = new TrackerCompassService(this::config);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
hudService = new HudService(this::config, dataStore, soulService, bountyService);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player));
ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> {
if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) {
onPlayerKilledOtherPlayer(killer, victim);
}
});
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (entity instanceof ServerPlayerEntity player) {
onPlayerDeath(player, damageSource);
}
});
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData());
}
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) {
long reward = config().economy().killReward();
if (reward > 0L) {
soulService.addSouls(killer.getUuid(), reward);
killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false);
}
BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid());
if (bountyClaim.claimedAny()) {
MinecraftServer server = killer.getCommandSource().getServer();
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
}
trackerCompassService.giveTrackerCompass(killer, victim);
}
private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) {
SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid());
if (penalty.delta() < 0L) {
player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false);
}
if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) {
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid());
if (!removedBounties.isEmpty()) {
player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false);
}
}
}
private void onServerTick(MinecraftServer server) {
trackerCompassService.tick(server);
if (server.getTicks() % 20 != 0) {
return;
}
long now = System.currentTimeMillis();
for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) {
ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid());
if (target != null && payout.reward() > 0L) {
target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false);
}
if (payout.reward() > 0L) {
server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false);
}
}
hudService.tick(server, now);
LOGGER.info("Initializing Soul Steal");
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
try {
configBundle = ConfigBundle.load(configDirectory);
dataStore = new SoulStealDataStore(configDirectory);
dataStore.load();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
}
permissionService = new PermissionService(dataStore);
soulService = new SoulService(this::config, dataStore);
bountyService = new BountyService(this::config, dataStore, soulService);
contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService);
rewardService = new RewardService(permissionService, soulService);
contractGuiService = new ContractGuiService(this::bundle, contractService, rewardService, soulService);
trackerCompassService = new TrackerCompassService(this::config);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
hudService = new HudService(this::config, dataStore, soulService, bountyService, contractService);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player));
ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> {
if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) {
onPlayerKilledOtherPlayer(killer, victim);
}
});
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (entity instanceof ServerPlayerEntity player) {
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);
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData());
}
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) {
long reward = config().economy().killReward();
if (reward > 0L) {
soulService.addSouls(killer.getUuid(), reward);
killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false);
}
BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid());
if (bountyClaim.claimedAny()) {
MinecraftServer server = killer.getCommandSource().getServer();
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
}
trackerCompassService.giveTrackerCompass(killer, victim);
}
private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) {
SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid());
if (penalty.delta() < 0L) {
player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false);
}
if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) {
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid());
if (!removedBounties.isEmpty()) {
player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false);
}
}
}
private void onServerTick(MinecraftServer server) {
trackerCompassService.tick(server);
if (server.getTicks() % 20 != 0) {
return;
}
long now = System.currentTimeMillis();
for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) {
ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid());
if (target != null && payout.reward() > 0L) {
target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false);
}
if (payout.reward() > 0L) {
server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false);
}
}
hudService.tick(server, now);
}
/**
@@ -151,21 +168,21 @@ public final class SoulStealMod implements ModInitializer {
* loaded
*/
public boolean reloadConfiguration() {
try {
configBundle = ConfigBundle.load(configDirectory);
return true;
} catch (IOException exception) {
LOGGER.error("Failed to reload Soul Steal configuration.", exception);
return false;
}
}
private void saveData() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
try {
configBundle = ConfigBundle.load(configDirectory);
return true;
} catch (IOException exception) {
LOGGER.error("Failed to reload Soul Steal configuration.", exception);
return false;
}
}
private void saveData() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
}
/**
@@ -213,6 +230,14 @@ public final class SoulStealMod implements ModInitializer {
return bountyService;
}
public ContractService contractService() {
return contractService;
}
public ContractGuiService contractGuiService() {
return contractGuiService;
}
/**
* Returns the reward service.
*
@@ -1,7 +1,8 @@
package com.g2806.soulsteal.command;
import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.SoulService;
@@ -108,6 +109,13 @@ public final class SoulCommandRegistrar {
.then(argument("player", EntityArgumentType.player())
.then(argument("target", EntityArgumentType.player())
.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.
.then(literal("reload")
.requires(source -> mod.permissionService().hasAny(source, 2,
@@ -250,6 +258,32 @@ public final class SoulCommandRegistrar {
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 {
ServerPlayerEntity placer = context.getSource().getPlayerOrThrow();
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player");
@@ -1,22 +1,26 @@
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.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/** 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 {
Files.createDirectories(configDirectory);
Map<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml());
SoulStealConfig config = SoulStealConfig.fromMap(configMap);
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop());
return new ConfigBundle(config, shopCatalog);
}
}
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop());
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);
}
}
@@ -5,21 +5,23 @@ import java.util.Map;
/**
* Main configuration tree for the mod's economy, bounty, tracker, and permission settings.
*/
public record SoulStealConfig(
EconomyConfig economy,
BountyConfig bounty,
TrackerConfig tracker,
ShopUiConfig shop,
HudConfig hud,
PermissionConfig permissions
) {
public record SoulStealConfig(
EconomyConfig economy,
BountyConfig bounty,
TrackerConfig tracker,
ContractConfig contracts,
ShopUiConfig shop,
HudConfig hud,
PermissionConfig permissions
) {
public static SoulStealConfig fromMap(Map<String, Object> root) {
Map<String, Object> economySection = YamlConfigHelper.section(root, "economy");
Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty");
Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer");
Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties");
Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker");
Map<String, Object> shopSection = YamlConfigHelper.section(root, "shop");
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> hudSection = YamlConfigHelper.section(root, "hud");
Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard");
Map<String, Object> bossbarSection = YamlConfigHelper.section(hudSection, "bounty_bossbar");
@@ -62,12 +64,21 @@ public record SoulStealConfig(
bountyConfig = bountyConfig.withMaxDurationSeconds(bountyConfig.minDurationSeconds());
}
TrackerConfig trackerConfig = new TrackerConfig(
YamlConfigHelper.bool(trackerSection, "enabled", true),
Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)),
Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)),
YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false)
);
TrackerConfig trackerConfig = new TrackerConfig(
YamlConfigHelper.bool(trackerSection, "enabled", true),
Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)),
Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)),
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(
YamlConfigHelper.string(shopSection, "title", "Soul Shop"),
@@ -106,8 +117,8 @@ public record SoulStealConfig(
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() {
return """
@@ -136,13 +147,19 @@ public record SoulStealConfig(
max_active_per_target: 5
max_active_per_placer: 3
tracker:
enabled: true
duration_seconds: 900
update_interval_ticks: 20
expire_if_target_offline: false
shop:
tracker:
enabled: true
duration_seconds: 900
update_interval_ticks: 20
expire_if_target_offline: false
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
shop:
title: "Soul Shop"
rows: 3
filler_item: "minecraft:black_stained_glass_pane"
@@ -216,8 +233,14 @@ 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(
String title,
@@ -254,4 +277,4 @@ public record SoulStealConfig(
String leaderboardNode
) {
}
}
}
@@ -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
}
@@ -19,9 +19,11 @@ public final class SoulStealData {
private Map<String, Set<String>> unlockedEntries = new HashMap<>();
private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>();
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = 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() {
if (souls == null) {
@@ -45,9 +47,15 @@ public final class SoulStealData {
if (playerNames == null) {
playerNames = new HashMap<>();
}
if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>();
}
if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>();
}
if (selectedContracts == null) {
selectedContracts = new HashMap<>();
}
if (contractProgress == null) {
contractProgress = new HashMap<>();
}
return this;
}
@@ -120,6 +128,14 @@ public final class SoulStealData {
* @return mutable scoreboard visibility map
*/
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();
}
}
@@ -1,9 +1,10 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.util.DurationFormatter;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
@@ -35,9 +36,10 @@ import net.minecraft.text.Text;
/** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */
public final class HudService {
private final Supplier<SoulStealConfig> configSupplier;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
private final BountyService bountyService;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
private final BountyService bountyService;
private final ContractService contractService;
private final Map<UUID, SidebarState> sidebars = new HashMap<>();
private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>();
@@ -45,12 +47,14 @@ public final class HudService {
Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore,
SoulService soulService,
BountyService bountyService
BountyService bountyService,
ContractService contractService
) {
this.configSupplier = configSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
this.bountyService = bountyService;
this.contractService = contractService;
}
/**
@@ -244,12 +248,18 @@ public final class HudService {
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
return List.of(
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())),
Text.literal("Bounties: " + activeBounties.size()),
Text.literal("Wanted Value: " + totalValue),
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None"))
);
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 void clearSidebar(ServerPlayerEntity player) {