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 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. |
@@ -37,6 +40,7 @@ Players gain souls for killing other players and lose souls whenever they die, w
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
| `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.
@@ -1,147 +1,164 @@
package com.g2806.soulsteal; package com.g2806.soulsteal;
import com.g2806.soulsteal.command.SoulCommandRegistrar; import com.g2806.soulsteal.command.SoulCommandRegistrar;
import com.g2806.soulsteal.config.ConfigBundle; 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.HudService; import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.service.PermissionService; import com.g2806.soulsteal.contract.ContractGuiService;
import com.g2806.soulsteal.service.RewardService; import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.ShopService; import com.g2806.soulsteal.service.PermissionService;
import com.g2806.soulsteal.service.SoulService; import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.TrackerCompassService; import com.g2806.soulsteal.service.ShopService;
import com.g2806.soulsteal.util.SoulTexts; import com.g2806.soulsteal.service.SoulService;
import java.io.IOException; import com.g2806.soulsteal.service.TrackerCompassService;
import java.io.UncheckedIOException; import com.g2806.soulsteal.util.SoulTexts;
import java.nio.file.Path; import java.io.IOException;
import net.fabricmc.api.ModInitializer; import java.io.UncheckedIOException;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import java.nio.file.Path;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents; import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.entity.damage.DamageSource; import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.minecraft.server.MinecraftServer; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.network.ServerPlayerEntity; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.text.Text; import net.minecraft.entity.damage.DamageSource;
import org.slf4j.Logger; import net.minecraft.registry.Registries;
import org.slf4j.LoggerFactory; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
/** import net.minecraft.text.Text;
* Entrypoint for the Soul Steal mod. import org.slf4j.Logger;
* import org.slf4j.LoggerFactory;
* <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> /**
*/ * Entrypoint for the Soul Steal mod.
public final class SoulStealMod implements ModInitializer { *
public static final String MOD_ID = "soulsteal"; * <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); * single bootstrap location for lifecycle setup and shared constants.</p>
*/
private Path configDirectory; public final class SoulStealMod implements ModInitializer {
private ConfigBundle configBundle; public static final String MOD_ID = "soulsteal";
private SoulStealDataStore dataStore; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private SoulService soulService;
private PermissionService permissionService; private Path configDirectory;
private BountyService bountyService; private ConfigBundle configBundle;
private RewardService rewardService; private SoulStealDataStore dataStore;
private TrackerCompassService trackerCompassService; private SoulService soulService;
private ShopService shopService; private PermissionService permissionService;
private HudService hudService; 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 * Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers. * event handlers.
*/ */
@Override @Override
public void onInitialize() { public void onInitialize() {
LOGGER.info("Initializing Soul Steal"); LOGGER.info("Initializing Soul Steal");
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID); configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
try { try {
configBundle = ConfigBundle.load(configDirectory); configBundle = ConfigBundle.load(configDirectory);
dataStore = new SoulStealDataStore(configDirectory); dataStore = new SoulStealDataStore(configDirectory);
dataStore.load(); dataStore.load();
} catch (IOException exception) { } catch (IOException exception) {
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception); throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
} }
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);
rewardService = new RewardService(permissionService, soulService); contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService);
trackerCompassService = new TrackerCompassService(this::config); rewardService = new RewardService(permissionService, soulService);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore); contractGuiService = new ContractGuiService(this::bundle, contractService, rewardService, soulService);
hudService = new HudService(this::config, dataStore, soulService, bountyService); trackerCompassService = new TrackerCompassService(this::config);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this)); hudService = new HudService(this::config, dataStore, soulService, bountyService, contractService);
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player)); CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> { ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) { ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player));
onPlayerKilledOtherPlayer(killer, victim); 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); ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
} if (entity instanceof ServerPlayerEntity player) {
}); onPlayerDeath(player, damageSource);
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick); return;
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData()); }
}
if (damageSource.getAttacker() instanceof ServerPlayerEntity killer) {
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) { contractService.recordHunting(killer, Registries.ENTITY_TYPE.getId(entity.getType()).toString());
long reward = config().economy().killReward(); }
if (reward > 0L) { });
soulService.addSouls(killer.getUuid(), reward); PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false); if (player instanceof ServerPlayerEntity serverPlayer) {
} contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
}
BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid()); });
if (bountyClaim.claimedAny()) { ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
MinecraftServer server = killer.getCommandSource().getServer(); ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData());
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false); }
}
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) {
trackerCompassService.giveTrackerCompass(killer, victim); long reward = config().economy().killReward();
} if (reward > 0L) {
soulService.addSouls(killer.getUuid(), reward);
private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) { killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false);
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); BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid());
} if (bountyClaim.claimedAny()) {
MinecraftServer server = killer.getCommandSource().getServer();
if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) { server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid()); }
if (!removedBounties.isEmpty()) { trackerCompassService.giveTrackerCompass(killer, victim);
player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false); }
}
} private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) {
} SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid());
if (penalty.delta() < 0L) {
private void onServerTick(MinecraftServer server) { player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false);
trackerCompassService.tick(server); }
if (server.getTicks() % 20 != 0) {
return; if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) {
} java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid());
if (!removedBounties.isEmpty()) {
long now = System.currentTimeMillis(); player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false);
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);
} private void onServerTick(MinecraftServer server) {
trackerCompassService.tick(server);
if (payout.reward() > 0L) { if (server.getTicks() % 20 != 0) {
server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false); return;
} }
}
long now = System.currentTimeMillis();
hudService.tick(server, now); 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 * loaded
*/ */
public boolean reloadConfiguration() { public boolean reloadConfiguration() {
try { try {
configBundle = ConfigBundle.load(configDirectory); configBundle = ConfigBundle.load(configDirectory);
return true; return true;
} catch (IOException exception) { } catch (IOException exception) {
LOGGER.error("Failed to reload Soul Steal configuration.", exception); LOGGER.error("Failed to reload Soul Steal configuration.", exception);
return false; return false;
} }
} }
private void saveData() { private void saveData() {
try { try {
dataStore.save(); dataStore.save();
} catch (IOException exception) { } catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception); LOGGER.error("Failed to save Soul Steal data.", exception);
} }
} }
/** /**
@@ -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,7 +1,8 @@
package com.g2806.soulsteal.command; package com.g2806.soulsteal.command;
import com.g2806.soulsteal.SoulStealMod; import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.data.StoredBounty; import com.g2806.soulsteal.contract.ContractDefinition;
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;
import com.g2806.soulsteal.service.SoulService; import com.g2806.soulsteal.service.SoulService;
@@ -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,22 +1,26 @@
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);
Map<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml()); Map<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml());
SoulStealConfig config = SoulStealConfig.fromMap(configMap); SoulStealConfig config = SoulStealConfig.fromMap(configMap);
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);
}
}
@@ -5,21 +5,23 @@ import java.util.Map;
/** /**
* Main configuration tree for the mod's economy, bounty, tracker, and permission settings. * Main configuration tree for the mod's economy, bounty, tracker, and permission settings.
*/ */
public record SoulStealConfig( public record SoulStealConfig(
EconomyConfig economy, EconomyConfig economy,
BountyConfig bounty, BountyConfig bounty,
TrackerConfig tracker, TrackerConfig tracker,
ShopUiConfig shop, ContractConfig contracts,
HudConfig hud, ShopUiConfig shop,
PermissionConfig permissions HudConfig hud,
) { PermissionConfig permissions
) {
public static SoulStealConfig fromMap(Map<String, Object> root) { public static SoulStealConfig fromMap(Map<String, Object> root) {
Map<String, Object> economySection = YamlConfigHelper.section(root, "economy"); Map<String, Object> economySection = YamlConfigHelper.section(root, "economy");
Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty"); Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty");
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> shopSection = YamlConfigHelper.section(root, "shop"); 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> hudSection = YamlConfigHelper.section(root, "hud");
Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard"); Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard");
Map<String, Object> bossbarSection = YamlConfigHelper.section(hudSection, "bounty_bossbar"); Map<String, Object> bossbarSection = YamlConfigHelper.section(hudSection, "bounty_bossbar");
@@ -62,12 +64,21 @@ public record SoulStealConfig(
bountyConfig = bountyConfig.withMaxDurationSeconds(bountyConfig.minDurationSeconds()); bountyConfig = bountyConfig.withMaxDurationSeconds(bountyConfig.minDurationSeconds());
} }
TrackerConfig trackerConfig = new TrackerConfig( TrackerConfig trackerConfig = new TrackerConfig(
YamlConfigHelper.bool(trackerSection, "enabled", true), YamlConfigHelper.bool(trackerSection, "enabled", true),
Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)), Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)),
Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)), Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)),
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"),
@@ -106,8 +117,8 @@ 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() {
return """ return """
@@ -136,13 +147,19 @@ public record SoulStealConfig(
max_active_per_target: 5 max_active_per_target: 5
max_active_per_placer: 3 max_active_per_placer: 3
tracker: tracker:
enabled: true enabled: true
duration_seconds: 900 duration_seconds: 900
update_interval_ticks: 20 update_interval_ticks: 20
expire_if_target_offline: false expire_if_target_offline: false
shop: contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
shop:
title: "Soul Shop" title: "Soul Shop"
rows: 3 rows: 3
filler_item: "minecraft:black_stained_glass_pane" 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( public record ShopUiConfig(
String title, String title,
@@ -254,4 +277,4 @@ public record SoulStealConfig(
String leaderboardNode 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, Set<String>> unlockedEntries = new HashMap<>();
private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>(); private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>();
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>(); private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
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) {
@@ -45,9 +47,15 @@ public final class SoulStealData {
if (playerNames == null) { if (playerNames == null) {
playerNames = new HashMap<>(); playerNames = new HashMap<>();
} }
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;
} }
@@ -120,6 +128,14 @@ public final class SoulStealData {
* @return mutable scoreboard visibility map * @return mutable scoreboard visibility map
*/ */
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();
}
}
@@ -1,9 +1,10 @@
package com.g2806.soulsteal.service; 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.util.DurationFormatter; import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.util.ArrayList; 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. */ /** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */
public final class HudService { public final class HudService {
private final Supplier<SoulStealConfig> configSupplier; private final Supplier<SoulStealConfig> configSupplier;
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;
} }
/** /**
@@ -244,12 +248,18 @@ public final class HudService {
.orElse(nowEpochMillis); .orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L); remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
return List.of( return List.of(
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())), Text.literal("Souls: " + soulService.balanceOf(player.getUuid())),
Text.literal("Bounties: " + activeBounties.size()), Text.literal(contractService.selectedContract(player.getUuid())
Text.literal("Wanted Value: " + totalValue), .map(contract -> "Contract: " + contract.name())
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None")) .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) { private void clearSidebar(ServerPlayerEntity player) {