Skip to content

Commit 164bf7e

Browse files
otectusclaude
andcommitted
v1.1.4: Fix projectile PvP bypass, add HUD timers, configurable teams
- B1: Projectile attacks (arrows, tridents, potions) now respect PvP toggle rules via getDirectEntity() fallback + self-damage guard - B2: Players notified on first combat tag (no spam on repeated hits) - B3: New useScoreboardTeams config to disable team logic; teams cleaned up when toggled off instead of left orphaned - B4: /combattoggle reload now refreshes teams and syncs HUD state - E1: HUD overlay shows combat tag (red) and cooldown (orange) timers - E4: Configurable team names via combatTeamName/peaceTeamName config - Fix: Tag timer sent as relative duration (clock-skew resistant) - Network protocol bumped to v2 for new packet format Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ce1b276 commit 164bf7e

13 files changed

Lines changed: 249 additions & 130 deletions

File tree

CHANGELOG.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Changelog
2+
3+
## 1.1.4
4+
5+
### Bug Fixes
6+
- **Projectile PvP bypass (B1).** Arrows, tridents, splash potions, and other projectile attacks now correctly respect PvP toggle rules. Previously, if the causing entity reference was lost (e.g., shooter logged out mid-flight), projectile damage could bypass enforcement entirely. The handler now falls back to resolving the attacker through the projectile's direct entity and its owner. A self-damage guard also prevents a player's own projectiles from triggering PvP logic.
7+
- **Victims now notified when combat-tagged (B2).** Players receive a chat message when first tagged by PvP combat, showing the tag duration. Subsequent hits while already tagged do not spam notifications.
8+
- **Reload command now functional (B4).** `/combattoggle reload` refreshes scoreboard teams and syncs HUD state for all online players, instead of being a no-op.
9+
- **Combat tag timer used absolute server timestamp.** The HUD tag timer was comparing the server's absolute clock against the client's, causing incorrect display under clock skew. Both tag and cooldown timers now use relative durations converted to client-local timestamps.
10+
11+
### New Features
12+
- **HUD cooldown and tag timers (E1).** The HUD overlay now displays a countdown timer for active combat tags (red) and cooldowns (orange) below the mode icon. Both timers can display simultaneously, stacked vertically.
13+
- **Configurable scoreboard team names (E4).** New `combatTeamName` and `peaceTeamName` config options allow customizing the scoreboard team names (default: `ct_combat`/`ct_peace`).
14+
- **Scoreboard teams can be disabled (B3).** New `useScoreboardTeams` config option (default: `true`). When disabled, players are removed from Combat Toggle teams, avoiding conflicts with other mods that use scoreboard teams.
15+
16+
### Improvements
17+
- **Team cleanup on disable.** When `useScoreboardTeams` is set to `false`, existing team assignments are cleaned up automatically instead of being left orphaned.
18+
- **Network protocol bumped to v2.** The sync packet format changed to support timer data. Clients with mismatched mod versions will get a clean disconnect instead of a deserialization crash.
19+
20+
## 1.1.0
21+
22+
### Bug Fixes
23+
- **Cooldown reset command now clears both cooldown types.** Previously, `/combattoggle resetcooldown` only reset the toggle-based cooldown (`lastToggleMs`) but left the PvP-triggered cooldown (`lastPvpMs`) active. Both are now reset to zero.
24+
- **Admin `set` command no longer triggers unintended cooldowns.** The command was unconditionally setting `lastToggleMs`, starting a toggle-based cooldown even when that cooldown type was disabled in config. It now respects the `cooldownTriggersOnToggle` setting.
25+
- **Fixed HUD texture rendering.** The texture dimension constants (450x101) did not match the actual texture files, causing incorrect UV sampling in the `blit()` call. Constants now match the real texture dimensions.
26+
- **Removed unused `allowClientButtonClick` config option.** This setting was defined but never referenced anywhere in the codebase.
27+
28+
### Improvements
29+
- **New GUI textures.** Replaced the plain colored rectangles with Minecraft-style beveled GUI icons featuring stone-gray borders, tinted inner fills, and pixel-art icons (shield for Peace, crossed swords for Combat).
30+
- **Compact HUD indicator.** Reduced texture size from 120x27 to 51x19 for a less intrusive on-screen presence.
31+
- **Cleaner toggle messages.** Removed the noisy cooldown suffix from mode switch messages. Toggle feedback now simply reads "Mode set to: COMBAT" or "Mode set to: PEACE".
32+
33+
### Repository
34+
- Fixed license mismatch: `mods.toml` now correctly declares GPL-3.0 (was MIT)
35+
- Added GitHub Actions CI workflow for automated build verification
36+
- Fixed `gradlew` line endings (CRLF to LF)
37+
38+
## 1.0.0
39+
40+
- Initial release
41+
- Peace/Combat mode toggle with Caps Lock keybind
42+
- HUD overlay showing current mode
43+
- Combat tagging system with configurable duration
44+
- PvP-triggered and toggle-triggered cooldown system
45+
- Nameplate text prefixes and color coding via scoreboard teams
46+
- Persistent player state via NBT
47+
- Full admin command suite (`get`, `set`, `resetcooldown`, `tag`, `untag`, `reload`)
48+
- Server-side configuration via `combattoggle.toml`

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44
id 'maven-publish'
55
}
66

