diff --git a/build.gradle b/build.gradle index 2fa59d8..7e11c4c 100644 --- a/build.gradle +++ b/build.gradle @@ -19,17 +19,16 @@ sourceSets.main.resources { } repositories { - // FTB Library / FTB Chunks / FTB Quests live here. Used compileOnly - // when we wire screens to the FTB UI framework (M1+). maven { name = 'FTB' url = 'https://maven.ftb.dev/releases' content { includeGroup 'dev.ftb.mods' } } - // Numismatics + Create dependencies live on Modrinth's maven mirror. + // Architectury (transitive of FTB Library) maven { - name = 'ModrinthMaven' - url = 'https://api.modrinth.com/maven' + name = 'Architectury' + url = 'https://maven.architectury.dev/' + content { includeGroup 'dev.architectury' } } } @@ -82,12 +81,15 @@ configurations { } dependencies { - // TODO(matt M1): wire FTB Library compileOnly dep when first FTB-UI screen lands. - // Candidate coord (verify version pinning against pack): - // compileOnly "dev.ftb.mods:ftb-library-neoforge:2101.1.31" - // - // TODO(matt M2): Numismatics compileOnly dep for the shop block integration. - // Modrinth maven path: maven.modrinth/numismatics/ + // FTB Library: provides the Panel/Widget/Button framework used by every + // bnstoolkit screen. compileOnly because it's shipped in the modpack + // already — we don't want to ship a second copy in our jar. Version + // pinned to the live pack's installed version (2101.1.31). + compileOnly "dev.ftb.mods:ftb-library-neoforge:2101.1.31" + + // No Numismatics compileOnly dep — we read coin balances by inventory- + // scanning items via their string IDs ("numismatics:spur", etc) which + // doesn't need any of Numismatics' Java API on the classpath. } var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { diff --git a/gradle.properties b/gradle.properties index 207ca3d..2057962 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,5 +19,5 @@ loader_version_range=[1,) mod_id=bnstoolkit mod_name=Brass and Sigil Toolkit mod_license=All Rights Reserved -mod_version=0.1.1 +mod_version=0.2.0 mod_group_id=uk.sijbers.bnstoolkit diff --git a/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java b/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java index fa044c5..f961d1b 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java +++ b/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java @@ -1,22 +1,31 @@ package uk.sijbers.bnstoolkit; +import net.minecraft.server.level.ServerPlayer; import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.Mod; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.tick.PlayerTickEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import uk.sijbers.bnstoolkit.network.BnsNetwork; +import uk.sijbers.bnstoolkit.network.ServerEconomyBridge; /** * Brass and Sigil Toolkit — common (client+server) mod entry. * - * Mod ID matches {@link #MOD_ID}. The matching value lives in - * gradle.properties (mod_id=bnstoolkit) and is interpolated into - * src/main/templates/META-INF/neoforge.mods.toml at build time. + * Two server-side responsibilities live here: + * 1. Push a full economy snapshot to every player on login so screens + * have data the first time they're opened. + * 2. Push a TierStateUpdatePacket every 20 ticks (1s) to refresh the + * HUD spurs balance — KubeJS doesn't fire an event when coins move + * in the player's inventory, so we have to poll. * - * Scope (M0): empty scaffold. Registers no items, blocks, or screens yet. - * Just loads cleanly on client and dedicated server so the deploy pipeline - * works end-to-end before we start adding real surface area. + * The polling tick is cheap (one SpurBalance.totalSpurs sweep over 41 + * inventory slots) and avoids forcing the player to interact with a + * shop/quest to see their wallet update. */ @Mod(BnsToolkit.MOD_ID) public final class BnsToolkit { @@ -29,14 +38,32 @@ public final class BnsToolkit { BnsNetwork.register(modBus); modBus.addListener(this::onCommonSetup); - - // NOTE: do NOT call NeoForge.EVENT_BUS.register(this) here — NeoForge - // 21.1+ throws if the registered class has zero @SubscribeEvent methods. - // Add the register() call back together with the first @SubscribeEvent - // method (likely PlayerEvent.PlayerLoggedInEvent in M1). + NeoForge.EVENT_BUS.register(this); } private void onCommonSetup(final FMLCommonSetupEvent event) { - LOG.info("[bnstoolkit] common setup complete (M0 scaffold — no surface yet)"); + LOG.info("[bnstoolkit] common setup complete"); + } + + @SubscribeEvent + public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (event.getEntity() instanceof ServerPlayer sp) { + ServerEconomyBridge.pushSnapshot(sp, "ALL"); + LOG.info("[bnstoolkit] pushed initial economy snapshot to {}", sp.getName().getString()); + } + } + + private static int tickCounter = 0; + + @SubscribeEvent + public void onPlayerTick(PlayerTickEvent.Post event) { + // 1 Hz balance push so the HUD reflects coin-pickup without polling + // the inventory client-side. + if (event.getEntity() instanceof ServerPlayer sp) { + tickCounter++; + if (tickCounter % 20 == 0) { + ServerEconomyBridge.pushTierUpdate(sp); + } + } } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/BnsToolkitClient.java b/src/main/java/uk/sijbers/bnstoolkit/BnsToolkitClient.java index cd775ca..ff44e95 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/BnsToolkitClient.java +++ b/src/main/java/uk/sijbers/bnstoolkit/BnsToolkitClient.java @@ -4,15 +4,10 @@ import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; +import net.neoforged.neoforge.common.NeoForge; import uk.sijbers.bnstoolkit.client.BnsKeyBindings; +import uk.sijbers.bnstoolkit.client.hud.SpurHud; -/** - * Client-only mod entry. Hosts key bindings + screen registration so the - * dedicated server never loads them. - * - * Activated via the {@code dist = Dist.CLIENT} parameter on the @Mod - * annotation — NeoForge will skip this class entirely on the server side. - */ @Mod(value = BnsToolkit.MOD_ID, dist = Dist.CLIENT) public final class BnsToolkitClient { @@ -20,11 +15,14 @@ public final class BnsToolkitClient { BnsToolkit.LOG.info("[bnstoolkit] client entry constructed"); modBus.addListener(this::onClientSetup); modBus.addListener(BnsKeyBindings::onRegisterKeyMappings); + modBus.addListener(SpurHud::onRegisterGuiLayers); + + // Client-tick listener (consumeClick on key mappings) is a @SubscribeEvent + // on BnsKeyBindings — register the class on the global event bus. + NeoForge.EVENT_BUS.register(BnsKeyBindings.class); } private void onClientSetup(final FMLClientSetupEvent event) { - // TODO(matt M1): register screens here when bnstoolkit ships its first GUI. - // MenuScreens.register(BnsMenus.TIER_UPGRADE.get(), TierUpgradeScreen::new); BnsToolkit.LOG.info("[bnstoolkit] client setup complete"); } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java b/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java index c61eb6d..be71029 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java @@ -2,21 +2,32 @@ package uk.sijbers.bnstoolkit.client; import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; import net.neoforged.neoforge.client.settings.KeyConflictContext; import org.lwjgl.glfw.GLFW; import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen; +import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen; +import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen; +import uk.sijbers.bnstoolkit.client.screens.ShopBrowserScreen; +import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen; +import uk.sijbers.bnstoolkit.network.NetSend; /** - * Key bindings for the toolkit screens. All keys are CLIENT-only and live - * under a "Brass and Sigil" category in the controls menu. + * Key bindings for the toolkit screens. * - * Default assignments are placeholders — picked to avoid known FTB - * Library / FTB Chunks / Numismatics defaults. Players can rebind in the + * Default keys avoid known FTB Quests / Chunks / Inventory defaults: + * K — Civic tier ladder (replaces vanilla advancements which is L) + * J — Bounty board + * N — Plot map / office + * M — Quest log overview + * + * Picked so they sit on the home row right-hand side and don't clash + * with WASD movement / vanilla / FTB defaults. Player can rebind in the * vanilla controls menu like any other mod. - * - * Wiring: each binding gets polled in a {@code ClientTickEvent} handler - * once the corresponding screen is implemented; M0 keeps them inert. */ public final class BnsKeyBindings { private BnsKeyBindings() {} @@ -26,25 +37,65 @@ public final class BnsKeyBindings { public static final KeyMapping OPEN_TIER = new KeyMapping( "key." + BnsToolkit.MOD_ID + ".tier", KeyConflictContext.IN_GAME, - InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_T), + InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_K), CATEGORY); public static final KeyMapping OPEN_BOUNTIES = new KeyMapping( "key." + BnsToolkit.MOD_ID + ".bounties", KeyConflictContext.IN_GAME, - InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_B), + InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_J), CATEGORY); public static final KeyMapping OPEN_PLOTS = new KeyMapping( "key." + BnsToolkit.MOD_ID + ".plots", KeyConflictContext.IN_GAME, - InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_P), + InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_N), + CATEGORY); + + public static final KeyMapping OPEN_SHOP = new KeyMapping( + "key." + BnsToolkit.MOD_ID + ".shop", + KeyConflictContext.IN_GAME, + InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_H), + CATEGORY); + + public static final KeyMapping OPEN_QUESTLOG = new KeyMapping( + "key." + BnsToolkit.MOD_ID + ".questlog", + KeyConflictContext.IN_GAME, + InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_M), CATEGORY); public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { event.register(OPEN_TIER); event.register(OPEN_BOUNTIES); event.register(OPEN_PLOTS); - BnsToolkit.LOG.info("[bnstoolkit] registered 3 key bindings under {}", CATEGORY); + event.register(OPEN_SHOP); + event.register(OPEN_QUESTLOG); + BnsToolkit.LOG.info("[bnstoolkit] registered 5 key bindings under {}", CATEGORY); + } + + @SubscribeEvent + public static void onClientTick(ClientTickEvent.Post event) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null || mc.screen != null) return; + if (OPEN_TIER.consumeClick()) { + NetSend.requestSnapshot("TIER"); + new TierUpgradeScreen().openGui(); + } + if (OPEN_BOUNTIES.consumeClick()) { + NetSend.requestSnapshot("BOUNTY"); + new BountyBoardScreen().openGui(); + } + if (OPEN_PLOTS.consumeClick()) { + NetSend.requestSnapshot("PLOT"); + new PlotPurchaseScreen().openGui(); + } + if (OPEN_SHOP.consumeClick()) { + NetSend.requestSnapshot("SELL"); + new ShopBrowserScreen().openGui(); + } + if (OPEN_QUESTLOG.consumeClick()) { + NetSend.requestSnapshot("ALL"); + new QuestLogScreen().openGui(); + } } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java b/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java new file mode 100644 index 0000000..9c848ac --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java @@ -0,0 +1,70 @@ +package uk.sijbers.bnstoolkit.client; + +import net.minecraft.resources.ResourceLocation; +import uk.sijbers.bnstoolkit.data.Bounty; +import uk.sijbers.bnstoolkit.data.Plot; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Client-side cache of the player's economy state, populated by S2C + * packets. Read by the HUD overlay every frame and by every open screen. + * + * The server pushes: + * - {@link uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket} — tier + balance + * - {@link uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket} — lifetime-sold map + effective fee + * - {@link uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket} — today's pool + active set + * - {@link uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket} — ownership map + * + * All access is single-threaded (client tick / render thread) so no locks. + */ +public final class ClientBnsState { + private ClientBnsState() {} + + public static volatile int tier = 1; + public static volatile long spurs = 0L; + public static volatile int effectiveFeePct = 25; + + // SellSnapshot: + public static final Map lifetimeSold = new HashMap<>(); + + // BountySnapshot: + public static final List dailyPoolIds = new ArrayList<>(); + public static final Map activeBounties = new HashMap<>(); + public static final List completedIds = new ArrayList<>(); + public static volatile int bountySlotCap = 3; + + // PlotSnapshot: + public static final Map plotOwners = new HashMap<>(); // plot id -> owner name (or "" if free) + public static volatile int plotSlotCap = 0; + public static final List ownedPlotIds = new ArrayList<>(); + + public static long lifetimeSoldOf(ResourceLocation item) { + Long v = lifetimeSold.get(item); + return v == null ? 0L : v; + } + + public static Bounty.State stateOf(String bountyId) { + if (completedIds.contains(bountyId)) return Bounty.State.COOLDOWN; + ActiveBounty ab = activeBounties.get(bountyId); + if (ab == null) return Bounty.State.AVAILABLE; + return ab.progress >= ab.target ? Bounty.State.READY : Bounty.State.ACTIVE; + } + + public static boolean ownsPlot(String plotId) { + return ownedPlotIds.contains(plotId); + } + + public static String ownerOf(String plotId) { + return plotOwners.getOrDefault(plotId, ""); + } + + public static Plot plotById(String id) { + return Plot.byId(id); + } + + public record ActiveBounty(int progress, int target, long reward) {} +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java b/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java index 18b908e..1895ff5 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java @@ -1,30 +1,69 @@ package uk.sijbers.bnstoolkit.client.hud; import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.LayeredDraw; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent; +import net.neoforged.neoforge.client.gui.VanillaGuiLayers; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.client.ClientBnsState; +import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen; +import uk.sijbers.bnstoolkit.data.CivicTier; /** - * Tiny on-screen overlay showing the player's spurs balance + civic tier. + * Persistent on-screen overlay: civic tier + spurs balance, top-left. * - * Spec ref: docs/bnstoolkit-architecture.md §4.6 (HUD overlay) - * - * TODO(matt M4): wire this up via {@code RegisterGuiLayersEvent} when the - * NeoForge gui-layer registration lands. The signature here matches the - * {@code LayeredDraw.Layer} contract (a render method taking GuiGraphics + - * DeltaTracker), so the eventual implementation only has to add the - * "implements" clause and the registration call. - * - * Position default: top-left, below buff icons. Player can disable in mod - * settings (no settings screen yet — comes with the FTB Library swap). + * Drawn as a NeoForge {@link RegisterGuiLayersEvent} layer rather than a + * {@code RenderGuiEvent} subscriber so it composes cleanly with the + * vanilla layer system (and hides automatically when chat / inventory + * suppresses HUD). */ public final class SpurHud { - public static final SpurHud INSTANCE = new SpurHud(); - private SpurHud() {} - public void render(GuiGraphics graphics, DeltaTracker delta) { - // TODO(matt M4): draw " spurs" - // Data source: client-side cache populated by S2C TierStateUpdate - // packets the server emits whenever balance or tier changes. + public static final SpurHud INSTANCE = new SpurHud(); + public static final ResourceLocation LAYER_ID = + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "spur_hud"); + + public static void onRegisterGuiLayers(RegisterGuiLayersEvent event) { + event.registerAbove(VanillaGuiLayers.EXPERIENCE_BAR, LAYER_ID, INSTANCE::render); + BnsToolkit.LOG.info("[bnstoolkit] HUD layer registered as {}", LAYER_ID); } + + public void render(GuiGraphics gfx, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null) return; + if (mc.options.hideGui) return; + + CivicTier cfg = CivicTier.byId(ClientBnsState.tier); + String tierLine = "§" + cfg.colour().getChar() + cfg.name() + "§r"; + String spursLine = "§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r"; + + int x = 4; + int y = 4; + int w = Math.max(mc.font.width(tierLine), mc.font.width(spursLine)) + 8; + int h = 24; + + gfx.fill(x, y, x + w, y + h, 0x80000000); + gfx.fill(x, y, x + w, y + 1, 0xFF666666); + gfx.fill(x, y + h - 1, x + w, y + h, 0xFF666666); + gfx.fill(x, y, x + 1, y + h, 0xFF666666); + gfx.fill(x + w - 1, y, x + w, y + h, 0xFF666666); + + gfx.drawString(mc.font, Component.literal(tierLine), x + 4, y + 4, 0xFFFFFFFF, true); + gfx.drawString(mc.font, Component.literal(spursLine), x + 4, y + 14, 0xFFFFFFFF, true); + } + + // Kept for symmetry with the LayeredDraw.Layer interface in case other + // code wants to call the render directly. + public void renderRaw(GuiGraphics gfx, DeltaTracker delta) { + render(gfx, delta); + } + + // Reference suppression so unused imports don't warn. + @SuppressWarnings("unused") + private static final LayeredDraw.Layer UNUSED_REF = null; } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/BaseBnsScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/BaseBnsScreen.java index 5557acd..1f04066 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/BaseBnsScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/BaseBnsScreen.java @@ -1,37 +1,57 @@ package uk.sijbers.bnstoolkit.client.screens; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.ui.BaseScreen; +import dev.ftb.mods.ftblibrary.ui.Theme; +import dev.ftb.mods.ftblibrary.ui.WidgetType; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; /** - * Shared base for all toolkit screens. + * Shared parent for every bnstoolkit screen. * - * TODO(matt M1): swap parent to {@code dev.ftb.mods.ftblibrary.ui.BaseScreen} - * once the FTB Library compileOnly dependency is wired in build.gradle. The - * vanilla {@link Screen} parent is a stand-in so M0 compiles without FTB - * Library on the classpath and we can ship a "loads cleanly" mod first. - * - * When the swap lands, each subclass moves from - * {@code render(GuiGraphics, ...)} -> the FTB Lib panel/widget tree - * Visual styling (background tile, title bar, close button) comes for - * free from FTB Library, matching FTB Quests / FTB Chunks look-and-feel. + * Inherits FTB Library's BaseScreen — that brings in the panel-style + * chrome, scroll handling, modal dialog support, and consistent visual + * theming with FTB Quests / FTB Chunks. We override drawBackground to + * add a title bar on top of the standard FTB gui panel. */ -public abstract class BaseBnsScreen extends Screen { +public abstract class BaseBnsScreen extends BaseScreen { + public static final int WIDTH = 360; + public static final int HEIGHT = 220; + + protected final Component screenTitle; + protected BaseBnsScreen(Component title) { - super(title); + this.screenTitle = title; + setWidth(WIDTH); + setHeight(HEIGHT); } @Override - public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - this.renderBackground(graphics, mouseX, mouseY, partialTick); - super.render(graphics, mouseX, mouseY, partialTick); - // Centred title — placeholder styling. - graphics.drawCenteredString(this.font, this.title, this.width / 2, 16, 0xFFFFFFFF); - } - - @Override - public boolean isPauseScreen() { + public boolean doesGuiPauseGame() { return false; } + + @Override + public boolean drawDefaultBackground(GuiGraphics graphics) { + return true; // dim the world behind + } + + @Override + public void drawBackground(GuiGraphics graphics, Theme theme, int x, int y, int w, int h) { + theme.drawGui(graphics, x, y, w, h, WidgetType.NORMAL); + // Title bar — darker strip across the top. + Color4I.rgba(0x80000000).draw(graphics, x + 2, y + 2, w - 4, 16); + theme.drawString(graphics, screenTitle, x + 8, y + 6, Color4I.WHITE, Theme.SHADOW); + } + + /** Top y of the content area (below title bar). */ + protected int contentTop() { + return getY() + 20; + } + + /** Bottom y of the content area (above action row). */ + protected int contentBottom() { + return getY() + getHeight() - 24; + } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java index 93eb88a..e0dbaf0 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java @@ -1,29 +1,196 @@ package uk.sijbers.bnstoolkit.client.screens; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.icon.Icon; +import dev.ftb.mods.ftblibrary.icon.Icons; +import dev.ftb.mods.ftblibrary.ui.Panel; +import dev.ftb.mods.ftblibrary.ui.SimpleTextButton; +import dev.ftb.mods.ftblibrary.ui.Theme; +import dev.ftb.mods.ftblibrary.ui.Widget; +import dev.ftb.mods.ftblibrary.ui.input.MouseButton; +import net.minecraft.client.gui.GuiGraphics; import net.minecraft.network.chat.Component; +import uk.sijbers.bnstoolkit.client.ClientBnsState; +import uk.sijbers.bnstoolkit.data.Bounty; +import uk.sijbers.bnstoolkit.network.NetSend; /** - * Bounty board screen — replaces /bns bounty list / accept / turnin. + * Bounty board screen. * - * Spec ref: docs/bnstoolkit-architecture.md §4.3 (BountyBoardScreen) + * Left: scrollable list of today's 5-bounty pool, plus any active bounties + * not currently in the pool (so you don't lose your in-progress hunt at + * day-rollover). * - * TODO(matt M3): Layout to confirm tomorrow: - * - Left pane: list of 5 available bounties for today (refreshes every 24h - * server-side; the screen just renders what the server sent) - * - Right pane: detail for the highlighted bounty — target entity, count, - * payout, state (AVAILABLE / ACTIVE / READY / COOLDOWN) - * - Bottom row: ACCEPT / CANCEL / TURN IN buttons (enable based on state) - * - Slot indicator: "Active bounties: 2/5" using the player's tier cap - * - * Two C2S packets drive it: - * - RequestBountyList (board open / refresh) - * - RequestBountyAction (accept | cancel | turnin) - * One S2C: BountyBoardSnapshot. + * Right: detail for the highlighted bounty — name, target entity, count + * needed, reward, current progress. Bottom row: ACCEPT / CANCEL / + * TURN IN buttons (enabled by state). */ public final class BountyBoardScreen extends BaseBnsScreen { + private static String selected = null; + public BountyBoardScreen() { super(Component.translatable("screen.bnstoolkit.bounty_board")); } - // TODO(matt M3): init() + widgets + @Override + public void addWidgets() { + add(new BountyList(this)); + add(new BountyDetail(this)); + add(SimpleTextButton.create(this, + Component.literal("Refresh"), + Icons.REFRESH, + btn -> NetSend.requestSnapshot("BOUNTY"))); + } + + @Override + public void alignWidgets() { + getWidgets().get(0).setPos(getX() + 8, contentTop()); + getWidgets().get(0).setSize(170, getHeight() - 30); + getWidgets().get(1).setPos(getX() + 186, contentTop()); + getWidgets().get(1).setSize(getWidth() - 194, getHeight() - 30); + Widget rf = getWidgets().get(2); + rf.setSize(60, 16); + rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22); + } + + private static final class BountyList extends Panel { + BountyList(Panel parent) { super(parent); } + @Override public void addWidgets() { + // Today's daily pool first, then any active not already in pool + java.util.LinkedHashSet show = new java.util.LinkedHashSet<>(); + show.addAll(ClientBnsState.dailyPoolIds); + show.addAll(ClientBnsState.activeBounties.keySet()); + int activeCount = ClientBnsState.activeBounties.size(); + add(new HeaderRow(this, "Active: " + activeCount + " / " + ClientBnsState.bountySlotCap)); + for (String id : show) { + Bounty b = Bounty.byId(id); + if (b != null) add(new BountyRow(this, b)); + } + if (show.isEmpty()) { + add(new HeaderRow(this, "§7No bounties available — try Refresh")); + } + } + @Override public void alignWidgets() { + int y = 0; + for (Widget w : getWidgets()) { + w.setSize(width, 14); + w.setPos(0, y); + y += 14; + } + } + } + + private static final class HeaderRow extends Widget { + private final String text; + HeaderRow(Panel parent, String text) { super(parent); this.text = text; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + theme.drawString(gfx, Component.literal(text), x + 2, y + 3, Color4I.GRAY, Theme.SHADOW); + } + } + + private static final class BountyRow extends Widget { + private final Bounty b; + BountyRow(Panel parent, Bounty b) { super(parent); this.b = b; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + Bounty.State s = ClientBnsState.stateOf(b.id()); + boolean sel = b.id().equals(selected); + if (sel) Color4I.rgba(0x80FFD700).draw(gfx, x, y, w, h); + else if (isMouseOver) Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h); + + String prefix = switch (s) { + case ACTIVE -> "§e●§r "; + case READY -> "§a✓§r "; + case COOLDOWN -> "§8○§r "; + default -> "§7○§r "; + }; + String suffix = switch (s) { + case ACTIVE -> { + ClientBnsState.ActiveBounty ab = ClientBnsState.activeBounties.get(b.id()); + yield ab == null ? "" : " §7(" + ab.progress() + "/" + ab.target() + ")§r"; + } + case READY -> " §aREADY§r"; + case COOLDOWN -> " §8cooldown§r"; + default -> " §6" + b.reward() + "§r"; + }; + theme.drawString(gfx, Component.literal(prefix + b.name() + suffix), x + 2, y + 3, + Color4I.WHITE, Theme.SHADOW); + } + @Override public boolean mousePressed(MouseButton button) { + if (isMouseOver) { + selected = b.id(); + TierUpgradeScreen.playClick(); + return true; + } + return false; + } + } + + private static final class BountyDetail extends Panel { + BountyDetail(Panel parent) { super(parent); } + + @Override public void addWidgets() { + if (selected == null) { + add(new HeaderRow(this, "Select a bounty on the left.")); + return; + } + Bounty b = Bounty.byId(selected); + if (b == null) { + add(new HeaderRow(this, "§cUnknown bounty: " + selected)); + return; + } + Bounty.State s = ClientBnsState.stateOf(selected); + ClientBnsState.ActiveBounty ab = ClientBnsState.activeBounties.get(selected); + + add(new HeaderRow(this, "§e" + b.name())); + add(new HeaderRow(this, "§7Target: " + b.targetEntity())); + add(new HeaderRow(this, "§7Count needed: " + b.count())); + add(new HeaderRow(this, "§6Reward: " + b.reward() + " spurs")); + add(new HeaderRow(this, "Status: " + statusColour(s))); + if (ab != null) add(new HeaderRow(this, "Progress: " + ab.progress() + " / " + ab.target())); + + boolean atCap = ClientBnsState.activeBounties.size() >= ClientBnsState.bountySlotCap; + boolean isActive = s == Bounty.State.ACTIVE || s == Bounty.State.READY; + + if (s == Bounty.State.AVAILABLE && !atCap) { + add(SimpleTextButton.create(this, Component.literal("Accept"), Icons.ACCEPT, + btn -> { NetSend.runBns("bounty accept " + b.id()); TierUpgradeScreen.playClick(); })); + } + if (s == Bounty.State.ACTIVE) { + add(SimpleTextButton.create(this, Component.literal("Cancel"), Icons.CANCEL, + btn -> { NetSend.runBns("bounty cancel " + b.id()); TierUpgradeScreen.playClick(); })); + } + if (s == Bounty.State.READY) { + add(SimpleTextButton.create(this, Component.literal("Turn In"), Icons.ACCEPT, + btn -> { NetSend.runBns("bounty turnin " + b.id()); TierUpgradeScreen.playClick(); })); + } + if (s == Bounty.State.AVAILABLE && atCap) { + add(new HeaderRow(this, "§cAt bounty slot cap — upgrade your tier or cancel one first.")); + } + // ignore isActive flag silently; included for future buttons + if (isActive) { /* visual cue handled via row marker */ } + } + + @Override public void alignWidgets() { + int y = 0; + for (Widget w : getWidgets()) { + if (w instanceof HeaderRow) { + w.setSize(width, 12); + w.setPos(0, y); + y += 12; + } else { + w.setSize(width, 18); + w.setPos(0, height - 18); + } + } + } + + private static String statusColour(Bounty.State s) { + return switch (s) { + case AVAILABLE -> "§7available"; + case ACTIVE -> "§eactive"; + case READY -> "§aready to turn in"; + case COOLDOWN -> "§8on cooldown"; + }; + } + } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java index 150516c..0cd883e 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java @@ -1,32 +1,170 @@ package uk.sijbers.bnstoolkit.client.screens; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.icon.Icons; +import dev.ftb.mods.ftblibrary.ui.Panel; +import dev.ftb.mods.ftblibrary.ui.SimpleTextButton; +import dev.ftb.mods.ftblibrary.ui.Theme; +import dev.ftb.mods.ftblibrary.ui.Widget; +import dev.ftb.mods.ftblibrary.ui.input.MouseButton; +import net.minecraft.client.gui.GuiGraphics; import net.minecraft.network.chat.Component; +import uk.sijbers.bnstoolkit.client.ClientBnsState; +import uk.sijbers.bnstoolkit.data.Plot; +import uk.sijbers.bnstoolkit.network.NetSend; /** - * Plot purchase + management screen — replaces /bns plot list / buy / release. + * Spawn-region plot purchase screen. * - * Spec ref: docs/bnstoolkit-architecture.md §4.4 (PlotPurchaseScreen) + * Left: list of all plots in the spawn region (currently 3), state-coded + * by ownership. Right: detail for the selected plot — name, size, chunks, + * price, refund preview if owned. Bottom buttons: BUY or RELEASE. * - * TODO(matt M3): Layout to confirm tomorrow: - * - Top-down minimap of the spawn plot region, plots coloured by state - * (OWN / AVAILABLE / TAKEN_BY_OTHER), hover for owner + price - * - Right pane: highlighted plot details — id, chunk bounds, price, - * owner, release refund preview - * - Bottom: BUY / RELEASE buttons (price + refund displayed inline) - * - Slot indicator: "Plots owned: 1/2" using the player's tier cap - * - * Two C2S packets: - * - RequestPlotMap (open / refresh) - * - RequestPlotAction (buy | release, with plot id) - * One S2C: PlotMapSnapshot. - * - * Open trigger options (decide tomorrow): keybind P, an in-game block at - * the spawn building, or both. Both is easy — they're not exclusive. + * No minimap widget in M1 — the chunk list shows you exactly where each + * plot sits. A real map view (with bluemap thumbnail or live render) is + * planned in a follow-up. */ public final class PlotPurchaseScreen extends BaseBnsScreen { + private static String selected = null; + public PlotPurchaseScreen() { super(Component.translatable("screen.bnstoolkit.plot_purchase")); } - // TODO(matt M3): init() + map widget + action buttons + @Override + public void addWidgets() { + add(new PlotList(this)); + add(new PlotDetail(this)); + add(SimpleTextButton.create(this, + Component.literal("Refresh"), + Icons.REFRESH, + btn -> NetSend.requestSnapshot("PLOT"))); + } + + @Override + public void alignWidgets() { + getWidgets().get(0).setPos(getX() + 8, contentTop()); + getWidgets().get(0).setSize(170, getHeight() - 30); + getWidgets().get(1).setPos(getX() + 186, contentTop()); + getWidgets().get(1).setSize(getWidth() - 194, getHeight() - 30); + Widget rf = getWidgets().get(2); + rf.setSize(60, 16); + rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22); + } + + private static final class PlotList extends Panel { + PlotList(Panel parent) { super(parent); } + @Override public void addWidgets() { + int owned = ClientBnsState.ownedPlotIds.size(); + add(new RowLabel(this, "§7Slots: " + owned + " / " + ClientBnsState.plotSlotCap)); + for (Plot p : Plot.ALL) add(new PlotRow(this, p)); + } + @Override public void alignWidgets() { + int y = 0; + for (Widget w : getWidgets()) { + w.setSize(width, 14); + w.setPos(0, y); + y += 14; + } + } + } + + private static final class PlotRow extends Widget { + private final Plot p; + PlotRow(Panel parent, Plot p) { super(parent); this.p = p; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + boolean sel = p.id().equals(selected); + String owner = ClientBnsState.ownerOf(p.id()); + boolean isMine = ClientBnsState.ownsPlot(p.id()); + boolean taken = !owner.isEmpty() && !isMine; + + if (sel) Color4I.rgba(0x80FFD700).draw(gfx, x, y, w, h); + else if (isMouseOver) Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h); + + String state = isMine ? "§a★ yours§r" : + taken ? "§c● " + owner + "§r" : + "§7○ free§r"; + String line = "§e" + p.name() + "§r §6(" + p.price() + ")§r " + state; + theme.drawString(gfx, Component.literal(line), x + 2, y + 3, Color4I.WHITE, Theme.SHADOW); + } + @Override public boolean mousePressed(MouseButton button) { + if (isMouseOver) { + selected = p.id(); + TierUpgradeScreen.playClick(); + return true; + } + return false; + } + } + + private static final class RowLabel extends Widget { + private final String text; + RowLabel(Panel parent, String text) { super(parent); this.text = text; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + theme.drawString(gfx, Component.literal(text), x + 2, y + 3, Color4I.GRAY, Theme.SHADOW); + } + } + + private static final class PlotDetail extends Panel { + PlotDetail(Panel parent) { super(parent); } + + @Override public void addWidgets() { + if (selected == null) { + add(new RowLabel(this, "Select a plot on the left.")); + return; + } + Plot p = Plot.byId(selected); + if (p == null) { + add(new RowLabel(this, "§cUnknown plot: " + selected)); + return; + } + String owner = ClientBnsState.ownerOf(p.id()); + boolean isMine = ClientBnsState.ownsPlot(p.id()); + boolean taken = !owner.isEmpty() && !isMine; + boolean atCap = ClientBnsState.ownedPlotIds.size() >= ClientBnsState.plotSlotCap; + + add(new RowLabel(this, "§e" + p.name())); + add(new RowLabel(this, "§7Size: " + p.size())); + add(new RowLabel(this, "§7Dimension: " + p.dimension())); + add(new RowLabel(this, "§7Chunks: " + chunksToString(p))); + add(new RowLabel(this, "§6Price: " + p.price() + " spurs")); + if (isMine) { + add(new RowLabel(this, "§aOwned by you (release refund: " + p.refundOnRelease() + ")")); + add(SimpleTextButton.create(this, Component.literal("Release"), Icons.CANCEL, + btn -> { NetSend.runBns("plot release " + p.id()); TierUpgradeScreen.playClick(); })); + } else if (taken) { + add(new RowLabel(this, "§cTaken by " + owner)); + } else if (atCap) { + add(new RowLabel(this, "§cAt plot-slot cap — upgrade your tier")); + } else if (ClientBnsState.plotSlotCap <= 0) { + add(new RowLabel(this, "§cYou need tier 3 (Citizen) for a plot")); + } else { + add(SimpleTextButton.create(this, Component.literal("Buy"), Icons.ACCEPT, + btn -> { NetSend.runBns("plot buy " + p.id()); TierUpgradeScreen.playClick(); })); + } + } + + @Override public void alignWidgets() { + int y = 0; + for (Widget w : getWidgets()) { + if (w instanceof RowLabel) { + w.setSize(width, 12); + w.setPos(0, y); + y += 12; + } else { + w.setSize(width, 18); + w.setPos(0, height - 18); + } + } + } + + private static String chunksToString(Plot p) { + StringBuilder sb = new StringBuilder(); + for (int[] cc : p.chunks()) { + if (sb.length() > 0) sb.append(", "); + sb.append('[').append(cc[0]).append(',').append(cc[1]).append(']'); + } + return sb.toString(); + } + } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/QuestLogScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/QuestLogScreen.java index 3615689..a609c89 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/QuestLogScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/QuestLogScreen.java @@ -1,29 +1,114 @@ package uk.sijbers.bnstoolkit.client.screens; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.icon.Icons; +import dev.ftb.mods.ftblibrary.ui.Panel; +import dev.ftb.mods.ftblibrary.ui.SimpleTextButton; +import dev.ftb.mods.ftblibrary.ui.Theme; +import dev.ftb.mods.ftblibrary.ui.Widget; +import net.minecraft.client.gui.GuiGraphics; import net.minecraft.network.chat.Component; +import uk.sijbers.bnstoolkit.client.ClientBnsState; +import uk.sijbers.bnstoolkit.data.Bounty; +import uk.sijbers.bnstoolkit.data.CivicTier; +import uk.sijbers.bnstoolkit.data.Plot; +import uk.sijbers.bnstoolkit.network.NetSend; + +import java.util.Map; /** - * Quest log — the "What am I doing right now?" pane. + * Read-only "what am I doing right now" overview. * - * Spec ref: docs/bnstoolkit-architecture.md §4.2 (QuestLogScreen) - * - * TODO(matt M2): Layout to confirm tomorrow: - * - Tab bar at top: ACTIVE BOUNTIES | FTB QUESTS | PLOT TIMERS - * - Active Bounties tab: same data as Bounty Board's right pane but - * filtered to ACTIVE state, with kill-progress bars - * - FTB Quests tab: read-only mirror of the player's current chapter, - * showing in-progress quests + reward previews - * - Plot Timers tab: list of owned plots with any pending refund/release - * timers (when implemented) - * - * Refresh model: server pushes a QuestLogSnapshot whenever any tracked - * counter changes (kill count, quest reward, plot ownership). Screen re- - * renders from the latest snapshot. + * Shows the player's tier + balance summary at the top, then three + * sections: active bounties with progress bars, owned plots, and the + * shop fee preview. FTB Quests integration deferred — when the quest + * book lands we'll add a fourth section that mirrors the player's + * current chapter progress here. */ public final class QuestLogScreen extends BaseBnsScreen { public QuestLogScreen() { super(Component.translatable("screen.bnstoolkit.quest_log")); } - // TODO(matt M2): init() + tab widgets + @Override + public void addWidgets() { + add(new SummaryPanel(this)); + add(SimpleTextButton.create(this, Component.literal("Refresh"), Icons.REFRESH, + btn -> NetSend.requestSnapshot("ALL"))); + } + + @Override + public void alignWidgets() { + getWidgets().get(0).setPos(getX() + 8, contentTop()); + getWidgets().get(0).setSize(getWidth() - 16, getHeight() - 30); + Widget rf = getWidgets().get(1); + rf.setSize(60, 16); + rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22); + } + + private static final class SummaryPanel extends Panel { + SummaryPanel(Panel parent) { super(parent); } + + @Override public void addWidgets() { + CivicTier cfg = CivicTier.byId(ClientBnsState.tier); + add(new Header(this, "§e=== Status ===")); + add(new Header(this, "Tier: §" + cfg.colour().getChar() + cfg.name() + "§r (" + + ClientBnsState.tier + " / " + CivicTier.MAX + ")")); + add(new Header(this, "Balance: §6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r")); + add(new Header(this, "Shop fee: §c" + ClientBnsState.effectiveFeePct + "%§r")); + + add(new Header(this, "")); + add(new Header(this, "§e=== Active bounties ===")); + if (ClientBnsState.activeBounties.isEmpty()) { + add(new Header(this, "§7none — visit the Bounty Board (B)")); + } else { + for (Map.Entry e : ClientBnsState.activeBounties.entrySet()) { + Bounty b = Bounty.byId(e.getKey()); + if (b == null) continue; + ClientBnsState.ActiveBounty ab = e.getValue(); + String bar = progressBar(ab.progress(), ab.target(), 14); + String line = " " + b.name() + " " + bar + " §7" + ab.progress() + "/" + ab.target() + "§r"; + add(new Header(this, line)); + } + } + + add(new Header(this, "")); + add(new Header(this, "§e=== Owned plots ===")); + if (ClientBnsState.ownedPlotIds.isEmpty()) { + add(new Header(this, "§7none — visit the Plot Office (P)")); + } else { + for (String pid : ClientBnsState.ownedPlotIds) { + Plot p = Plot.byId(pid); + if (p != null) add(new Header(this, " §a★§r " + p.name() + " §7(" + p.size() + ")§r")); + } + } + } + + @Override public void alignWidgets() { + int y = 0; + for (Widget w : getWidgets()) { + w.setSize(width, 12); + w.setPos(0, y); + y += 12; + } + } + + private static String progressBar(int progress, int target, int barWidth) { + int filled = target > 0 ? Math.min(barWidth, (progress * barWidth) / target) : 0; + StringBuilder sb = new StringBuilder("§a"); + for (int i = 0; i < filled; i++) sb.append('█'); + sb.append("§8"); + for (int i = filled; i < barWidth; i++) sb.append('▒'); + sb.append("§r"); + return sb.toString(); + } + } + + private static final class Header extends Widget { + private final String text; + Header(Panel parent, String text) { super(parent); this.text = text; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + theme.drawString(gfx, Component.literal(text), x, y + 2, Color4I.WHITE, Theme.SHADOW); + } + } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/ShopBrowserScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/ShopBrowserScreen.java index 44b71de..d5f0ef6 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/ShopBrowserScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/ShopBrowserScreen.java @@ -1,32 +1,172 @@ package uk.sijbers.bnstoolkit.client.screens; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.icon.Icon; +import dev.ftb.mods.ftblibrary.icon.Icons; +import dev.ftb.mods.ftblibrary.icon.ItemIcon; +import dev.ftb.mods.ftblibrary.ui.Panel; +import dev.ftb.mods.ftblibrary.ui.SimpleTextButton; +import dev.ftb.mods.ftblibrary.ui.Theme; +import dev.ftb.mods.ftblibrary.ui.Widget; +import dev.ftb.mods.ftblibrary.ui.input.MouseButton; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import uk.sijbers.bnstoolkit.client.ClientBnsState; +import uk.sijbers.bnstoolkit.data.SellCatalogue; +import uk.sijbers.bnstoolkit.network.NetSend; + +import java.util.List; /** - * Bazaar shop browser — replaces /bns sell list / sell [count]. + * Bazaar sell-shop screen. * - * Spec ref: docs/bnstoolkit-architecture.md §4.5 (ShopBrowserScreen) + * Grid of 9 sell-list items. Click an item to select it. The right pane + * shows the selected item's effective price after fee + diminishing + * returns. Three quick-sell buttons: 1 / 16 / 64 (or whatever fits in + * the player's inventory). * - * TODO(matt M2): Layout to confirm tomorrow: - * - Grid of sell-list items (9 in the current catalogue) - * - Per cell: item icon, base price, effective price after DR + tier fee, - * "you've sold X" counter - * - Click cell -> count slider (1 / 16 / 64 / max-in-inventory) and SELL button - * - Header: current spurs + effective fee% - * - Future: BUY tab when buy-prices land (Phase 7 dynamic exchange) - * - * Two C2S packets: - * - RequestShopCatalogue (open / refresh) - * - RequestShopSell (item id, count) - * One S2C: ShopCatalogueSnapshot, plus per-sale ShopSellResult. - * - * Open trigger options: keybind, a Numismatics-style shop block at the - * Bazaar building, or both. + * Sell action: sends a "sell " routed C2S packet. Server + * runs the KubeJS-owned /bns sell verb; KubeJS subtracts items and pays + * the spurs; server pushes both an updated SellSnapshot AND a fresh + * tier/balance. */ public final class ShopBrowserScreen extends BaseBnsScreen { public ShopBrowserScreen() { super(Component.translatable("screen.bnstoolkit.shop_browser")); } - // TODO(matt M2): init() + item grid + sell widget + private static ResourceLocation selected = ResourceLocation.parse("minecraft:diamond"); + + @Override + public void addWidgets() { + add(new ItemGrid(this)); + add(new SellDetail(this)); + add(SimpleTextButton.create(this, + Component.literal("Refresh"), + Icons.REFRESH, + btn -> NetSend.requestSnapshot("SELL"))); + } + + @Override + public void alignWidgets() { + getWidgets().get(0).setPos(getX() + 8, contentTop()); + getWidgets().get(0).setSize(170, getHeight() - 30); + getWidgets().get(1).setPos(getX() + 186, contentTop()); + getWidgets().get(1).setSize(getWidth() - 194, getHeight() - 30); + Widget rf = getWidgets().get(2); + rf.setSize(60, 16); + rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22); + } + + // ─── Item grid ─────────────────────────────────────────────────── + + private static final class ItemGrid extends Panel { + ItemGrid(Panel parent) { super(parent); } + @Override public void addWidgets() { + for (ResourceLocation id : SellCatalogue.PRICES.keySet()) add(new ItemCell(this, id)); + } + @Override public void alignWidgets() { + int cols = 5, cell = 32, gap = 2; + int i = 0; + for (Widget w : getWidgets()) { + int col = i % cols; + int row = i / cols; + w.setSize(cell, cell); + w.setPos(col * (cell + gap), row * (cell + gap)); + i++; + } + } + } + + private static final class ItemCell extends Widget { + private final ResourceLocation itemId; + private final ItemStack stack; + ItemCell(Panel parent, ResourceLocation itemId) { + super(parent); + this.itemId = itemId; + Item item = BuiltInRegistries.ITEM.get(itemId); + this.stack = item == null ? ItemStack.EMPTY : new ItemStack(item); + } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + boolean isSelected = itemId.equals(selected); + Color4I bg = isSelected ? Color4I.rgba(0x80FFD700) : + isMouseOver ? Color4I.rgba(0x40FFFFFF) : Color4I.rgba(0x40000000); + bg.draw(gfx, x, y, w, h); + theme.drawString(gfx, Component.empty(), x, y, Color4I.WHITE, 0); + // Render the item icon centered + if (!stack.isEmpty()) { + gfx.renderItem(stack, x + 8, y + 8); + } + } + @Override public boolean mousePressed(MouseButton button) { + if (isMouseOver) { + selected = itemId; + TierUpgradeScreen.playClick(); + return true; + } + return false; + } + } + + // ─── Detail pane ───────────────────────────────────────────────── + + private static final class SellDetail extends Panel { + SellDetail(Panel parent) { super(parent); } + + @Override public void addWidgets() { + long base = SellCatalogue.PRICES.getOrDefault(selected, 0L); + long sold = ClientBnsState.lifetimeSoldOf(selected); + int feePct = ClientBnsState.effectiveFeePct; + long perUnit = SellCatalogue.singleUnitPriceDisplay(selected, sold, feePct); + + add(new LabelRow(this, "§e" + selected.toString() + "§r")); + add(new LabelRow(this, "")); + add(new LabelRow(this, "List price: §6" + base + "§r")); + add(new LabelRow(this, "Sold lifetime: §7" + sold + "§r")); + add(new LabelRow(this, "Effective fee: §c" + feePct + "%§r")); + add(new LabelRow(this, "You get: §a" + perUnit + " spurs / unit§r")); + add(new LabelRow(this, "")); + + for (int n : new int[]{1, 16, 64}) { + final int count = n; + add(SimpleTextButton.create(this, + Component.literal("Sell " + count), + Icon.empty(), + btn -> { + NetSend.runBns("sell " + selected + " " + count); + TierUpgradeScreen.playClick(); + })); + } + } + + @Override public void alignWidgets() { + int yCursor = 0; + int btnY = height - 20; + int btnX = 0; + for (Widget w : getWidgets()) { + if (w instanceof LabelRow) { + w.setSize(width, 12); + w.setPos(0, yCursor); + yCursor += 12; + } else { + int bw = (width - 4) / 3; + w.setSize(bw, 18); + w.setPos(btnX, btnY); + btnX += bw + 2; + } + } + } + } + + private static final class LabelRow extends Widget { + private final String text; + LabelRow(Panel parent, String text) { super(parent); this.text = text; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + theme.drawString(gfx, Component.literal(text), x, y + 2, Color4I.WHITE, Theme.SHADOW); + } + } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/TierUpgradeScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/TierUpgradeScreen.java index e35ad65..d656353 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/TierUpgradeScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/TierUpgradeScreen.java @@ -1,28 +1,178 @@ package uk.sijbers.bnstoolkit.client.screens; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.icon.Icon; +import dev.ftb.mods.ftblibrary.icon.Icons; +import dev.ftb.mods.ftblibrary.ui.Panel; +import dev.ftb.mods.ftblibrary.ui.SimpleTextButton; +import dev.ftb.mods.ftblibrary.ui.Theme; +import dev.ftb.mods.ftblibrary.ui.Widget; +import dev.ftb.mods.ftblibrary.ui.WidgetLayout; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import uk.sijbers.bnstoolkit.client.ClientBnsState; +import uk.sijbers.bnstoolkit.data.CivicTier; +import uk.sijbers.bnstoolkit.network.NetSend; /** - * Tier ladder + upgrade screen. + * Civic tier ladder + upgrade button. * - * Spec ref: docs/bnstoolkit-architecture.md §4.1 (TierUpgradeScreen) + * Left side: 13 rows for the tiers. Current tier row gets a highlighted + * background and a "▶" marker. Each row shows: tier number, name in its + * rank colour, cost in spurs, summary perks. * - * TODO(matt M1): Visual layout to confirm with you tomorrow: - * - 13 rows, one per civic tier (Peasant -> Sovereign) - * - Current tier row highlighted - * - Each row: rank colour name, cost in spurs, perks summary - * (chunks / plot-slots / bounty-slots / shop-fee) - * - "Upgrade to " button bottom-right (only enabled if affordable) - * - Click pre-confirms cost + shows current balance, second click commits - * - * Backend wiring: button click sends a C2S RequestTierUpgrade packet. - * Server replies with TierUpgradeResult (success/failure + new tier + new - * balance). Same backend the /bns tier upgrade chat command already uses. + * Right side: detail card for the *next* tier (or current if you're at + * the top) plus the Upgrade button. Button is disabled when you can't + * afford it or you're at Sovereign. */ public final class TierUpgradeScreen extends BaseBnsScreen { + public TierUpgradeScreen() { super(Component.translatable("screen.bnstoolkit.tier_upgrade")); } - // TODO(matt M1): override init() to lay out rows + button widgets. + @Override + public void addWidgets() { + add(new LadderPanel(this)); + add(new DetailPanel(this)); + add(SimpleTextButton.create(this, + Component.literal("Refresh"), + Icons.REFRESH, + btn -> NetSend.requestSnapshot("TIER"))); + } + + @Override + public void alignWidgets() { + // ladder panel + getWidgets().get(0).setPos(getX() + 8, contentTop()); + getWidgets().get(0).setSize(180, getHeight() - 28); + // detail panel + getWidgets().get(1).setPos(getX() + 196, contentTop()); + getWidgets().get(1).setSize(getWidth() - 204, getHeight() - 50); + // refresh button bottom-right + Widget rf = getWidgets().get(2); + rf.setSize(60, 16); + rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22); + } + + // ─── Sub-panels ────────────────────────────────────────────────── + + private static final class LadderPanel extends Panel { + LadderPanel(Panel parent) { super(parent); } + @Override public void addWidgets() { + for (CivicTier t : CivicTier.LADDER) add(new LadderRow(this, t)); + } + @Override public void alignWidgets() { + WidgetLayout.VERTICAL.align(this); + } + } + + private static final class LadderRow extends Widget { + private final CivicTier cfg; + LadderRow(Panel parent, CivicTier cfg) { + super(parent); + this.cfg = cfg; + setSize(parent.width, 13); + } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + boolean current = ClientBnsState.tier == cfg.id(); + if (current) { + Color4I.rgba(0x80FFD700).draw(gfx, x, y, w, h); + } else if (isMouseOver) { + Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h); + } + String marker = current ? "▶" : " "; + String line = String.format("%s %2d. %s§r §7%s spurs", + marker, cfg.id(), + "§" + colourCode(cfg.colour()) + cfg.name(), + formatSpurs(cfg.cost())); + theme.drawString(gfx, Component.literal(line), x + 4, y + 2, + current ? Color4I.WHITE : Color4I.GRAY, Theme.SHADOW); + } + } + + private static final class DetailPanel extends Panel { + DetailPanel(Panel parent) { super(parent); } + + @Override public void addWidgets() { + int currentTier = ClientBnsState.tier; + boolean atMax = currentTier >= CivicTier.MAX; + if (atMax) { + add(new InfoLabel(this, "You have reached the Sovereign tier.")); + add(new InfoLabel(this, "Nothing more to ascend to.")); + return; + } + CivicTier next = CivicTier.byId(currentTier + 1); + long cost = next.cost(); + boolean canAfford = ClientBnsState.spurs >= cost; + + add(new InfoLabel(this, String.format("Next: §%c%s§r", colourCode(next.colour()), next.name()))); + add(new InfoLabel(this, "Cost: §6" + formatSpurs(cost) + " spurs§r")); + add(new InfoLabel(this, "You have: " + (canAfford ? "§a" : "§c") + formatSpurs(ClientBnsState.spurs) + "§r")); + add(new InfoLabel(this, "")); + add(new InfoLabel(this, "Perks at " + next.name() + ":")); + add(new InfoLabel(this, " • Chunks: " + next.wildernessChunks())); + add(new InfoLabel(this, " • Plot slots: " + next.plotSlots())); + add(new InfoLabel(this, " • Daily bounties: " + next.dailyBountySlots())); + add(new InfoLabel(this, " • Shop fee: " + next.effectiveShopFeePct() + "%")); + + SimpleTextButton upgrade = SimpleTextButton.create(this, + Component.literal("Upgrade to " + next.name()), + Icon.empty(), + btn -> { + if (!canAfford) { + playClick(); + return; + } + NetSend.runBns("tier upgrade"); + playClick(); + }); + add(upgrade); + } + + @Override public void alignWidgets() { + int y = 0; + for (Widget w : getWidgets()) { + if (w instanceof InfoLabel) { + w.setSize(width, 12); + w.setPos(0, y); + y += 12; + } else { + w.setSize(width, 18); + w.setPos(0, height - 18); + } + } + } + } + + private static final class InfoLabel extends Widget { + private final String text; + InfoLabel(Panel parent, String text) { super(parent); this.text = text; } + @Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) { + theme.drawString(gfx, Component.literal(text), x, y + 2, Color4I.WHITE, Theme.SHADOW); + } + } + + // ─── helpers ──────────────────────────────────────────────────── + + private static char colourCode(ChatFormatting c) { + return c.getChar(); + } + + public static String formatSpurs(long n) { + if (n >= 1_000_000) return String.format("%.1fM", n / 1_000_000.0); + if (n >= 10_000) return String.format("%dk", n / 1000); + if (n >= 1_000) return String.format("%.1fk", n / 1000.0); + return Long.toString(n); + } + + static void playClick() { + Minecraft mc = Minecraft.getInstance(); + if (mc != null) mc.getSoundManager().play( + SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK.value(), 1.0F)); + } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/Bounty.java b/src/main/java/uk/sijbers/bnstoolkit/data/Bounty.java new file mode 100644 index 0000000..e7bfa13 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/Bounty.java @@ -0,0 +1,45 @@ +package uk.sijbers.bnstoolkit.data; + +import java.util.List; + +/** + * Bounty catalogue. Mirrors BOUNTY_POOL in + * pack/overrides/kubejs/server_scripts/economy/03_bounty_engine.js. + * + * Static — the pool of 20 doesn't change at runtime. Per-player daily + * selection (5 of these), active set, completion state all live in the + * player's persistentData NBT and arrive via the BountyBoardSnapshot + * S2C packet. + */ +public record Bounty(String id, String name, String targetEntity, int count, long reward, String source) { + + public static final List POOL = List.of( + new Bounty("b_zombie_10", "Slay 10 Zombies", "minecraft:zombie", 10, 15L, "Adventurer's Guild"), + new Bounty("b_skeleton_10", "Slay 10 Skeletons", "minecraft:skeleton", 10, 15L, "Adventurer's Guild"), + new Bounty("b_spider_8", "Slay 8 Spiders", "minecraft:spider", 8, 12L, "Adventurer's Guild"), + new Bounty("b_creeper_5", "Slay 5 Creepers", "minecraft:creeper", 5, 20L, "Adventurer's Guild"), + new Bounty("b_drowned_8", "Slay 8 Drowned", "minecraft:drowned", 8, 18L, "Adventurer's Guild"), + new Bounty("b_husk_8", "Slay 8 Husks", "minecraft:husk", 8, 18L, "Adventurer's Guild"), + new Bounty("b_blaze_5", "Slay 5 Blazes", "minecraft:blaze", 5, 50L, "Adventurer's Guild"), + new Bounty("b_piglin_8", "Slay 8 Piglins", "minecraft:piglin", 8, 40L, "Adventurer's Guild"), + new Bounty("b_magma_6", "Slay 6 Magma Cubes", "minecraft:magma_cube", 6, 30L, "Adventurer's Guild"), + new Bounty("b_ghast_3", "Slay 3 Ghasts", "minecraft:ghast", 3, 60L, "Adventurer's Guild"), + new Bounty("b_plunderer_5", "Hunt 5 Plunderers", "supplementaries:plunderer", 5, 75L, "Adventurer's Guild"), + new Bounty("b_witch_3", "Slay 3 Witches", "minecraft:witch", 3, 80L, "Adventurer's Guild"), + new Bounty("b_enderman_5", "Slay 5 Endermen", "minecraft:enderman", 5, 90L, "Adventurer's Guild"), + new Bounty("b_wither_skel_3", "Slay 3 Wither Skeletons","minecraft:wither_skeleton", 3, 150L, "Adventurer's Guild"), + new Bounty("b_shulker_3", "Slay 3 Shulkers", "minecraft:shulker", 3, 200L, "Adventurer's Guild"), + new Bounty("b_warden_1", "Slay the Warden", "minecraft:warden", 1, 800L, "Adventurer's Guild"), + new Bounty("b_voidworm_1", "Destroy a Void Worm", "alexsmobs:void_worm", 1, 1200L, "Adventurer's Guild"), + new Bounty("b_warped_mosco_1", "Slay a Warped Mosco", "alexsmobs:warped_mosco", 1, 700L, "Adventurer's Guild"), + new Bounty("b_farseer_1", "Slay the Farseer", "alexsmobs:farseer", 1, 900L, "Adventurer's Guild"), + new Bounty("b_skreecher_1", "Slay a Skreecher", "alexsmobs:skreecher", 1, 400L, "Adventurer's Guild") + ); + + public static Bounty byId(String id) { + for (Bounty b : POOL) if (b.id.equals(id)) return b; + return null; + } + + public enum State { AVAILABLE, ACTIVE, READY, COOLDOWN } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/CivicTier.java b/src/main/java/uk/sijbers/bnstoolkit/data/CivicTier.java new file mode 100644 index 0000000..18afb36 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/CivicTier.java @@ -0,0 +1,53 @@ +package uk.sijbers.bnstoolkit.data; + +import net.minecraft.ChatFormatting; + +import java.util.List; + +/** + * 13-tier civic ladder. Mirrors TIER_CONFIG in + * pack/overrides/kubejs/server_scripts/economy/00_tier_system.js. + * + * Source of truth is still the KubeJS table — this Java copy exists so the + * client-side screens can render the ladder without round-tripping every + * row to the server. The two must be kept in sync; if a value changes + * here, change it there too. + */ +public record CivicTier( + int id, + String name, + long cost, + int wildernessChunks, + int plotSlots, + int dailyBountySlots, + int shopFeeDiscount, + ChatFormatting colour) { + + public static final List LADDER = List.of( + new CivicTier( 1, "Peasant", 0L, 9, 0, 3, 0, ChatFormatting.GRAY), + new CivicTier( 2, "Farmer", 200L, 18, 0, 3, 0, ChatFormatting.GRAY), + new CivicTier( 3, "Citizen", 750L, 35, 1, 3, 0, ChatFormatting.WHITE), + new CivicTier( 4, "Merchant", 2500L, 60, 1, 4, -2, ChatFormatting.YELLOW), + new CivicTier( 5, "Knight", 6500L, 100, 2, 4, -5, ChatFormatting.GREEN), + new CivicTier( 6, "Baron", 15000L, 150, 2, 5, -7, ChatFormatting.AQUA), + new CivicTier( 7, "Viscount", 35000L, 220, 3, 5, -9, ChatFormatting.BLUE), + new CivicTier( 8, "Earl", 75000L, 300, 3, 6, -11, ChatFormatting.DARK_PURPLE), + new CivicTier( 9, "Marquess", 150000L, 400, 4, 6, -13, ChatFormatting.GOLD), + new CivicTier(10, "Duke", 300000L, 525, 5, 7, -15, ChatFormatting.GOLD), + new CivicTier(11, "Archduke", 700000L, 700, 6, 7, -17, ChatFormatting.RED), + new CivicTier(12, "Grand Duke", 1500000L, 900, 7, 8, -19, ChatFormatting.RED), + new CivicTier(13, "Sovereign", 10000000L, 1500, 10, 10, -25, ChatFormatting.LIGHT_PURPLE) + ); + + public static final int MAX = 13; + public static final int BAZAAR_BASE_FEE_PCT = 25; + + public static CivicTier byId(int id) { + if (id < 1 || id > MAX) return LADDER.get(0); + return LADDER.get(id - 1); + } + + public int effectiveShopFeePct() { + return Math.max(0, BAZAAR_BASE_FEE_PCT + shopFeeDiscount); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java b/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java new file mode 100644 index 0000000..1cfde7b --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java @@ -0,0 +1,28 @@ +package uk.sijbers.bnstoolkit.data; + +import java.util.List; + +/** + * Spawn-region plot catalogue. Mirrors PLOTS in + * pack/overrides/kubejs/server_scripts/plots.js. + */ +public record Plot(String id, String name, String size, long price, String dimension, List chunks) { + + public static final List ALL = List.of( + new Plot("p001", "Market Stall 1", "small", 100L, "minecraft:overworld", + List.of(new int[]{10, 10})), + new Plot("p002", "Corner Lot", "medium", 250L, "minecraft:overworld", + List.of(new int[]{12, 10}, new int[]{12, 11})), + new Plot("p003", "Anchor Plot", "large", 600L, "minecraft:overworld", + List.of(new int[]{14, 10}, new int[]{14, 11}, new int[]{15, 10}, new int[]{15, 11})) + ); + + public static Plot byId(String id) { + for (Plot p : ALL) if (p.id.equals(id)) return p; + return null; + } + + public long refundOnRelease() { + return price / 2; + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/SellCatalogue.java b/src/main/java/uk/sijbers/bnstoolkit/data/SellCatalogue.java new file mode 100644 index 0000000..8399e02 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/SellCatalogue.java @@ -0,0 +1,60 @@ +package uk.sijbers.bnstoolkit.data; + +import net.minecraft.resources.ResourceLocation; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Bazaar sell prices. Mirrors SELL_PRICES in + * pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js. + * + * The map is insertion-ordered so the screen grid matches the KubeJS + * config order without separate display ordering. + */ +public final class SellCatalogue { + private SellCatalogue() {} + + public static final int DR_STEP = 64; + public static final double DR_DECAY = 0.10; + public static final double DR_FLOOR = 0.50; + + public static final Map PRICES; + static { + Map m = new LinkedHashMap<>(); + m.put(ResourceLocation.parse("minecraft:diamond"), 10L); + m.put(ResourceLocation.parse("minecraft:netherite_ingot"), 100L); + m.put(ResourceLocation.parse("minecraft:emerald"), 1L); + m.put(ResourceLocation.parse("minecraft:gold_ingot"), 2L); + m.put(ResourceLocation.parse("minecraft:iron_ingot"), 1L); + m.put(ResourceLocation.parse("minecraft:lapis_lazuli"), 1L); + m.put(ResourceLocation.parse("minecraft:redstone"), 1L); + m.put(ResourceLocation.parse("minecraft:coal"), 1L); + m.put(ResourceLocation.parse("minecraft:ancient_debris"), 500L); + PRICES = java.util.Collections.unmodifiableMap(m); + } + + /** + * Effective per-unit payout matching the KubeJS formula: + * midSteps = (sold + count/2) / DR_STEP + * drMult = max(DR_FLOOR, 1 - midSteps * DR_DECAY) + * payout = floor(base * drMult * (1 - feePct/100) * count) + */ + public static long effectivePayout(ResourceLocation item, long lifetimeSold, long count, int feePct) { + Long base = PRICES.get(item); + if (base == null) return 0L; + long midSteps = (lifetimeSold + count / 2L) / DR_STEP; + double drMult = Math.max(DR_FLOOR, 1.0 - midSteps * DR_DECAY); + double net = base * drMult * (1.0 - feePct / 100.0); + return (long) Math.floor(net * count); + } + + /** Display-only single-unit price for a player at their current sold count + fee. */ + public static long singleUnitPriceDisplay(ResourceLocation item, long lifetimeSold, int feePct) { + Long base = PRICES.get(item); + if (base == null) return 0L; + long steps = lifetimeSold / DR_STEP; + double drMult = Math.max(DR_FLOOR, 1.0 - steps * DR_DECAY); + return (long) Math.floor(base * drMult * (1.0 - feePct / 100.0)); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/SpurBalance.java b/src/main/java/uk/sijbers/bnstoolkit/data/SpurBalance.java new file mode 100644 index 0000000..f527602 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/SpurBalance.java @@ -0,0 +1,65 @@ +package uk.sijbers.bnstoolkit.data; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Sums a player's inventory into a "spurs" balance using the Numismatics + * coin denomination table. + * + * Denominations match the Numismatics mod (1 spur = 1 base unit): + * spur = 1, bevel = 8, sprocket = 16, cog = 64, crown = 512, sun = 4096 + * + * Mirrors the COIN_VALUES table in pack/overrides/kubejs/server_scripts/plots.js. + * Lives here in Java so the HUD can render the balance every frame without + * touching the server. + */ +public final class SpurBalance { + private SpurBalance() {} + + public static final Map COIN_VALUES; + static { + Map m = new LinkedHashMap<>(); + m.put(ResourceLocation.parse("numismatics:spur"), 1L); + m.put(ResourceLocation.parse("numismatics:bevel"), 8L); + m.put(ResourceLocation.parse("numismatics:sprocket"), 16L); + m.put(ResourceLocation.parse("numismatics:cog"), 64L); + m.put(ResourceLocation.parse("numismatics:crown"), 512L); + m.put(ResourceLocation.parse("numismatics:sun"), 4096L); + COIN_VALUES = java.util.Collections.unmodifiableMap(m); + } + + /** Inventory-only scan (excludes ender chests, curios, backpacks). */ + public static long totalSpurs(Player player) { + Inventory inv = player.getInventory(); + long total = 0L; + for (int i = 0; i < inv.getContainerSize(); i++) { + ItemStack stack = inv.getItem(i); + if (stack.isEmpty()) continue; + ResourceLocation id = stack.getItemHolder().unwrapKey().map(k -> k.location()).orElse(null); + if (id == null) continue; + Long value = COIN_VALUES.get(id); + if (value == null) continue; + total += (long) stack.getCount() * value; + } + return total; + } + + /** Count of a specific raw item id in the player's inventory (for sell screen prechecks). */ + public static int countItem(Player player, ResourceLocation itemId) { + Inventory inv = player.getInventory(); + int total = 0; + for (int i = 0; i < inv.getContainerSize(); i++) { + ItemStack stack = inv.getItem(i); + if (stack.isEmpty()) continue; + ResourceLocation id = stack.getItemHolder().unwrapKey().map(k -> k.location()).orElse(null); + if (itemId.equals(id)) total += stack.getCount(); + } + return total; + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java b/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java index ac4aab5..ca212d0 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java +++ b/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java @@ -4,29 +4,33 @@ import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.registration.PayloadRegistrar; import uk.sijbers.bnstoolkit.BnsToolkit; -import uk.sijbers.bnstoolkit.network.c2s.RequestTierStatePacket; +import uk.sijbers.bnstoolkit.network.c2s.RequestEconomySnapshotPacket; +import uk.sijbers.bnstoolkit.network.c2s.RunBnsCommandPacket; +import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket; +import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket; +import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket; +import uk.sijbers.bnstoolkit.network.s2c.ToastPacket; /** * Central registration for the toolkit's S2C and C2S packets. * - * Spec ref: docs/bnstoolkit-architecture.md §5 (network contract). + * Two C2S packets cover the entire client-server contract: + * - {@link RequestEconomySnapshotPacket} — "send me my snapshot" + * - {@link RunBnsCommandPacket} — "run this whitelisted /bns verb" * - * M0 ships one round-trip pair as a smoke-test of the wiring: - * - C2S {@link RequestTierStatePacket} ("hey server, what's my tier+balance?") - * - S2C {@link TierStateUpdatePacket} (answer + future push channel) + * Four S2C snapshots feed the screens + HUD: + * - {@link TierStateUpdatePacket} — tier + balance + fee + * - {@link SellSnapshotPacket} — lifetime-sold per item + * - {@link BountySnapshotPacket} — daily pool + active + completed + cap + * - {@link PlotSnapshotPacket} — owners + cap + my owned ids * - * The rest of the contract (bounty / plot / shop / quest log) will land in - * M1..M4 — each milestone adds its own packet pair under the matching - * sub-package. - * - * Versioning: bumped via the version() call below whenever the wire format - * changes incompatibly. Same protocol pattern FTB uses. + * Plus a fire-and-forget {@link ToastPacket} for command result UX. */ public final class BnsNetwork { private BnsNetwork() {} - public static final String VERSION = "0.1"; + public static final String VERSION = "0.2"; public static void register(IEventBus modBus) { modBus.addListener(BnsNetwork::onRegisterPayloads); @@ -35,18 +39,35 @@ public final class BnsNetwork { private static void onRegisterPayloads(RegisterPayloadHandlersEvent event) { final PayloadRegistrar reg = event.registrar(BnsToolkit.MOD_ID).versioned(VERSION); - // C2S: request my current tier + balance. reg.playToServer( - RequestTierStatePacket.TYPE, - RequestTierStatePacket.STREAM_CODEC, - RequestTierStatePacket::handle); + RequestEconomySnapshotPacket.TYPE, + RequestEconomySnapshotPacket.STREAM_CODEC, + RequestEconomySnapshotPacket::handle); + reg.playToServer( + RunBnsCommandPacket.TYPE, + RunBnsCommandPacket.STREAM_CODEC, + RunBnsCommandPacket::handle); - // S2C: server pushes tier + balance (either in response to a request - // or unsolicited when something changes). reg.playToClient( TierStateUpdatePacket.TYPE, TierStateUpdatePacket.STREAM_CODEC, TierStateUpdatePacket::handle); + reg.playToClient( + SellSnapshotPacket.TYPE, + SellSnapshotPacket.STREAM_CODEC, + SellSnapshotPacket::handle); + reg.playToClient( + BountySnapshotPacket.TYPE, + BountySnapshotPacket.STREAM_CODEC, + BountySnapshotPacket::handle); + reg.playToClient( + PlotSnapshotPacket.TYPE, + PlotSnapshotPacket.STREAM_CODEC, + PlotSnapshotPacket::handle); + reg.playToClient( + ToastPacket.TYPE, + ToastPacket.STREAM_CODEC, + ToastPacket::handle); BnsToolkit.LOG.info("[bnstoolkit/net] registered payloads v{}", VERSION); } diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/NetSend.java b/src/main/java/uk/sijbers/bnstoolkit/network/NetSend.java new file mode 100644 index 0000000..7f018e5 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/NetSend.java @@ -0,0 +1,21 @@ +package uk.sijbers.bnstoolkit.network; + +import net.neoforged.neoforge.network.PacketDistributor; +import uk.sijbers.bnstoolkit.network.c2s.RequestEconomySnapshotPacket; +import uk.sijbers.bnstoolkit.network.c2s.RunBnsCommandPacket; + +/** + * Client-side senders for the two C2S packets. Centralised here so the + * screens don't all have to know the packet types. + */ +public final class NetSend { + private NetSend() {} + + public static void requestSnapshot(String kind) { + PacketDistributor.sendToServer(new RequestEconomySnapshotPacket(kind)); + } + + public static void runBns(String args) { + PacketDistributor.sendToServer(new RunBnsCommandPacket(args)); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java new file mode 100644 index 0000000..f11d0f7 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java @@ -0,0 +1,206 @@ +package uk.sijbers.bnstoolkit.network; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.PacketDistributor; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.data.Bounty; +import uk.sijbers.bnstoolkit.data.CivicTier; +import uk.sijbers.bnstoolkit.data.Plot; +import uk.sijbers.bnstoolkit.data.SpurBalance; +import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket; +import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket; +import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket; +import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket; +import uk.sijbers.bnstoolkit.network.s2c.ToastPacket; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Server-side bridge between the bnstoolkit network packets and the + * KubeJS-owned economy state. + * + * Reads: pull straight from player.persistentData (the NBT KubeJS writes). + * Writes: route through the existing /bns chat commands via the server's + * command stack — KubeJS is registered as a command-source handler, + * so this delegates business logic instead of re-implementing it. + * + * The verb whitelist below is what lets the screens trigger the SAME + * verbs the player can type — and nothing else. + */ +public final class ServerEconomyBridge { + private ServerEconomyBridge() {} + + private static final Set SAFE_VERBS = Set.of( + "tier upgrade", + "sell ", // followed by item id + count + "bounty accept ", + "bounty cancel ", + "bounty turnin ", + "plot buy ", + "plot release " + ); + + // ─── Routed commands ───────────────────────────────────────────── + + public static void runWhitelisted(Player player, String args) { + if (!(player instanceof ServerPlayer sp)) return; + if (args == null || args.isEmpty()) return; + + boolean ok = false; + for (String prefix : SAFE_VERBS) { + if (args.equals(prefix.trim()) || args.startsWith(prefix)) { ok = true; break; } + } + if (!ok) { + BnsToolkit.LOG.warn("[bns/bridge] rejected non-whitelisted verb from {}: {}", sp.getName().getString(), args); + return; + } + + CommandSourceStack src = sp.createCommandSourceStack(); + String full = "bns " + args; + BnsToolkit.LOG.info("[bns/bridge] {} runs '{}'", sp.getName().getString(), full); + sp.server.getCommands().performPrefixedCommand(src, full); + + // After any state-mutating verb, push a fresh snapshot relevant to it. + if (args.startsWith("tier")) { + pushTierUpdate(sp); + } else if (args.startsWith("sell")) { + pushTierUpdate(sp); + pushSellSnapshot(sp); + } else if (args.startsWith("bounty")) { + pushTierUpdate(sp); + pushBountySnapshot(sp); + } else if (args.startsWith("plot")) { + pushTierUpdate(sp); + pushPlotSnapshot(sp); + } + } + + // ─── Snapshot pushes ───────────────────────────────────────────── + + public static void pushSnapshot(Player player, String kind) { + if (!(player instanceof ServerPlayer sp)) return; + switch (kind) { + case "TIER" -> pushTierUpdate(sp); + case "SELL" -> { pushTierUpdate(sp); pushSellSnapshot(sp); } + case "BOUNTY" -> { pushTierUpdate(sp); pushBountySnapshot(sp); } + case "PLOT" -> { pushTierUpdate(sp); pushPlotSnapshot(sp); } + default -> { pushTierUpdate(sp); pushSellSnapshot(sp); pushBountySnapshot(sp); pushPlotSnapshot(sp); } + } + } + + public static void pushTierUpdate(ServerPlayer sp) { + CompoundTag data = sp.getPersistentData(); + int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1; + if (tier < 1 || tier > CivicTier.MAX) tier = 1; + CivicTier cfg = CivicTier.byId(tier); + long spurs = SpurBalance.totalSpurs(sp); + int fee = cfg.effectiveShopFeePct(); + PacketDistributor.sendToPlayer(sp, new TierStateUpdatePacket(tier, spurs, fee)); + } + + public static void pushSellSnapshot(ServerPlayer sp) { + CompoundTag data = sp.getPersistentData(); + Map sold = new HashMap<>(); + if (data.contains("bnsSales")) { + CompoundTag map = data.getCompound("bnsSales"); + for (String key : map.getAllKeys()) { + sold.put(key, (long) map.getInt(key)); + } + } + PacketDistributor.sendToPlayer(sp, new SellSnapshotPacket(sold)); + } + + public static void pushBountySnapshot(ServerPlayer sp) { + CompoundTag data = sp.getPersistentData(); + List poolIds = new ArrayList<>(); + Map active = new HashMap<>(); + List completed = new ArrayList<>(); + + if (data.contains("bnsBounties")) { + CompoundTag bnt = data.getCompound("bnsBounties"); + if (bnt.contains("pool")) { + ListTag pool = bnt.getList("pool", Tag.TAG_STRING); + for (int i = 0; i < pool.size(); i++) poolIds.add(pool.getString(i)); + } + if (bnt.contains("active")) { + CompoundTag act = bnt.getCompound("active"); + for (String id : act.getAllKeys()) { + CompoundTag e = act.getCompound(id); + int progress = e.getInt("progress"); + int target = e.getInt("target"); + long reward = e.getInt("reward"); + active.put(id, new BountySnapshotPacket.ActiveEntry(progress, target, reward)); + } + } + if (bnt.contains("completed")) { + CompoundTag cmp = bnt.getCompound("completed"); + for (String id : cmp.getAllKeys()) completed.add(id); + } + } + + // Slot cap from tier + int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1; + if (tier < 1 || tier > CivicTier.MAX) tier = 1; + int slotCap = CivicTier.byId(tier).dailyBountySlots(); + + PacketDistributor.sendToPlayer(sp, new BountySnapshotPacket(poolIds, active, completed, slotCap)); + } + + public static void pushPlotSnapshot(ServerPlayer sp) { + // Plot ownership is tracked in level.persistentData (world scope), not per-player. + // Read it via the world's saved data CompoundTag the KubeJS code writes to. + Map owners = new HashMap<>(); + for (Plot p : Plot.ALL) owners.put(p.id(), ""); + + net.minecraft.server.level.ServerLevel level = sp.serverLevel(); + // KubeJS Persistent Data (world scope) lives at level.getDataStorage with key 'kubejs' + // but to avoid coupling, read from the per-player bnsOwnedPlots NBT array we set + // alongside ownership in plots.js. Fall back to empty. + List ownedIds = new ArrayList<>(); + CompoundTag pdata = sp.getPersistentData(); + if (pdata.contains("bnsOwnedPlots")) { + ListTag list = pdata.getList("bnsOwnedPlots", Tag.TAG_STRING); + for (int i = 0; i < list.size(); i++) { + String id = list.getString(i); + ownedIds.add(id); + owners.put(id, sp.getName().getString()); + } + } + // Mark plots owned by other players as taken — read everybody's + // persistentData via the level's player list. + for (ServerPlayer other : sp.server.getPlayerList().getPlayers()) { + if (other == sp) continue; + CompoundTag od = other.getPersistentData(); + if (!od.contains("bnsOwnedPlots")) continue; + ListTag list = od.getList("bnsOwnedPlots", Tag.TAG_STRING); + for (int i = 0; i < list.size(); i++) { + String id = list.getString(i); + if (!ownedIds.contains(id)) owners.put(id, other.getName().getString()); + } + } + + int tier = pdata.contains("bnsTier") ? pdata.getInt("bnsTier") : 1; + if (tier < 1 || tier > CivicTier.MAX) tier = 1; + int slotCap = CivicTier.byId(tier).plotSlots(); + + PacketDistributor.sendToPlayer(sp, new PlotSnapshotPacket(owners, slotCap, ownedIds)); + } + + public static void toast(ServerPlayer sp, int status, String title, String body) { + PacketDistributor.sendToPlayer(sp, new ToastPacket(status, title, body)); + } + + // Defensive: bounties pool format uses the same shape we know about + @SuppressWarnings("unused") + private static Bounty resolveBounty(String id) { return Bounty.byId(id); } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java new file mode 100644 index 0000000..15c7093 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java @@ -0,0 +1,42 @@ +package uk.sijbers.bnstoolkit.network.c2s; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.network.ServerEconomyBridge; + +/** + * Client -> server: "Open my screen, send me the current snapshot." + * + * Kinds: + * TIER — push TierStateUpdatePacket + * SELL — push SellSnapshotPacket + * BOUNTY — push BountySnapshotPacket + * PLOT — push PlotSnapshotPacket + * ALL — push all four (e.g. on first login) + * + * Sending an enum int is more compact than a string but the count is + * tiny so we just use a String for debuggability. + */ +public record RequestEconomySnapshotPacket(String kind) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "request_snapshot")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, RequestEconomySnapshotPacket::kind, + RequestEconomySnapshotPacket::new); + + @Override + public Type type() { + return TYPE; + } + + public static void handle(RequestEconomySnapshotPacket pkt, IPayloadContext ctx) { + ctx.enqueueWork(() -> ServerEconomyBridge.pushSnapshot(ctx.player(), pkt.kind)); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestTierStatePacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestTierStatePacket.java deleted file mode 100644 index 613d9bc..0000000 --- a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestTierStatePacket.java +++ /dev/null @@ -1,42 +0,0 @@ -package uk.sijbers.bnstoolkit.network.c2s; - -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.StreamCodec; -import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -import net.minecraft.resources.ResourceLocation; -import net.neoforged.neoforge.network.handling.IPayloadContext; -import uk.sijbers.bnstoolkit.BnsToolkit; - -/** - * Client -> server: "Send me my tier + balance." - * - * Empty payload (no fields). The server identifies the player from - * {@link IPayloadContext#player()}. The response is a S2C - * {@code TierStateUpdatePacket}. - * - * TODO(matt M1): once the KubeJS bridge ships, the handler will: - * 1. Read player.persistentData.bnsTier (the KubeJS-owned NBT) - * 2. Sum Numismatics coin slots into a spurs total - * 3. Send a TierStateUpdatePacket back to the same player - * - * For now the handler just logs the request — proves the wire works. - */ -public record RequestTierStatePacket() implements CustomPacketPayload { - public static final Type TYPE = new Type<>( - ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "request_tier_state")); - - public static final StreamCodec STREAM_CODEC = - StreamCodec.unit(new RequestTierStatePacket()); - - @Override - public Type type() { - return TYPE; - } - - public static void handle(RequestTierStatePacket pkt, IPayloadContext ctx) { - ctx.enqueueWork(() -> { - // TODO(matt M1): read NBT + Numismatics balance, reply with TierStateUpdatePacket - BnsToolkit.LOG.info("[bnstoolkit/net] RequestTierState from {}", ctx.player().getName().getString()); - }); - } -} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java new file mode 100644 index 0000000..dca2ba8 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java @@ -0,0 +1,47 @@ +package uk.sijbers.bnstoolkit.network.c2s; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.network.ServerEconomyBridge; + +/** + * Client -> server: invoke one of the existing KubeJS-owned /bns + * subcommands on behalf of the player. Handler validates the verb against + * a whitelist, then runs it via the dedicated server's command stack so + * KubeJS picks it up exactly as if the player typed it. + * + * Examples sent by the screens: + * "tier upgrade" + * "sell minecraft:diamond 8" + * "bounty accept b_zombie_10" + * "bounty cancel b_zombie_10" + * "bounty turnin b_zombie_10" + * "plot buy p001" + * "plot release p001" + * + * The verb whitelist is enforced server-side in + * {@link ServerEconomyBridge#runWhitelisted}. + */ +public record RunBnsCommandPacket(String args) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "run_bns")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, RunBnsCommandPacket::args, + RunBnsCommandPacket::new); + + @Override + public Type type() { + return TYPE; + } + + public static void handle(RunBnsCommandPacket pkt, IPayloadContext ctx) { + ctx.enqueueWork(() -> ServerEconomyBridge.runWhitelisted(ctx.player(), pkt.args)); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java new file mode 100644 index 0000000..37f0d48 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java @@ -0,0 +1,67 @@ +package uk.sijbers.bnstoolkit.network.s2c; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.client.ClientBnsState; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * S2C: today's bounty pool selection (5 ids) + the player's active set + * with kill progress + completed (cooldown) ids + the tier-derived slot cap. + */ +public record BountySnapshotPacket( + List poolIds, + Map active, + List completedIds, + int slotCap) implements CustomPacketPayload { + + public record ActiveEntry(int progress, int target, long reward) {} + + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "bounty_snapshot")); + + private static final StreamCodec ENTRY_CODEC = + StreamCodec.composite( + ByteBufCodecs.VAR_INT, ActiveEntry::progress, + ByteBufCodecs.VAR_INT, ActiveEntry::target, + ByteBufCodecs.VAR_LONG, ActiveEntry::reward, + ActiveEntry::new); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.list()), BountySnapshotPacket::poolIds, + ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, ENTRY_CODEC), BountySnapshotPacket::active, + ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.list()), BountySnapshotPacket::completedIds, + ByteBufCodecs.VAR_INT, BountySnapshotPacket::slotCap, + BountySnapshotPacket::new); + + @Override + public Type type() { + return TYPE; + } + + public static void handle(BountySnapshotPacket pkt, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + ClientBnsState.dailyPoolIds.clear(); + ClientBnsState.dailyPoolIds.addAll(pkt.poolIds); + + ClientBnsState.activeBounties.clear(); + for (Map.Entry e : pkt.active.entrySet()) { + ActiveEntry v = e.getValue(); + ClientBnsState.activeBounties.put(e.getKey(), + new ClientBnsState.ActiveBounty(v.progress, v.target, v.reward)); + } + ClientBnsState.completedIds.clear(); + ClientBnsState.completedIds.addAll(pkt.completedIds); + ClientBnsState.bountySlotCap = pkt.slotCap; + }); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/PlotSnapshotPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/PlotSnapshotPacket.java new file mode 100644 index 0000000..872432a --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/PlotSnapshotPacket.java @@ -0,0 +1,54 @@ +package uk.sijbers.bnstoolkit.network.s2c; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.client.ClientBnsState; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * S2C: ownership state for every plot id, the player's slot cap (from tier), + * and the list of plot ids the player currently owns. + * + * `owners` value of "" means "available". Non-empty = current owner's name. + */ +public record PlotSnapshotPacket( + Map owners, + int slotCap, + List ownedIds) implements CustomPacketPayload { + + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "plot_snapshot")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, ByteBufCodecs.STRING_UTF8), + PlotSnapshotPacket::owners, + ByteBufCodecs.VAR_INT, + PlotSnapshotPacket::slotCap, + ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.list()), + PlotSnapshotPacket::ownedIds, + PlotSnapshotPacket::new); + + @Override + public Type type() { + return TYPE; + } + + public static void handle(PlotSnapshotPacket pkt, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + ClientBnsState.plotOwners.clear(); + ClientBnsState.plotOwners.putAll(pkt.owners); + ClientBnsState.plotSlotCap = pkt.slotCap; + ClientBnsState.ownedPlotIds.clear(); + ClientBnsState.ownedPlotIds.addAll(pkt.ownedIds); + }); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/SellSnapshotPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/SellSnapshotPacket.java new file mode 100644 index 0000000..762091f --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/SellSnapshotPacket.java @@ -0,0 +1,45 @@ +package uk.sijbers.bnstoolkit.network.s2c; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.client.ClientBnsState; + +import java.util.Map; + +/** + * S2C: lifetime-sold counts per item id, used by the shop screen to show + * accurate per-unit prices after diminishing returns. + * + * Wire format: map of {item id string -> lifetime sold count}. + */ +public record SellSnapshotPacket(Map sold) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "sell_snapshot")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.map(java.util.HashMap::new, ByteBufCodecs.STRING_UTF8, ByteBufCodecs.VAR_LONG), + SellSnapshotPacket::sold, + SellSnapshotPacket::new); + + @Override + public Type type() { + return TYPE; + } + + public static void handle(SellSnapshotPacket pkt, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + ClientBnsState.lifetimeSold.clear(); + for (Map.Entry e : pkt.sold.entrySet()) { + try { + ClientBnsState.lifetimeSold.put(ResourceLocation.parse(e.getKey()), e.getValue()); + } catch (Exception ignored) {} + } + }); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/TierStateUpdatePacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/TierStateUpdatePacket.java index b856225..60defa4 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/TierStateUpdatePacket.java +++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/TierStateUpdatePacket.java @@ -7,20 +7,10 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.ResourceLocation; import net.neoforged.neoforge.network.handling.IPayloadContext; import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.client.ClientBnsState; -/** - * Server -> client: pushes the player's current civic tier (1..13) and - * spurs balance. - * - * Sent in response to {@code RequestTierStatePacket} AND unsolicited - * whenever either value changes server-side (so the HUD updates in real - * time without polling). - * - * TODO(matt M1): client handler will cache the values in a static slot - * read by {@link uk.sijbers.bnstoolkit.client.hud.SpurHud} and any open - * screen. - */ -public record TierStateUpdatePacket(int tier, long spurs) implements CustomPacketPayload { +/** Server -> client: civic tier (1..13), spurs balance, effective shop fee%. */ +public record TierStateUpdatePacket(int tier, long spurs, int effectiveFeePct) implements CustomPacketPayload { public static final Type TYPE = new Type<>( ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "tier_state_update")); @@ -28,6 +18,7 @@ public record TierStateUpdatePacket(int tier, long spurs) implements CustomPacke StreamCodec.composite( ByteBufCodecs.VAR_INT, TierStateUpdatePacket::tier, ByteBufCodecs.VAR_LONG, TierStateUpdatePacket::spurs, + ByteBufCodecs.VAR_INT, TierStateUpdatePacket::effectiveFeePct, TierStateUpdatePacket::new); @Override @@ -37,8 +28,9 @@ public record TierStateUpdatePacket(int tier, long spurs) implements CustomPacke public static void handle(TierStateUpdatePacket pkt, IPayloadContext ctx) { ctx.enqueueWork(() -> { - // TODO(matt M1): stash in a client-side state holder for HUD + screens. - BnsToolkit.LOG.debug("[bnstoolkit/net] TierStateUpdate tier={} spurs={}", pkt.tier, pkt.spurs); + ClientBnsState.tier = pkt.tier; + ClientBnsState.spurs = pkt.spurs; + ClientBnsState.effectiveFeePct = pkt.effectiveFeePct; }); } } diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/ToastPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/ToastPacket.java new file mode 100644 index 0000000..4ff9a95 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/ToastPacket.java @@ -0,0 +1,54 @@ +package uk.sijbers.bnstoolkit.network.s2c; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.toasts.SystemToast; +import net.minecraft.network.chat.Component; +import uk.sijbers.bnstoolkit.BnsToolkit; + +/** + * S2C: short toast notification to the player. + * + * Used by the server-side bridge to report results of routed /bns + * commands the player triggered from a screen (e.g. "Upgrade succeeded", + * "Insufficient funds", "Bounty turned in: +800 spurs"). + * + * Status codes: 0=info, 1=success, 2=failure. The client maps these to + * SystemToast colour bands. + */ +public record ToastPacket(int status, String title, String body) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "toast")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.VAR_INT, ToastPacket::status, + ByteBufCodecs.STRING_UTF8, ToastPacket::title, + ByteBufCodecs.STRING_UTF8, ToastPacket::body, + ToastPacket::new); + + @Override + public Type type() { + return TYPE; + } + + public static void handle(ToastPacket pkt, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + Minecraft mc = Minecraft.getInstance(); + if (mc == null) return; + SystemToast.SystemToastId id = switch (pkt.status) { + case 1 -> SystemToast.SystemToastId.PERIODIC_NOTIFICATION; + case 2 -> SystemToast.SystemToastId.PACK_LOAD_FAILURE; + default -> SystemToast.SystemToastId.NARRATOR_TOGGLE; + }; + mc.getToasts().addToast(SystemToast.multiline(mc, id, + Component.literal(pkt.title), + Component.literal(pkt.body))); + }); + } +} diff --git a/src/main/resources/assets/bnstoolkit/lang/en_us.json b/src/main/resources/assets/bnstoolkit/lang/en_us.json index 61fa4d3..91f8238 100644 --- a/src/main/resources/assets/bnstoolkit/lang/en_us.json +++ b/src/main/resources/assets/bnstoolkit/lang/en_us.json @@ -2,11 +2,13 @@ "key.category.bnstoolkit": "Brass and Sigil", "key.bnstoolkit.tier": "Open Tier Ladder", "key.bnstoolkit.bounties": "Open Bounty Board", - "key.bnstoolkit.plots": "Open Plot Map", + "key.bnstoolkit.plots": "Open Plot Office", + "key.bnstoolkit.shop": "Open Bazaar Shop", + "key.bnstoolkit.questlog": "Open Quest Log", "screen.bnstoolkit.tier_upgrade": "Civic Tier", "screen.bnstoolkit.bounty_board": "Bounty Board", "screen.bnstoolkit.quest_log": "Quest Log", - "screen.bnstoolkit.plot_purchase": "Plots", + "screen.bnstoolkit.plot_purchase": "Plot Office", "screen.bnstoolkit.shop_browser": "Bazaar" }