diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index a11e1c92..8f2ceaca 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -130,7 +130,7 @@

Stances and combat rules

9. States And Government

-

A state is the political actor behind settlements, claims, wars, and allies. Create one with /bannermod state create <name>, inspect with list and info, set capital with setcapital, and change status with status. War Room state screens now show a visible ledger line for waiting-sync, select-first, read-only authority, and next server-checked steps. If you only want another player to share claim and settlement access, use the claim editor's Trusted Members list instead of making them a co-leader.

+

A state is the political actor behind settlements, claims, wars, and allies. Create one with /bannermod state create <name>, inspect with list and info, set capital with setcapital, and change status with status. War Room state screens now show a visible ledger line for waiting-sync, select-first, read-only authority, and next server-checked steps. Charter opens a dedicated editor that explains where the text appears, and Add co-leader now opens an online player picker while keeping manual nickname or UUID entry for offline targets. If you only want another player to share claim and settlement access, use the claim editor's Trusted Members list instead of making them a co-leader.

Government forms

Monarchy keeps core political authority leader-only. Republic lets co-leaders share authority for status, capital, colors, charter, claim edits, ally invites, siege placement, and legal war outcomes. Operators can perform explicitly admin-only outcomes. Trusted Members are different: they may build and manage local work areas in the claim, but they do not get claim-edit or war/state authority.

Promotion

@@ -273,7 +273,7 @@

Стойки и правила боя

9. Государство и правление

-

Государство — политическое лицо поселений, участков, войн и союзов. Создание: /bannermod state create <название>. Просмотр: list и info. Столица: setcapital. Статус: status. Если нужно просто поделиться доступом к клейму и поселению, используй список Доверенные игроки в редакторе клейма, а не со-лидера.

+

Государство — политическое лицо поселений, участков, войн и союзов. Создание: /bannermod state create <название>. Просмотр: list и info. Столица: setcapital. Статус: status. Кнопка Хартия теперь открывает отдельный редактор и сразу объясняет, где будет виден текст, а Добавить со-лидера открывает список онлайн-игроков, но сохраняет ручной ввод ника или UUID для оффлайн-целей. Если нужно просто поделиться доступом к клейму и поселению, используй список Доверенные игроки в редакторе клейма, а не со-лидера.

Формы правления

Монархия оставляет основные решения лидеру. Республика даёт соправителям общие полномочия: статус, столица, цвета, устав, правки участков, приглашение союзников, постановка осады и законные итоги войны. Операторы сервера могут выполнять действия, явно отмеченные как административные. Доверенные игроки работают иначе: они могут строить и управлять местными рабочими зонами в клейме, но не получают власти над государством, дипломатией или войной.

Повышение

diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java new file mode 100644 index 00000000..b08f60f0 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java @@ -0,0 +1,131 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.client.military.gui.component.RecruitsMultiLineEditBox; +import com.talhanation.bannermod.war.registry.PoliticalRegistryValidation; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.util.List; +import java.util.function.Consumer; + +public class PoliticalEntityCharterScreen extends Screen { + private static final int W = 320; + private static final int H = 224; + + private final Screen parent; + private final Consumer onSubmit; + private final String initialValue; + private int guiLeft; + private int guiTop; + private RecruitsMultiLineEditBox charterBox; + + public PoliticalEntityCharterScreen(Screen parent, String initialValue, Consumer onSubmit) { + super(Component.translatable("gui.bannermod.states.dialog.charter.title")); + this.parent = parent; + this.onSubmit = onSubmit; + this.initialValue = initialValue == null ? "" : initialValue; + } + + @Override + protected void init() { + super.init(); + this.guiLeft = (this.width - W) / 2; + this.guiTop = Math.max(8, (this.height - H) / 2); + + this.charterBox = new RecruitsMultiLineEditBox(font, guiLeft + 14, guiTop + 68, W - 28, 100, Component.empty(), Component.empty()); + this.charterBox.setValue(this.initialValue); + this.charterBox.setEnableEditing(true); + this.charterBox.setCharacterLimit(PoliticalRegistryValidation.MAX_CHARTER_LENGTH); + this.charterBox.setFocused(true); + addRenderableWidget(this.charterBox); + setInitialFocus(this.charterBox); + + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.submit"), button -> submit()) + .bounds(guiLeft + 14, guiTop + H - 28, 88, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.dialog.charter.clear"), button -> clearDraft()) + .bounds(guiLeft + 116, guiTop + H - 28, 88, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.back"), button -> onClose()) + .bounds(guiLeft + W - 102, guiTop + H - 28, 88, 20) + .build()); + } + + private void submit() { + this.onSubmit.accept(this.charterBox.getValue().trim()); + onClose(); + } + + private void clearDraft() { + this.charterBox.setValue(""); + this.charterBox.setFocused(true); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, width, height, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, guiLeft, guiTop, W, H); + MilitaryGuiStyle.titleStrip(graphics, guiLeft + 8, guiTop + 8, W - 16, 14); + MilitaryGuiStyle.parchmentInset(graphics, guiLeft + 10, guiTop + 24, W - 20, 36); + MilitaryGuiStyle.insetPanel(graphics, guiLeft + 12, guiTop + 66, W - 24, 104); + + MilitaryGuiStyle.drawCenteredTitle(graphics, font, title, guiLeft, guiTop + 11, W); + drawWrapped(graphics, + Component.translatable("gui.bannermod.states.dialog.charter.subtitle"), + guiLeft + 16, + guiTop + 30, + W - 32, + MilitaryGuiStyle.TEXT_DARK); + graphics.drawString(font, + Component.translatable("gui.bannermod.states.dialog.charter.prompt", PoliticalRegistryValidation.MAX_CHARTER_LENGTH), + guiLeft + 14, + guiTop + 58, + MilitaryGuiStyle.TEXT_MUTED, + false); + + if (this.charterBox != null) { + String count = Component.translatable( + "gui.bannermod.states.dialog.charter.count", + this.charterBox.getValue().length(), + PoliticalRegistryValidation.MAX_CHARTER_LENGTH).getString(); + graphics.drawString(font, count, guiLeft + 14, guiTop + 176, MilitaryGuiStyle.TEXT_MUTED, false); + Component preview = this.charterBox.getValue().isBlank() + ? Component.translatable("gui.bannermod.states.dialog.charter.empty") + : Component.translatable("gui.bannermod.states.dialog.charter.preview", this.charterBox.getValue().replaceAll("\\s+", " ").trim()); + graphics.drawString(font, + font.plainSubstrByWidth(preview.getString(), W - 32), + guiLeft + 14, + guiTop + 188, + this.charterBox.getValue().isBlank() ? MilitaryGuiStyle.TEXT_MUTED : MilitaryGuiStyle.TEXT_DARK, + false); + } + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private int drawWrapped(GuiGraphics graphics, Component text, int x, int y, int width, int color) { + List lines = this.font.split(text, width); + for (net.minecraft.util.FormattedCharSequence line : lines) { + graphics.drawString(this.font, line, x, y, color, false); + y += 10; + } + return y; + } + + @Override + public void onClose() { + if (parent != null) { + this.minecraft.setScreen(parent); + } else { + super.onClose(); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java new file mode 100644 index 00000000..dbee15a6 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java @@ -0,0 +1,226 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.client.military.ClientManager; +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.client.military.gui.widgets.ScrollDropDownMenu; +import com.talhanation.bannermod.network.messages.war.MessageUpdateCoLeader; +import com.talhanation.bannermod.persistence.military.RecruitsPlayerInfo; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FastColor; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +public class PoliticalEntityCoLeaderPickerScreen extends Screen { + private static final int W = 292; + private static final int H = 172; + + private final Screen parent; + private final UUID entityId; + private final UUID leaderUuid; + private final List existingCoLeaders; + private final List candidates = new ArrayList<>(); + private int guiLeft; + private int guiTop; + private int lastOnlinePlayersVersion = -1; + private ScrollDropDownMenu playerDropdown; + private Button grantButton; + @Nullable + private RecruitsPlayerInfo selectedPlayer; + + public PoliticalEntityCoLeaderPickerScreen(Screen parent, UUID entityId, @Nullable UUID leaderUuid, List existingCoLeaders) { + super(Component.translatable("gui.bannermod.states.dialog.co_leader_add.title")); + this.parent = parent; + this.entityId = entityId; + this.leaderUuid = leaderUuid; + this.existingCoLeaders = existingCoLeaders == null ? List.of() : List.copyOf(existingCoLeaders); + } + + @Override + protected void init() { + super.init(); + this.guiLeft = (this.width - W) / 2; + this.guiTop = Math.max(8, (this.height - H) / 2); + rebuildCandidates(); + + this.playerDropdown = new ScrollDropDownMenu<>(this.selectedPlayer, guiLeft + 16, guiTop + 72, W - 32, 20, this.candidates, + this::displayName, + selected -> { + this.selectedPlayer = selected; + updateButtons(); + }); + this.playerDropdown.setBgFill(FastColor.ARGB32.color(255, 66, 50, 34)); + this.playerDropdown.setBgFillHovered(FastColor.ARGB32.color(255, 104, 79, 54)); + this.playerDropdown.setBgFillSelected(FastColor.ARGB32.color(255, 50, 37, 24)); + this.playerDropdown.setDisplayColor(MilitaryGuiStyle.TEXT); + this.playerDropdown.setOptionTextColor(MilitaryGuiStyle.TEXT); + addRenderableWidget(this.playerDropdown); + + this.grantButton = addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.dialog.co_leader.select"), button -> submitSelected()) + .bounds(guiLeft + 16, guiTop + H - 28, 82, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.dialog.co_leader.manual"), button -> openManualEntry()) + .bounds(guiLeft + 105, guiTop + H - 28, 88, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.back"), button -> onClose()) + .bounds(guiLeft + W - 98, guiTop + H - 28, 82, 20) + .build()); + updateButtons(); + } + + private void rebuildCandidates() { + this.candidates.clear(); + this.lastOnlinePlayersVersion = ClientManager.onlinePlayersVersion; + for (RecruitsPlayerInfo playerInfo : ClientManager.onlinePlayers) { + if (playerInfo == null || playerInfo.getUUID() == null) { + continue; + } + if (playerInfo.getUUID().equals(this.leaderUuid) || this.existingCoLeaders.contains(playerInfo.getUUID())) { + continue; + } + this.candidates.add(copyInfo(playerInfo)); + } + this.candidates.sort(Comparator.comparing(this::displayName, String.CASE_INSENSITIVE_ORDER)); + if (this.selectedPlayer != null && this.selectedPlayer.getUUID() != null) { + this.selectedPlayer = this.candidates.stream() + .filter(candidate -> candidate.getUUID().equals(this.selectedPlayer.getUUID())) + .findFirst() + .orElse(null); + } + } + + @Override + public void tick() { + super.tick(); + if (this.lastOnlinePlayersVersion != ClientManager.onlinePlayersVersion) { + this.init(); + } + } + + private void submitSelected() { + if (this.selectedPlayer == null || this.selectedPlayer.getUUID() == null) { + return; + } + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUpdateCoLeader(this.entityId, this.selectedPlayer.getUUID().toString(), true)); + this.minecraft.setScreen(this.parent); + } + + private void openManualEntry() { + this.minecraft.setScreen(new PoliticalEntityNameInputScreen( + this.parent, + Component.translatable("gui.bannermod.states.dialog.co_leader_add.title"), + Component.translatable("gui.bannermod.states.dialog.co_leader.prompt"), + "", + value -> BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUpdateCoLeader(this.entityId, value, true)), + 36, + false + )); + } + + private void updateButtons() { + if (this.grantButton != null) { + boolean hasSelection = this.selectedPlayer != null && this.selectedPlayer.getUUID() != null; + this.grantButton.active = hasSelection; + this.grantButton.setTooltip(hasSelection ? net.minecraft.client.gui.components.Tooltip.create(Component.translatable("gui.bannermod.states.dialog.co_leader.select.tooltip")) + : net.minecraft.client.gui.components.Tooltip.create(Component.translatable("gui.bannermod.states.dialog.co_leader.empty"))); + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, width, height, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, guiLeft, guiTop, W, H); + MilitaryGuiStyle.titleStrip(graphics, guiLeft + 8, guiTop + 8, W - 16, 14); + MilitaryGuiStyle.parchmentInset(graphics, guiLeft + 10, guiTop + 24, W - 20, 34); + MilitaryGuiStyle.insetPanel(graphics, guiLeft + 14, guiTop + 70, W - 28, 24); + + MilitaryGuiStyle.drawCenteredTitle(graphics, font, title, guiLeft, guiTop + 11, W); + drawWrapped(graphics, Component.translatable("gui.bannermod.states.dialog.co_leader.subtitle"), guiLeft + 16, guiTop + 32, W - 32, MilitaryGuiStyle.TEXT_DARK); + graphics.drawString(font, Component.translatable("gui.bannermod.states.dialog.co_leader.online"), guiLeft + 16, guiTop + 60, MilitaryGuiStyle.TEXT_MUTED, false); + drawWrapped(graphics, + candidates.isEmpty() + ? Component.translatable("gui.bannermod.states.dialog.co_leader.empty") + : Component.translatable("gui.bannermod.states.dialog.co_leader.selected", displayName(this.selectedPlayer)), + guiLeft + 16, + guiTop + 104, + W - 32, + MilitaryGuiStyle.TEXT_MUTED); + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private int drawWrapped(GuiGraphics graphics, Component text, int x, int y, int width, int color) { + List lines = this.font.split(text, width); + for (net.minecraft.util.FormattedCharSequence line : lines) { + graphics.drawString(this.font, line, x, y, color, false); + y += 10; + } + return y; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0 && this.playerDropdown != null && this.playerDropdown.isMouseOver(mouseX, mouseY)) { + this.playerDropdown.onMouseClick(mouseX, mouseY); + return true; + } + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + super.mouseMoved(mouseX, mouseY); + if (this.playerDropdown != null) { + this.playerDropdown.onMouseMove(mouseX, mouseY); + } + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double delta) { + if (this.playerDropdown != null && this.playerDropdown.isMouseOver(mouseX, mouseY) && this.playerDropdown.mouseScrolled(mouseX, mouseY, scrollX, delta)) { + return true; + } + return super.mouseScrolled(mouseX, mouseY, scrollX, delta); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (this.playerDropdown != null && this.playerDropdown.mouseReleased(mouseX, mouseY, button)) { + return true; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + private String displayName(@Nullable RecruitsPlayerInfo playerInfo) { + if (playerInfo == null) { + return Component.translatable("gui.bannermod.common.none").getString(); + } + if (playerInfo.getName() != null && !playerInfo.getName().isBlank()) { + return playerInfo.getName(); + } + return playerInfo.getUUID() == null ? Component.translatable("gui.bannermod.common.none").getString() : playerInfo.getUUID().toString(); + } + + private RecruitsPlayerInfo copyInfo(RecruitsPlayerInfo playerInfo) { + RecruitsPlayerInfo copy = new RecruitsPlayerInfo(playerInfo.getUUID(), playerInfo.getName()); + copy.setOnline(playerInfo.isOnline()); + return copy; + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parent); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java new file mode 100644 index 00000000..c5fe994e --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java @@ -0,0 +1,179 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.war.registry.PoliticalColorParser; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.util.function.Consumer; + +public class PoliticalEntityColorPaletteScreen extends Screen { + private static final int PANEL_W = 252; + private static final int PANEL_H = 194; + private static final String[] PALETTE = { + "F9FFFE", "F9801D", "C74EBD", "3AB3DA", + "FED83D", "80C71F", "F38BAA", "474F52", + "9D9D97", "169C9C", "8932B8", "3C44AA", + "835432", "5E7C16", "B02E26", "1D1D21" + }; + + private final Screen parent; + private final Consumer onSubmit; + private String currentColor; + private int guiLeft; + private int guiTop; + + public PoliticalEntityColorPaletteScreen(Screen parent, String currentColor, Consumer onSubmit) { + super(Component.translatable("gui.bannermod.states.dialog.color.title")); + this.parent = parent; + this.currentColor = currentColor == null ? "" : currentColor.trim(); + this.onSubmit = onSubmit; + } + + @Override + protected void init() { + super.init(); + this.guiLeft = (this.width - PANEL_W) / 2; + this.guiTop = (this.height - PANEL_H) / 2; + + int swatchSize = 28; + int gap = 6; + int gridLeft = this.guiLeft + 22; + int gridTop = this.guiTop + 72; + for (int i = 0; i < PALETTE.length; i++) { + String hex = PALETTE[i]; + int column = i % 4; + int row = i / 4; + addRenderableWidget(new SwatchButton( + gridLeft + column * (swatchSize + gap), + gridTop + row * (swatchSize + gap), + swatchSize, + hex, + button -> choose(hex))); + } + + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.palette.clear"), button -> choose("")) + .bounds(this.guiLeft + 150, this.guiTop + 82, 80, 20) + .tooltip(Tooltip.create(Component.translatable("gui.bannermod.states.palette.clear.tooltip"))) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.palette.custom"), button -> openCustomDialog()) + .bounds(this.guiLeft + 150, this.guiTop + 108, 80, 20) + .tooltip(Tooltip.create(Component.translatable("gui.bannermod.states.palette.custom.tooltip"))) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.back"), button -> onClose()) + .bounds(this.guiLeft + 150, this.guiTop + 134, 80, 20) + .build()); + } + + private void choose(String color) { + this.currentColor = color == null ? "" : color; + this.onSubmit.accept(this.currentColor); + this.minecraft.setScreen(this.parent); + } + + private void applyCustomColor(String color) { + this.currentColor = color == null ? "" : color; + this.onSubmit.accept(this.currentColor); + } + + private void openCustomDialog() { + Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( + this, + Component.translatable("gui.bannermod.states.dialog.color.title"), + Component.translatable("gui.bannermod.states.dialog.color.prompt"), + this.currentColor, + this::applyCustomColor, + 9, + true)); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, this.width, this.height, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, this.guiLeft, this.guiTop, PANEL_W, PANEL_H); + MilitaryGuiStyle.titleStrip(graphics, this.guiLeft + 6, this.guiTop + 6, PANEL_W - 12, 14); + MilitaryGuiStyle.drawCenteredTitle(graphics, this.font, this.title, this.guiLeft, this.guiTop + 9, PANEL_W); + + graphics.drawString(this.font, + Component.translatable("gui.bannermod.states.palette.current", currentLabel()), + this.guiLeft + 18, + this.guiTop + 28, + MilitaryGuiStyle.TEXT_DARK, + false); + graphics.drawString(this.font, + Component.translatable("gui.bannermod.states.palette.hint"), + this.guiLeft + 18, + this.guiTop + 44, + MilitaryGuiStyle.TEXT_MUTED, + false); + + int previewColor = PoliticalColorParser.parseArgb(this.currentColor, 0xFFB8A17A); + graphics.fill(this.guiLeft + 18, this.guiTop + 58, this.guiLeft + 230, this.guiTop + 60, 0x665A4025); + graphics.fill(this.guiLeft + 150, this.guiTop + 30, this.guiLeft + 230, this.guiTop + 72, 0xFF201810); + graphics.fill(this.guiLeft + 154, this.guiTop + 34, this.guiLeft + 226, this.guiTop + 68, previewColor); + graphics.renderOutline(this.guiLeft + 150, this.guiTop + 30, 80, 42, 0xFF8A6A3A); + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private Component currentLabel() { + if (this.currentColor.isBlank()) { + return Component.translatable("gui.bannermod.common.none"); + } + return Component.literal(this.currentColor); + } + + private static boolean sameColor(String left, String right) { + return PoliticalColorParser.parseArgb(left, Integer.MIN_VALUE) + == PoliticalColorParser.parseArgb(right, Integer.MAX_VALUE); + } + + @Override + public void onClose() { + if (this.parent != null) { + this.minecraft.setScreen(this.parent); + } else { + super.onClose(); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private final class SwatchButton extends Button { + private final String hexColor; + private final int argbColor; + + private SwatchButton(int x, int y, int size, String hexColor, OnPress onPress) { + super(x, y, size, size, Component.empty(), onPress, DEFAULT_NARRATION); + this.hexColor = hexColor; + this.argbColor = PoliticalColorParser.parseArgb(hexColor); + setTooltip(Tooltip.create(Component.literal("#" + hexColor))); + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + int x = getX(); + int y = getY(); + int size = getWidth(); + boolean hovered = isHoveredOrFocused(); + boolean selected = sameColor(currentColor, this.hexColor); + int border = selected ? 0xFFE0B86A : hovered ? 0xFFB8A17A : 0xFF5A4025; + + graphics.fill(x, y, x + size, y + size, 0xFF201810); + graphics.fill(x + 2, y + 2, x + size - 2, y + size - 2, this.argbColor); + graphics.renderOutline(x, y, size, size, border); + if (selected) { + Font font = Minecraft.getInstance().font; + graphics.drawCenteredString(font, "*", x + size / 2, y + (size - 8) / 2, 0xFFFFFFFF); + } + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java index f4b7acf5..4bd98571 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java @@ -260,41 +260,42 @@ private void promoteToState() { private void openColorDialog() { if (this.selected == null) return; UUID id = this.selected.id(); - // 9 chars: optional '#' + up to 8 hex digits (covers AARRGGBB). - Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( + Minecraft.getInstance().setScreen(new PoliticalEntityColorPaletteScreen( this, - text("gui.bannermod.states.dialog.color.title"), - text("gui.bannermod.states.dialog.color.prompt"), this.selected.color(), - value -> sendColor(id, value), - 9, - /* allowEmpty */ true + value -> sendColor(id, value) )); } private void openCharterDialog() { if (this.selected == null) return; UUID id = this.selected.id(); - Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( + Minecraft.getInstance().setScreen(new PoliticalEntityCharterScreen( this, - text("gui.bannermod.states.dialog.charter.title"), - text("gui.bannermod.states.dialog.charter.prompt", PoliticalRegistryValidation.MAX_CHARTER_LENGTH), this.selected.charter(), - value -> sendCharter(id, value), - PoliticalRegistryValidation.MAX_CHARTER_LENGTH, - /* allowEmpty */ true + value -> sendCharter(id, value) )); } - private void openCoLeaderDialog(boolean add) { + private void openAddCoLeaderDialog() { + if (this.selected == null) return; + Minecraft.getInstance().setScreen(new PoliticalEntityCoLeaderPickerScreen( + this, + this.selected.id(), + this.selected.leaderUuid(), + this.selected.coLeaderUuids() + )); + } + + private void openRemoveCoLeaderDialog() { if (this.selected == null) return; UUID id = this.selected.id(); Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( this, - text(add ? "gui.bannermod.states.dialog.co_leader_add.title" : "gui.bannermod.states.dialog.co_leader_remove.title"), + text("gui.bannermod.states.dialog.co_leader_remove.title"), text("gui.bannermod.states.dialog.co_leader.prompt"), "", - value -> sendCoLeader(id, value, add), + value -> sendCoLeader(id, value, false), 36, false )); @@ -351,9 +352,9 @@ private List buildManageEntries() { entries.add(new ContextMenuEntry(text("gui.bannermod.states.charter").getString(), this::openCharterDialog, canAct)); entries.add(new ContextMenuEntry(text("gui.bannermod.states.add_co_leader").getString(), - () -> openCoLeaderDialog(true), leader)); + this::openAddCoLeaderDialog, leader)); entries.add(new ContextMenuEntry(text("gui.bannermod.states.remove_co_leader").getString(), - () -> openCoLeaderDialog(false), canRemoveCoLeader)); + this::openRemoveCoLeaderDialog, canRemoveCoLeader)); entries.add(new ContextMenuEntry(text("gui.bannermod.states.promote_state").getString(), this::promoteToState, canPromote)); return entries; @@ -438,6 +439,7 @@ private void renderDetails(GuiGraphics graphics) { text("gui.bannermod.states.detail.co_leader_authority", text(selected.governmentForm().coLeadersShareAuthority() ? "gui.bannermod.states.co_authority.active" : "gui.bannermod.states.co_authority.locked").getString()).getString(), text("gui.bannermod.states.detail.capital", selected.capitalPos() == null ? text("gui.bannermod.common.none").getString() : selected.capitalPos().toShortString()).getString(), text("gui.bannermod.states.detail.color", selected.color().isBlank() ? text("gui.bannermod.common.none").getString() : selected.color()).getString(), + text("gui.bannermod.states.detail.charter", charterSummary(selected)).getString(), text("gui.bannermod.states.detail.region", selected.homeRegion().isBlank() ? text("gui.bannermod.common.none").getString() : selected.homeRegion()).getString(), text("gui.bannermod.states.detail.wars", involvedWarCount(selected)).getString() }; @@ -553,6 +555,13 @@ private String coLeaderSummary(PoliticalEntityRecord entity) { return String.join(", ", names) + suffix; } + private String charterSummary(PoliticalEntityRecord entity) { + if (entity.charter().isBlank()) { + return text("gui.bannermod.common.none").getString(); + } + return entity.charter().replaceAll("\\s+", " ").trim(); + } + private static int involvedWarCount(PoliticalEntityRecord entity) { int count = 0; for (var war : WarClientState.wars()) { diff --git a/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java b/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java index 37d04dcd..b9017b80 100644 --- a/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java +++ b/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.items.civilian; +import com.talhanation.bannermod.bootstrap.BannerModMain; import com.talhanation.bannermod.client.civilian.gui.SettlementSurveyorScreen; +import com.talhanation.bannermod.network.messages.civilian.MessageUseSurveyorBlock; import com.talhanation.bannermod.settlement.building.ZoneRole; import com.talhanation.bannermod.settlement.building.ZoneSelection; import com.talhanation.bannermod.settlement.validation.SurveyorDraftSuggestionService; @@ -65,21 +67,32 @@ public InteractionResult useOn(UseOnContext context) { } if (level.isClientSide) { + // Keep surveyor mode changes and block clicks on the same packet stream so a + // fresh farm click cannot race an older fort session on the server. + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUseSurveyorBlock(context.getHand(), clicked)); return InteractionResult.SUCCESS; } + return InteractionResult.SUCCESS; + } + + public static void handleBlockClick(ServerPlayer player, ItemStack stack, BlockPos clicked) { + if (player == null || stack == null || clicked == null) { + return; + } + ValidationSession session = getOrCreateSession(player, stack); if (session.anchorPos().equals(BlockPos.ZERO)) { SurveyorSessionCodec.write(stack, session.withAnchor(clicked)); player.sendSystemMessage(Component.translatable("bannermod.surveyor.anchor_set", clicked.toShortString()).withStyle(ChatFormatting.AQUA)); - return InteractionResult.SUCCESS; + return; } CompoundTag tag = ItemStackComponentData.read(stack); if (!tag.contains(TAG_PENDING_CORNER)) { ItemStackComponentData.update(stack, data -> data.putLong(TAG_PENDING_CORNER, clicked.asLong())); player.sendSystemMessage(Component.translatable("bannermod.surveyor.corner_a", clicked.toShortString()).withStyle(ChatFormatting.AQUA)); - return InteractionResult.SUCCESS; + return; } BlockPos cornerA = BlockPos.of(tag.getLong(TAG_PENDING_CORNER)); @@ -90,7 +103,6 @@ public InteractionResult useOn(UseOnContext context) { SurveyorSessionCodec.write(stack, updated); player.sendSystemMessage(Component.translatable("bannermod.surveyor.zone_captured", roleLabel(role)).withStyle(ChatFormatting.GREEN)); maybeAdvanceRoleAfterCapture(player, stack, updated, role); - return InteractionResult.SUCCESS; } @Override @@ -265,12 +277,6 @@ public static boolean hasAnyMarks(ItemStack stack) { || session != null && (!session.anchorPos().equals(BlockPos.ZERO) || !session.selections().isEmpty()); } - private static SurveyorMode nextMode(SurveyorMode mode) { - SurveyorMode[] modes = SurveyorMode.values(); - int idx = mode.ordinal(); - return modes[(idx + 1) % modes.length]; - } - public static void setSelectedRole(ItemStack stack, ZoneRole role) { if (stack == null || role == null) { return; diff --git a/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java b/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java index a203b36d..75f1b7f2 100644 --- a/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java +++ b/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java @@ -32,6 +32,7 @@ public final class CivilianPacketCatalog { MessageRequestRegisterBuilding.class, MessageSetSurveyorMode.class, MessageSetSurveyorRole.class, + MessageUseSurveyorBlock.class, MessageModifySurveyorSession.class, MessageValidateSurveyorSession.class, MessageToClientOpenWorkerScreen.class, diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java new file mode 100644 index 00000000..b3a80e6a --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java @@ -0,0 +1,64 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.items.civilian.SettlementSurveyorToolItem; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +public class MessageUseSurveyorBlock implements BannerModMessage { + private static final double MAX_CLICK_DISTANCE_SQR = 49.0D; + + public int handIndex; + public BlockPos clickedPos; + + public MessageUseSurveyorBlock() { + this.clickedPos = BlockPos.ZERO; + } + + public MessageUseSurveyorBlock(InteractionHand hand, BlockPos clickedPos) { + this.handIndex = hand == InteractionHand.OFF_HAND ? 1 : 0; + this.clickedPos = clickedPos == null ? BlockPos.ZERO : clickedPos.immutable(); + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + context.enqueueWork(() -> { + ServerPlayer player = context.getSender(); + if (player == null) { + return; + } + if (player.getEyePosition().distanceToSqr(Vec3.atCenterOf(this.clickedPos)) > MAX_CLICK_DISTANCE_SQR) { + return; + } + ItemStack stack = player.getItemInHand(handIndex == 1 ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof SettlementSurveyorToolItem)) { + return; + } + SettlementSurveyorToolItem.handleBlockClick(player, stack, this.clickedPos); + }); + } + + @Override + public MessageUseSurveyorBlock fromBytes(FriendlyByteBuf buf) { + this.handIndex = buf.readVarInt(); + this.clickedPos = buf.readBlockPos(); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeVarInt(this.handIndex); + buf.writeBlockPos(this.clickedPos == null ? BlockPos.ZERO : this.clickedPos); + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java b/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java index 93d9979a..97500af5 100644 --- a/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java +++ b/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java @@ -24,6 +24,9 @@ public static Result validateCreate(String name, UUID leaderUuid, Collection MAX_CLICK_DISTANCE_SQR")); + assertTrue(useBlockMessage.contains("SettlementSurveyorToolItem.handleBlockClick(player, stack, this.clickedPos);")); + assertTrue(packetCatalog.contains("MessageUseSurveyorBlock.class")); + } + + private static String read(String relativePath) throws IOException { + return Files.readString(ROOT.resolve(relativePath)); + } +} diff --git a/src/test/java/com/talhanation/bannermod/war/registry/PoliticalRegistryCreateValidationTest.java b/src/test/java/com/talhanation/bannermod/war/registry/PoliticalRegistryCreateValidationTest.java new file mode 100644 index 00000000..e25b82df --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/war/registry/PoliticalRegistryCreateValidationTest.java @@ -0,0 +1,34 @@ +package com.talhanation.bannermod.war.registry; + +import net.minecraft.core.BlockPos; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PoliticalRegistryCreateValidationTest { + private static final UUID LEADER = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + + @Test + void validateCreateRejectsSecondEntityForSameLeader() { + PoliticalRegistryRuntime runtime = new PoliticalRegistryRuntime(); + runtime.create("Acadia", LEADER, BlockPos.ZERO, "", "", "", "", 0L).orElseThrow(); + + PoliticalRegistryValidation.Result validation = runtime.canCreate("Brittany", LEADER); + + assertFalse(validation.valid()); + assertEquals("leader_already_has_entity", validation.reason()); + } + + @Test + void createAllowsDifferentLeaders() { + PoliticalRegistryRuntime runtime = new PoliticalRegistryRuntime(); + runtime.create("Acadia", LEADER, BlockPos.ZERO, "", "", "", "", 0L).orElseThrow(); + + assertTrue(runtime.create("Brittany", UUID.fromString("00000000-0000-0000-0000-0000000000bb"), BlockPos.ZERO, + "", "", "", "", 0L).isPresent()); + } +}