7-
version = '1.0.0'
7+
version = '1.1.4'
88
group = 'com.runecraft.combattoggle'
99

1010
base {
Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
package com.runecraft.combattoggle.client;
2-
3-
public final class ClientCombatState {
4-
private static volatile boolean enabled = false;
5-
private static volatile long lastToggleMs = 0L;
6-
private static volatile long combatTagUntilMs = 0L;
7-
8-
public static void update(boolean enabledIn, long lastToggleMsIn, long combatTagUntilMsIn) {
9-
enabled = enabledIn;
10-
lastToggleMs = lastToggleMsIn;
11-
combatTagUntilMs = combatTagUntilMsIn;
12-
}
13-
14-
public static boolean isEnabled() { return enabled; }
15-
public static long getLastToggleMs() { return lastToggleMs; }
16-
public static long getCombatTagUntilMs() { return combatTagUntilMs; }
17-
}
1+
package com.runecraft.combattoggle.client;
2+
3+
public final class ClientCombatState {
4+
private static volatile boolean enabled = false;
5+
private static volatile long combatTagUntilMs = 0L;
6+
private static volatile long cooldownUntilMs = 0L;
7+
8+
public static void update(boolean enabledIn, long combatTagRemainingMs, long cooldownRemainingMs) {
9+
enabled = enabledIn;
10+
long now = System.currentTimeMillis();
11+
combatTagUntilMs = combatTagRemainingMs > 0 ? now + combatTagRemainingMs : 0L;
12+
cooldownUntilMs = cooldownRemainingMs > 0 ? now + cooldownRemainingMs : 0L;
13+
}
14+
15+
public static boolean isEnabled() { return enabled; }
16+
public static long getCombatTagUntilMs() { return combatTagUntilMs; }
17+
public static long getCooldownUntilMs() { return cooldownUntilMs; }
18+
}
Lines changed: 60 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,60 @@
1-
package com.runecraft.combattoggle.client;
2-
3-
import com.runecraft.combattoggle.CombatToggle;
4-
import com.runecraft.combattoggle.config.CTConfig;
5-
import net.minecraft.client.gui.GuiGraphics;
6-
import net.minecraft.resources.ResourceLocation;
7-
import net.minecraftforge.api.distmarker.Dist;
8-
import net.minecraftforge.client.event.RegisterGuiOverlaysEvent;
9-
import net.minecraftforge.eventbus.api.SubscribeEvent;
10-
import net.minecraftforge.fml.common.Mod;
11-
import net.minecraft.client.Minecraft;
12-
13-
import static com.runecraft.combattoggle.CombatToggle.MODID;
14-
15-
@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
16-
public final class CombatHudOverlay {
17-
private static final ResourceLocation PEACE_TEX = new ResourceLocation(CombatToggle.MODID, "textures/gui/peace.png");
18-
private static final ResourceLocation COMBAT_TEX = new ResourceLocation(CombatToggle.MODID, "textures/gui/combat.png");
19-
20-
private static final int WIDTH = 120;
21-
private static final int HEIGHT = 27;
22-
private static final int Y = 6;
23-
24-
@SubscribeEvent
25-
public static void registerOverlays(RegisterGuiOverlaysEvent event) {
26-
event.registerAboveAll("combat_toggle", CombatHudOverlay::render);
27-
}
28-
29-
private static void render(net.minecraftforge.client.gui.overlay.ForgeGui gui, GuiGraphics g, float partialTick, int w, int h) {
30-
if (!CTConfig.showHud.get()) return;
31-
32-
Minecraft mc = Minecraft.getInstance();
33-
if (mc.player == null) return;
34-
if (mc.options.hideGui) return;
35-
36-
int x = (w / 2) - (WIDTH / 2);
37-
38-
ResourceLocation texture = ClientCombatState.isEnabled() ? COMBAT_TEX : PEACE_TEX;
39-
g.blit(texture, x, Y, 0, 0, WIDTH, HEIGHT, WIDTH, HEIGHT);
40-
}
41-
}
1+
package com.runecraft.combattoggle.client;
2+
3+
import com.runecraft.combattoggle.CombatToggle;
4+
import com.runecraft.combattoggle.config.CTConfig;
5+
import com.runecraft.combattoggle.util.TextUtil;
6+
import net.minecraft.client.gui.GuiGraphics;
7+
import net.minecraft.resources.ResourceLocation;
8+
import net.minecraftforge.api.distmarker.Dist;
9+
import net.minecraftforge.client.event.RegisterGuiOverlaysEvent;
10+
import net.minecraftforge.eventbus.api.SubscribeEvent;
11+
import net.minecraftforge.fml.common.Mod;
12+
import net.minecraft.client.Minecraft;
13+
14+
import static com.runecraft.combattoggle.CombatToggle.MODID;
15+
16+
@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
17+
public final class CombatHudOverlay {
18+
private static final ResourceLocation PEACE_TEX = new ResourceLocation(CombatToggle.MODID, "textures/gui/peace.png");
19+
private static final ResourceLocation COMBAT_TEX = new ResourceLocation(CombatToggle.MODID, "textures/gui/combat.png");
20+
21+
private static final int WIDTH = 51;
22+
private static final int HEIGHT = 19;
23+
private static final int Y = 6;
24+
25+
@SubscribeEvent
26+
public static void registerOverlays(RegisterGuiOverlaysEvent event) {
27+
event.registerAboveAll("combat_toggle", CombatHudOverlay::render);
28+
}
29+
30+
private static void render(net.minecraftforge.client.gui.overlay.ForgeGui gui, GuiGraphics g, float partialTick, int w, int h) {
31+
if (!CTConfig.showHud.get()) return;
32+
33+
Minecraft mc = Minecraft.getInstance();
34+
if (mc.player == null) return;
35+
if (mc.options.hideGui) return;
36+
37+
int x = (w / 2) - (WIDTH / 2);
38+
39+
ResourceLocation texture = ClientCombatState.isEnabled() ? COMBAT_TEX : PEACE_TEX;
40+
g.blit(texture, x, Y, 0, 0, WIDTH, HEIGHT, WIDTH, HEIGHT);
41+
42+
// Render combat tag timer
43+
long now = System.currentTimeMillis();
44+
long tagUntil = ClientCombatState.getCombatTagUntilMs();
45+
if (tagUntil > now) {
46+
String tagText = "Tag: " + TextUtil.formatRemaining(tagUntil - now);
47+
int textX = (w / 2) - (mc.font.width(tagText) / 2);
48+
g.drawString(mc.font, tagText, textX, Y + HEIGHT + 2, 0xFFFF5555, true);
49+
}
50+
51+
// Render cooldown timer
52+
long cdUntil = ClientCombatState.getCooldownUntilMs();
53+
if (cdUntil > now) {
54+
String cdText = "CD: " + TextUtil.formatRemaining(cdUntil - now);
55+
int textX = (w / 2) - (mc.font.width(cdText) / 2);
56+
int yOff = (tagUntil > now) ? Y + HEIGHT + 14 : Y + HEIGHT + 2;
57+
g.drawString(mc.font, cdText, textX, yOff, 0xFFFFAA00, true);
58+
}
59+
}
60+
}

src/main/java/com/runecraft/combattoggle/config/CTConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public final class CTConfig {
2626
public static final ForgeConfigSpec.ConfigValue<String> combatEmoji;
2727
public static final ForgeConfigSpec.ConfigValue<String> peaceEmoji;
2828
public static final ForgeConfigSpec.BooleanValue useNameplateColors;
29+
public static final ForgeConfigSpec.BooleanValue useScoreboardTeams;
30+
public static final ForgeConfigSpec.ConfigValue<String> combatTeamName;
31+
public static final ForgeConfigSpec.ConfigValue<String> peaceTeamName;
2932

3033
public static final ForgeConfigSpec.BooleanValue showHud;
3134

@@ -90,6 +93,18 @@ public final class CTConfig {
9093
.comment("If true, uses scoreboard team colors (RED for Combat, BLUE for Peace)")
9194
.define("useNameplateColors", true);
9295

96+
useScoreboardTeams = b
97+
.comment("Use scoreboard teams for nameplate colors/prefixes. Disable to avoid conflicts with other mods.")
98+
.define("useScoreboardTeams", true);
99+
100+
combatTeamName = b
101+
.comment("Scoreboard team name for Combat mode players")
102+
.define("combatTeamName", "ct_combat");
103+
104+
peaceTeamName = b
105+
.comment("Scoreboard team name for Peace mode players")
106+
.define("peaceTeamName", "ct_peace");
107+
93108
b.pop();
94109

95110
b.comment("Client-facing toggles that are still controlled by server config").push("client");

src/main/java/com/runecraft/combattoggle/events/CombatEnforcementEvents.java

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.runecraft.combattoggle.util.TeamManager;
88
import com.runecraft.combattoggle.util.TextUtil;
99
import net.minecraft.server.level.ServerPlayer;
10+
import net.minecraft.world.entity.projectile.Projectile;
1011
import net.minecraftforge.event.entity.living.LivingHurtEvent;
1112
import net.minecraftforge.eventbus.api.SubscribeEvent;
1213
import net.minecraftforge.fml.common.Mod;
@@ -19,7 +20,23 @@ public final class CombatEnforcementEvents {
1920
@SubscribeEvent
2021
public static void onLivingHurt(LivingHurtEvent event) {
2122
if (!(event.getEntity() instanceof ServerPlayer victim)) return;
22-
if (!(event.getSource().getEntity() instanceof ServerPlayer attacker)) return;
23+
24+
// Resolve attacker: direct hit or projectile owner
25+
ServerPlayer attacker;
26+
var causingEntity = event.getSource().getEntity();
27+
if (causingEntity instanceof ServerPlayer sp) {
28+
attacker = sp;
29+
} else {
30+
// Fallback: resolve through the direct entity (the projectile itself)
31+
var directEntity = event.getSource().getDirectEntity();
32+
if (directEntity instanceof Projectile proj && proj.getOwner() instanceof ServerPlayer owner) {
33+
attacker = owner;
34+
} else {
35+
return;
36+
}
37+
}
38+
39+
if (attacker == victim) return; // self-damage guard
2340

2441
long now = System.currentTimeMillis();
2542

@@ -34,10 +51,21 @@ public static void onLivingHurt(LivingHurtEvent event) {
3451
return;
3552
}
3653

37-
// PvP is allowed, apply combat tag to both
54+
// PvP is allowed, apply combat tag to both (notify on first tag only)
55+
boolean attackerWasTagged = a.isTagged(now);
56+
boolean victimWasTagged = v.isTagged(now);
57+
3858
a.applyCombatTag(now);
3959
v.applyCombatTag(now);
4060

61+
int tagSeconds = CTConfig.combatTagSeconds.get();
62+
if (tagSeconds > 0) {
63+
if (!attackerWasTagged)
64+
attacker.sendSystemMessage(TextUtil.system("[Combat Toggle] Combat-tagged for " + tagSeconds + "s"));
65+
if (!victimWasTagged)
66+
victim.sendSystemMessage(TextUtil.system("[Combat Toggle] Combat-tagged for " + tagSeconds + "s"));
67+
}
68+
4169
// Track PvP activity for cooldown system
4270
if (CTConfig.cooldownTriggersOnPvp.get()) {
4371
a.lastPvpMs = now;
@@ -59,7 +87,7 @@ public static void onLivingHurt(LivingHurtEvent event) {
5987
v.save(victim);
6088

6189
// Sync if forced changes happened
62-
PacketHandler.sendToPlayer(attacker, new S2CSyncStatePacket(a.enabled, a.lastToggleMs, a.combatTagUntilMs));
63-
PacketHandler.sendToPlayer(victim, new S2CSyncStatePacket(v.enabled, v.lastToggleMs, v.combatTagUntilMs));
90+
PacketHandler.sendToPlayer(attacker, new S2CSyncStatePacket(a.enabled, Math.max(0, a.combatTagUntilMs - now), a.getRemainingCooldown(now)));
91+
PacketHandler.sendToPlayer(victim, new S2CSyncStatePacket(v.enabled, Math.max(0, v.combatTagUntilMs - now), v.getRemainingCooldown(now)));
6492
}
6593
}

src/main/java/com/runecraft/combattoggle/events/CommandEvents.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ private static int set(CommandContext<CommandSourceStack> ctx, ServerPlayer targ
100100
ctx.getSource().sendSuccess(() -> TextUtil.system("Set " + target.getScoreboardName() + " to " + (wantCombat ? "COMBAT" : "PEACE")), true);
101101
target.sendSystemMessage(TextUtil.system("[Combat Toggle] Admin set your mode to: " + (wantCombat ? "COMBAT" : "PEACE")));
102102

103-
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, d.lastToggleMs, d.combatTagUntilMs));
103+
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, Math.max(0, d.combatTagUntilMs - now), d.getRemainingCooldown(now)));
104104
return 1;
105105
}
106106

@@ -110,8 +110,9 @@ private static int resetCooldown(CommandContext<CommandSourceStack> ctx, ServerP
110110
d.lastPvpMs = 0L;
111111
d.save(target);
112112

113+
long now = System.currentTimeMillis();
113114
ctx.getSource().sendSuccess(() -> TextUtil.system("Cooldown reset for " + target.getScoreboardName()), true);
114-
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, d.lastToggleMs, d.combatTagUntilMs));
115+
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, Math.max(0, d.combatTagUntilMs - now), d.getRemainingCooldown(now)));
115116
return 1;
116117
}
117118

