diff --git a/README.md b/README.md index 6f6b78a..048ed83 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 ` | 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. | diff --git a/docs/PROJECT.md b/docs/PROJECT.md index cb9a663..72980b8 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -75,9 +75,25 @@ Behavior: Admins can also grant a tracker compass directly with `/souls tracker give `. 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 ` - 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. diff --git a/src/main/java/com/g2806/soulsteal/SoulStealMod.java b/src/main/java/com/g2806/soulsteal/SoulStealMod.java index 10e2e79..3f30a11 100644 --- a/src/main/java/com/g2806/soulsteal/SoulStealMod.java +++ b/src/main/java/com/g2806/soulsteal/SoulStealMod.java @@ -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. - * - *

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.

- */ -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. + * + *

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.

+ */ +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 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 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. * diff --git a/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java b/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java index 95c9ded..2ef7442 100644 --- a/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java +++ b/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java @@ -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 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 context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + java.util.Optional 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 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 context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException { ServerPlayerEntity placer = context.getSource().getPlayerOrThrow(); ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); diff --git a/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java b/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java index f403cb9..f6d78b4 100644 --- a/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java +++ b/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java @@ -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 configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml()); SoulStealConfig config = SoulStealConfig.fromMap(configMap); - Map shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml()); - ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop()); - - return new ConfigBundle(config, shopCatalog); - } -} \ No newline at end of file + Map shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml()); + ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop()); + + Map contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml()); + ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts()); + + return new ConfigBundle(config, shopCatalog, contractCatalog); + } +} diff --git a/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java b/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java index 5658d43..e7393a6 100644 --- a/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java +++ b/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java @@ -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 root) { Map economySection = YamlConfigHelper.section(root, "economy"); Map deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty"); Map transferSection = YamlConfigHelper.section(economySection, "transfer"); Map bountySection = YamlConfigHelper.section(root, "bounties"); - Map trackerSection = YamlConfigHelper.section(root, "tracker"); - Map shopSection = YamlConfigHelper.section(root, "shop"); + Map trackerSection = YamlConfigHelper.section(root, "tracker"); + Map contractsSection = YamlConfigHelper.section(root, "contracts"); + Map shopSection = YamlConfigHelper.section(root, "shop"); Map hudSection = YamlConfigHelper.section(root, "hud"); Map scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard"); Map 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 ) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java b/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java new file mode 100644 index 0000000..e42f5ab --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java @@ -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`. + * + *

Contracts are grouped by type in the GUI, but remain flat in the config so each entry can be + * addressed by its own id.

+ */ +public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List contracts) { + public static ContractCatalog fromMap(Map root, ContractConfig config) { + Map contractsSection = YamlConfigHelper.section(root, "contracts"); + List contracts = new ArrayList<>(); + for (Map.Entry entry : contractsSection.entrySet()) { + if (!(entry.getValue() instanceof Map rawMap)) { + continue; + } + Map 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 contract(String id) { + return contracts.stream().filter(contract -> contract.id().equalsIgnoreCase(id)).findFirst(); + } + + public List 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 toStringMap(Map rawMap) { + Map converted = new LinkedHashMap<>(); + for (Map.Entry entry : rawMap.entrySet()) { + if (entry.getKey() != null) { + converted.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return converted; + } +} diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java b/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java new file mode 100644 index 0000000..26d1d5d --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java @@ -0,0 +1,21 @@ +package com.g2806.soulsteal.contract; + +/** + * Immutable definition for one contract entry loaded from `catalog.yml`. + * + *

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.

+ */ +public record ContractDefinition( + String id, + String name, + String iconItemId, + ContractType type, + String targetId, + String targetName, + String description, + long amountRequired, + long reward, + boolean repeatable +) { +} diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java b/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java new file mode 100644 index 0000000..faff74a --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java @@ -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. + * + *

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.

+ */ +public final class ContractGuiService { + private static final int PAGE_ROWS = 6; + private static final int ITEM_SLOT_COUNT = 27; + private final Supplier bundleSupplier; + private final ContractService contractService; + private final RewardService rewardService; + private final SoulService soulService; + + public ContractGuiService(Supplier 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 mining = pagedContracts(bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING), view.page()); + List 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 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 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 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 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 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 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 pagedContracts(List 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 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(); + } +} diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractScreenHandler.java b/src/main/java/com/g2806/soulsteal/contract/ContractScreenHandler.java new file mode 100644 index 0000000..eb35870 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/contract/ContractScreenHandler.java @@ -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); + } + } +} diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractType.java b/src/main/java/com/g2806/soulsteal/contract/ContractType.java new file mode 100644 index 0000000..0963012 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/contract/ContractType.java @@ -0,0 +1,6 @@ +package com.g2806.soulsteal.contract; + +public enum ContractType { + MINING, + HUNTING +} diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java index 9278fb7..5aa655d 100644 --- a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java @@ -19,9 +19,11 @@ public final class SoulStealData { private Map> unlockedEntries = new HashMap<>(); private Map> purchaseCooldowns = new HashMap<>(); private Map> grantedPermissions = new HashMap<>(); - private Map bountyPlacementCooldowns = new HashMap<>(); - private Map playerNames = new HashMap<>(); - private Map scoreboardVisibility = new HashMap<>(); + private Map bountyPlacementCooldowns = new HashMap<>(); + private Map playerNames = new HashMap<>(); + private Map scoreboardVisibility = new HashMap<>(); + private Map selectedContracts = new HashMap<>(); + private Map> 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 scoreboardVisibility() { - return scoreboardVisibility; - } + return scoreboardVisibility; + } + + public Map selectedContracts() { + return selectedContracts; + } + + public Map> contractProgress() { + return contractProgress; + } } diff --git a/src/main/java/com/g2806/soulsteal/service/ContractService.java b/src/main/java/com/g2806/soulsteal/service/ContractService.java new file mode 100644 index 0000000..4789274 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/ContractService.java @@ -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 catalogSupplier; + private final SoulStealDataStore dataStore; + private final SoulService soulService; + + public ContractService(Supplier catalogSupplier, SoulStealDataStore dataStore, SoulService soulService) { + this.catalogSupplier = catalogSupplier; + this.dataStore = dataStore; + this.soulService = soulService; + } + + public Optional 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 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 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 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(); + } +} diff --git a/src/main/java/com/g2806/soulsteal/service/HudService.java b/src/main/java/com/g2806/soulsteal/service/HudService.java index c722730..fd37325 100644 --- a/src/main/java/com/g2806/soulsteal/service/HudService.java +++ b/src/main/java/com/g2806/soulsteal/service/HudService.java @@ -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 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 sidebars = new HashMap<>(); private final Map bountyBossBars = new HashMap<>(); @@ -45,12 +47,14 @@ public final class HudService { Supplier 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) {