@@ -132,7 +133,7 @@ private static int tag(CommandContext<CommandSourceStack> ctx, ServerPlayer targ
132133
ctx.getSource().sendSuccess(() -> TextUtil.system("Tagged " + target.getScoreboardName() + " for " + seconds + "s"), true);
133134
target.sendSystemMessage(TextUtil.system("[Combat Toggle] You have been combat-tagged for " + seconds + "s"));
134135

135-
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, d.lastToggleMs, d.combatTagUntilMs));
136+
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, Math.max(0, d.combatTagUntilMs - now), d.getRemainingCooldown(now)));
136137
return 1;
137138
}
138139

@@ -141,16 +142,26 @@ private static int untag(CommandContext<CommandSourceStack> ctx, ServerPlayer ta
141142
d.combatTagUntilMs = 0L;
142143
d.save(target);
143144

145+
long now = System.currentTimeMillis();
144146
ctx.getSource().sendSuccess(() -> TextUtil.system("Untagged " + target.getScoreboardName()), true);
145147
target.sendSystemMessage(TextUtil.system("[Combat Toggle] Combat tag cleared"));
146148

147-
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, d.lastToggleMs, d.combatTagUntilMs));
149+
PacketHandler.sendToPlayer(target, new S2CSyncStatePacket(d.enabled, Math.max(0, d.combatTagUntilMs - now), d.getRemainingCooldown(now)));
148150
return 1;
149151
}
150152

151153
private static int reload(CommandContext<CommandSourceStack> ctx) {
152-
// Forge auto reloads config on file change. This command is mostly ceremonial unless you implement manual reload hooks.
153-
ctx.getSource().sendSuccess(() -> TextUtil.system("Config reload requested. If you edited the file, Forge should pick it up."), false);
154+
var server = ctx.getSource().getServer();
155+
long now = System.currentTimeMillis();
156+
for (ServerPlayer p : server.getPlayerList().getPlayers()) {
157+
CombatToggleData d = CombatToggleData.get(p);
158+
TeamManager.updatePlayerTeam(p, d.enabled);
159+
PacketHandler.sendToPlayer(p, new S2CSyncStatePacket(d.enabled, Math.max(0, d.combatTagUntilMs - now), d.getRemainingCooldown(now)));
160+
}
161+
ctx.getSource().sendSuccess(() -> TextUtil.system(
162+
"[Combat Toggle] Scoreboard teams refreshed and state synced for all online players. " +
163+
"Config values are auto-reloaded by Forge when the file changes."
164+
), false);
154165
return 1;
155166
}
156167
}

src/main/java/com/runecraft/combattoggle/events/PlayerLifecycleEvents.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,6 @@ private static void syncAndEnforce(ServerPlayer p) {
5252
// Update scoreboard team for nameplate color
5353
TeamManager.updatePlayerTeam(p, d.enabled);
5454

55-
PacketHandler.sendToPlayer(p, new S2CSyncStatePacket(d.enabled, d.lastToggleMs, d.combatTagUntilMs));
55+
PacketHandler.sendToPlayer(p, new S2CSyncStatePacket(d.enabled, Math.max(0, d.combatTagUntilMs - now), d.getRemainingCooldown(now)));
5656
}
5757
}

0 commit comments

Comments
 (0)