From 1c514a0242c6c82baba527b98858578c4becf588 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:48:18 +0300 Subject: [PATCH 01/26] feat(network): emit canonical moderation ban payload Map outbound BanData publishes to the canonical moderation.ban.created contract so the protocol-first migration can proceed without changing Redis stream or envelope semantics. Keep Gson serialization and runtime transport ownership in XCore-plugin while validating the new slice with targeted and full transport checks. --- build.gradle.kts | 6 +++ .../network/ModerationProtocolMapper.java | 48 +++++++++++++++++++ .../service/network/RedisNetworkBackend.java | 14 +++++- .../RedisNetworkBackendIntegrationTest.java | 29 ++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java diff --git a/build.gradle.kts b/build.gradle.kts index 4461f46..64b4e9f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,12 @@ java { } } +sourceSets { + main { + java.srcDir("../xcore-protocol/java/core/src/main/java") + } +} + toxopid { compileVersion.set("v$mindustryVersion") runtimeVersion.set("v$mindustryVersion") diff --git a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java new file mode 100644 index 0000000..9720bcb --- /dev/null +++ b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java @@ -0,0 +1,48 @@ +package org.xcore.plugin.service.network; + +import org.xcore.plugin.model.BanData; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ExpirationInfoV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; + +import java.time.Instant; + +final class ModerationProtocolMapper { + private ModerationProtocolMapper() { + } + + static ModerationBanCreatedV1 toBanCreated(BanData ban, String server, Instant occurredAt) { + return new ModerationBanCreatedV1( + new PlayerRefV1(ban.uuid, null, ban.name, normalizeOptional(ban.ip)), + new ActorRefV1(resolveActorName(ban), normalizeOptional(ban.adminDiscordId), resolveActorType(ban)), + resolveReason(ban.reason), + toExpirationInfo(ban), + normalizeOptional(server), + occurredAt.toString() + ); + } + + private static ExpirationInfoV1 toExpirationInfo(BanData ban) { + if (ban.expireDate == null) { + return new ExpirationInfoV1(null, true); + } + return new ExpirationInfoV1(ban.expireDate.toString(), false); + } + + private static String resolveActorName(BanData ban) { + return normalizeOptional(ban.adminName) == null ? "Unknown" : ban.adminName; + } + + private static String resolveActorType(BanData ban) { + return normalizeOptional(ban.adminDiscordId) == null ? "unknown" : "discord"; + } + + private static String resolveReason(String reason) { + return normalizeOptional(reason) == null ? "Not Specified" : reason; + } + + private static String normalizeOptional(String value) { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java index 48ada02..04c1aae 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -18,6 +18,7 @@ import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.event.TransportEvents.Request; import org.xcore.plugin.event.TransportEvents.Response; +import org.xcore.plugin.model.BanData; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -147,7 +148,7 @@ public void send(Object event) { try { var route = router.route(event, config.server); long now = System.currentTimeMillis(); - String payloadJson = gson.toJson(event); + String payloadJson = payloadJson(event, now); RedisCommands commands = connectionManager.commands(); streamSupport.xaddWithTrim(commands, route.streamKey(), envelopeFactory.eventFields(route, payloadJson, now)); publishedEvents.incrementAndGet(); @@ -304,6 +305,17 @@ public boolean supportsRespond(Request request) { return rpcTracker.contains(request); } + private String payloadJson(Object event, long now) { + if (event instanceof BanData banData) { + return gson.toJson(ModerationProtocolMapper.toBanCreated( + banData, + config.server, + Instant.ofEpochMilli(now) + )); + } + return gson.toJson(event); + } + private boolean ensureConnected() { return connectionManager.ensureConnected(); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 26e2085..1ad87c4 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -1,5 +1,6 @@ package org.xcore.plugin.service.network; +import com.google.gson.Gson; import org.xcore.plugin.service.network.RedisNetworkBackend.RequestSubscription; import org.xcore.plugin.service.network.RedisNetworkBackend.Subscription; import io.lettuce.core.RedisClient; @@ -180,8 +181,34 @@ void sendSerializesBanDataInstant() { assertThat(messages).isNotEmpty(); var last = messages.get(messages.size() - 1).getBody(); + @SuppressWarnings("unchecked") + Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); assertThat(last.get("event_type")).isEqualTo("moderation.ban"); - assertThat(last.get("payload_json")).contains("expireDate"); + assertThat(payload) + .containsEntry("messageType", "moderation.ban.created") + .containsEntry("messageVersion", 1.0) + .containsEntry("reason", "rule") + .containsEntry("server", "alpha") + .containsKey("occurredAt") + .containsKeys("target", "actor", "expiration") + .doesNotContainKeys("uuid", "name", "adminName", "expireDate"); + + @SuppressWarnings("unchecked") + Map target = (Map) payload.get("target"); + assertThat(target) + .containsEntry("playerUuid", "u-1") + .containsEntry("playerName", "player") + .containsEntry("ip", "1.2.3.4"); + @SuppressWarnings("unchecked") + Map actor = (Map) payload.get("actor"); + assertThat(actor) + .containsEntry("actorName", "admin") + .containsEntry("actorType", "unknown"); + @SuppressWarnings("unchecked") + Map expiration = (Map) payload.get("expiration"); + assertThat(expiration) + .containsEntry("permanent", false) + .containsKey("expiresAt"); } } From ade8c236806f125ab70a0d32646514f2ade62573 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:11:00 +0300 Subject: [PATCH 02/26] build(network): consume protocol from Maven Replace the sibling source-set wiring with the published xcore-protocol Java artifact so the moderation migration branch builds through the shared Maven path. Keep the current protocol mapping slice intact while aligning local and CI resolution with the canonical publish flow. --- build.gradle.kts | 7 +------ gradle/libs.versions.toml | 2 ++ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 64b4e9f..7833c81 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,12 +28,6 @@ java { } } -sourceSets { - main { - java.srcDir("../xcore-protocol/java/core/src/main/java") - } -} - toxopid { compileVersion.set("v$mindustryVersion") runtimeVersion.set("v$mindustryVersion") @@ -68,6 +62,7 @@ dependencies { compileOnly(toxopid.dependencies.mindustryCore) compileOnly(toxopid.dependencies.arcCore) compileOnly(toxopid.dependencies.mindustryHeadless) + implementation(libs.xcore.protocol.java) implementation(libs.flubundle) implementation(libs.cloud.mindustry) implementation(libs.mongodb.sync) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2674d7e..590e0f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] mindustry = "157" +xcore-protocol = "0.1.0-SNAPSHOT" # Plugins toxopid = "4.1.2" @@ -27,6 +28,7 @@ shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } [libraries] cloud-mindustry = { module = "org.xcore:cloud-mindustry", version.ref = "cloud-mindustry" } +xcore-protocol-java = { module = "org.xcore:xcore-protocol-java", version.ref = "xcore-protocol" } mongodb-sync = { module = "org.mongodb:mongodb-driver-sync", version.ref = "mongodb" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } jbcrypt = { module = "org.mindrot:jbcrypt", version.ref = "jbcrypt" } From a9efd6a5b0ceadfe284ea07b70e3a9d597402e1a Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:04:18 +0300 Subject: [PATCH 03/26] feat(network): migrate moderation transport DTOs Replace legacy moderation wire payloads with canonical xcore-protocol DTO wrappers so Redis routes, publishers, and handlers share one transport contract. Update moderation transport tests to lock the canonical stream and command identities across the plugin. --- .../xcore/plugin/event/TransportEvents.java | 48 +++ .../transport/ModerationTransportHandler.java | 14 +- .../service/moderation/ModerationService.java | 77 +++-- .../network/ModerationProtocolMapper.java | 291 +++++++++++++++++- .../service/network/RedisNetworkBackend.java | 60 +++- .../service/network/RedisRouteRegistry.java | 12 +- .../network/RedisTransportTopology.java | 12 +- .../java/org/xcore/plugin/vote/VoteKick.java | 24 +- .../ModerationServiceAvajeTest.java | 168 ++++++++-- .../RedisNetworkBackendIntegrationTest.java | 156 +++++++--- .../network/RedisRouteRegistryTest.java | 8 +- .../network/RedisStreamRouterTest.java | 79 +++-- .../network/RedisTransportContractsTest.java | 21 +- 13 files changed, 794 insertions(+), 176 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 0dfae1c..21a1b9e 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -2,6 +2,12 @@ import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import java.time.Instant; import java.util.List; @@ -150,6 +156,48 @@ public record ModerationAuditAppendedEvent( Instant occurredAt ) implements Event, ServerScopedEvent {} + public record ModerationBanCreatedEvent(ModerationBanCreatedV1 payload) implements Event, ServerScopedEvent { + @Override + public String server() { + return payload == null ? null : payload.server(); + } + } + + public record ModerationMuteCreatedEvent(ModerationMuteCreatedV1 payload) implements Event, ServerScopedEvent { + @Override + public String server() { + return payload == null ? null : payload.server(); + } + } + + public record ModerationVoteKickCreatedEvent(ModerationVoteKickCreatedV1 payload) implements Event, ServerScopedEvent { + @Override + public String server() { + return payload == null ? null : payload.server(); + } + } + + public record ModerationAuditAppendedProtocolEvent(ModerationAuditAppendedV1 payload) implements Event, ServerScopedEvent { + @Override + public String server() { + return payload == null ? null : payload.server(); + } + } + + public record ModerationKickBannedCommandEvent(ModerationKickBannedCommandV1 payload) implements Event, ServerScopedEvent { + @Override + public String server() { + return payload == null ? null : payload.server(); + } + } + + public record ModerationPardonCommandEvent(ModerationPardonCommandV1 payload) implements Event, ServerScopedEvent { + @Override + public String server() { + return payload == null ? null : payload.server(); + } + } + public static class ReloadPlayerDataCache {} public record LoadMapsV2(FileURL[] urls, String server) implements ServerScopedEvent {} diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index 8e4f21d..08e8543 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -49,8 +49,14 @@ public ModerationTransportHandler(NetworkService network, } public void registerListeners() { - network.subscribe(TransportEvents.KickBannedPlayer.class, e -> Groups.player - .each(p -> p.uuid().equals(e.uuid()) || p.ip().equals(e.ip()), p -> p.kick(Packets.KickReason.banned))); + network.subscribe(TransportEvents.ModerationKickBannedCommandEvent.class, e -> Groups.player.each( + p -> { + var target = e.payload().target(); + return p.uuid().equals(target.playerUuid()) + || (target.ip() != null && target.ip().equals(p.ip())); + }, + p -> p.kick(Packets.KickReason.banned) + )); network.subscribe(TransportEvents.DiscordAdminAccessChanged.class, e -> { if (e.admin()) { @@ -65,8 +71,8 @@ public void registerListeners() { } }); - network.subscribe(TransportEvents.PardonPlayer.class, e -> { - Administration.PlayerInfo info = netServer.admins.getInfoOptional(e.uuid()); + network.subscribe(TransportEvents.ModerationPardonCommandEvent.class, e -> { + Administration.PlayerInfo info = netServer.admins.getInfoOptional(e.payload().target().playerUuid()); if (info != null) { info.lastKicked = 0; diff --git a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java index cf0b551..1a7e349 100644 --- a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java +++ b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java @@ -2,6 +2,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.database.repository.MuteDataRepository; import org.xcore.plugin.database.repository.PlayerDataRepository; @@ -20,6 +21,7 @@ import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.FindService; import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.network.ModerationProtocolMapper; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.service.TimeService; @@ -53,6 +55,7 @@ public class ModerationService { private final FindService find; private final TimeService time; private final AuditService auditService; + private final Config config; @Inject public ModerationService(PlayerDataRepository playerDataRepository, @@ -62,7 +65,8 @@ public ModerationService(PlayerDataRepository playerDataRepository, NetworkService network, FindService find, TimeService timeService, - AuditService auditService) { + AuditService auditService, + Config config) { this.playerDataRepository = playerDataRepository; this.banDataRepository = banDataRepository; this.muteDataRepository = muteDataRepository; @@ -71,6 +75,7 @@ public ModerationService(PlayerDataRepository playerDataRepository, this.find = find; this.time = timeService; this.auditService = auditService; + this.config = config; } /** @@ -117,11 +122,18 @@ public ModerationResult banById(int id, String adminName, String adminD null ); - network.post(ban); + postBanEvents(ban, audit); postAuditEvent(audit); if (kickOnline) { - network.post(new TransportEvents.KickBannedPlayer(target.uuid, ip)); + network.post(ModerationProtocolMapper.toKickBannedCommandEvent( + target.uuid, + target.pid, + target.nickname, + ip, + config.server, + commandOccurredAt(audit) + )); } return ModerationResult.success("Player '" + target.nickname + "' banned successfully", ban); @@ -154,6 +166,7 @@ public ModerationResult unbanById(int id, String adminName, String a ); postAuditEvent(audit); + network.post(toPardonCommandEvent(target.uuid, target.pid, target.nickname, audit)); return ModerationResult.success("Player '" + target.nickname + "' unbanned successfully", target); } @@ -198,7 +211,7 @@ public ModerationResult muteById(int id, String adminName, String admi null ); - network.post(mute); + network.post(ModerationProtocolMapper.toMuteCreatedEvent(mute, config.server, eventOccurredAt(audit))); postAuditEvent(audit); return ModerationResult.success("Player '" + target.nickname + "' muted successfully", mute); @@ -231,6 +244,7 @@ public ModerationResult unmuteById(int id, String adminName, String ); postAuditEvent(audit); + network.post(toPardonCommandEvent(target.uuid, target.pid, target.nickname, audit)); return ModerationResult.success("Player '" + target.nickname + "' unmuted successfully", target); } @@ -277,9 +291,16 @@ public ModerationResult tempBanByUuidOrIp(String uuid, String ip, Strin null ); - network.post(ban); + postBanEvents(ban, audit); postAuditEvent(audit); - network.post(new TransportEvents.KickBannedPlayer(uuid, ip)); + network.post(ModerationProtocolMapper.toKickBannedCommandEvent( + uuid, + null, + ban.name, + ip, + config.server, + commandOccurredAt(audit) + )); return ModerationResult.success("Player '" + ban.name + "' banned until " + expire, ban); } @@ -311,6 +332,7 @@ public ModerationResult tempUnban(String uuid, String ip, String adminName ); postAuditEvent(audit); + network.post(toPardonCommandEvent(uuid, null, UNKNOWN_PLAYER_NAME, audit)); return ModerationResult.success("Unbanned: UUID=" + uuid + " / IP=" + ip, null); } @@ -373,10 +395,32 @@ private AuditRecord appendAudit(AuditAction action, private void postAuditEvent(AuditRecord audit) { if (audit != null) { - network.post(toAuditEvent(audit)); + network.post(ModerationProtocolMapper.toAuditAppendedEvent(audit, config.server)); } } + private void postBanEvents(BanData ban, AuditRecord audit) { + network.post(ModerationProtocolMapper.toBanCreatedEvent(ban, config.server, eventOccurredAt(audit))); + } + + private TransportEvents.ModerationPardonCommandEvent toPardonCommandEvent(String uuid, Integer pid, String playerName, AuditRecord audit) { + return ModerationProtocolMapper.toPardonCommandEvent( + uuid, + pid, + playerName, + config.server, + commandOccurredAt(audit) + ); + } + + private static Instant eventOccurredAt(AuditRecord audit) { + return audit != null && audit.occurredAt != null ? audit.occurredAt : Instant.now(); + } + + private static Instant commandOccurredAt(AuditRecord audit) { + return eventOccurredAt(audit); + } + private static AuditTarget auditTarget(String uuid, Integer pid, String nameSnapshot, String ipSnapshot) { return AuditTarget.builder() .uuid(uuid == null ? "" : uuid) @@ -426,25 +470,6 @@ private static AuditDetails auditDetails(Duration duration, Instant expiresAt) { .build(); } - private static TransportEvents.ModerationAuditAppendedEvent toAuditEvent(AuditRecord record) { - return new TransportEvents.ModerationAuditAppendedEvent( - record.auditId, - record.action.name(), - record.target == null ? null : record.target.getUuid(), - record.target == null ? null : record.target.getPid(), - record.target == null ? null : record.target.getNameSnapshot(), - record.actor == null || record.actor.getType() == null ? null : record.actor.getType().name(), - record.actor == null ? null : record.actor.getId(), - record.actor == null ? null : record.actor.getNameSnapshot(), - record.reason, - record.details == null ? null : record.details.getDurationMs(), - record.details == null ? null : record.details.getExpiresAt(), - record.relatedAuditId, - record.origin == null ? null : record.origin.getServerId(), - record.occurredAt - ); - } - private static boolean hasNoIdentifier(String uuid, String ip) { return uuid == null && ip == null; } diff --git a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java index 9720bcb..d898f68 100644 --- a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java @@ -1,21 +1,38 @@ package org.xcore.plugin.service.network; +import org.xcore.plugin.event.TransportEvents; +import org.xcore.plugin.model.AuditAction; +import org.xcore.plugin.model.AuditActorType; +import org.xcore.plugin.model.AuditRecord; import org.xcore.plugin.model.BanData; +import org.xcore.plugin.model.MuteData; +import org.xcore.plugin.model.Punishment; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.shared.ActorRefV1; import org.xcore.protocol.generated.shared.ExpirationInfoV1; +import org.xcore.protocol.generated.shared.PlayerCommandTargetV1; import org.xcore.protocol.generated.shared.PlayerRefV1; +import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; -final class ModerationProtocolMapper { +public final class ModerationProtocolMapper { private ModerationProtocolMapper() { } - static ModerationBanCreatedV1 toBanCreated(BanData ban, String server, Instant occurredAt) { + public static ModerationBanCreatedV1 toBanCreated(BanData ban, String server, Instant occurredAt) { return new ModerationBanCreatedV1( new PlayerRefV1(ban.uuid, null, ban.name, normalizeOptional(ban.ip)), - new ActorRefV1(resolveActorName(ban), normalizeOptional(ban.adminDiscordId), resolveActorType(ban)), + new ActorRefV1(resolveActorName(ban.adminName), normalizeOptional(ban.adminDiscordId), resolveActorType(ban.adminDiscordId)), resolveReason(ban.reason), toExpirationInfo(ban), normalizeOptional(server), @@ -23,23 +40,275 @@ static ModerationBanCreatedV1 toBanCreated(BanData ban, String server, Instant o ); } - private static ExpirationInfoV1 toExpirationInfo(BanData ban) { - if (ban.expireDate == null) { + public static TransportEvents.ModerationBanCreatedEvent toBanCreatedEvent(BanData ban, String server, Instant occurredAt) { + return new TransportEvents.ModerationBanCreatedEvent(toBanCreated(ban, server, occurredAt)); + } + + public static ModerationMuteCreatedV1 toMuteCreated(MuteData mute, String server, Instant occurredAt) { + return new ModerationMuteCreatedV1( + toPlayerRef(mute), + toActorRef(mute), + resolveReason(mute.reason), + toExpirationInfo(mute), + normalizeOptional(server), + toOccurredAt(occurredAt) + ); + } + + public static TransportEvents.ModerationMuteCreatedEvent toMuteCreatedEvent(MuteData mute, String server, Instant occurredAt) { + return new TransportEvents.ModerationMuteCreatedEvent(toMuteCreated(mute, server, occurredAt)); + } + + public static ModerationVoteKickCreatedV1 toVoteKickCreated( + String targetUuid, + Integer targetPid, + String targetName, + String starterName, + Integer starterPid, + String starterDiscordId, + String reason, + List votesFor, + List votesAgainst, + String server, + Instant occurredAt + ) { + return new ModerationVoteKickCreatedV1( + new PlayerRefV1(requireNonBlank(targetUuid, "targetUuid"), normalizeOptionalPid(targetPid), requirePlayerName(targetName), null), + new ActorRefV1(resolveActorName(starterName), normalizeOptional(starterDiscordId), resolveActorType(starterDiscordId)), + resolveReason(reason), + votesFor == null ? List.of() : List.copyOf(votesFor), + votesAgainst == null ? List.of() : List.copyOf(votesAgainst), + normalizeOptional(server), + toOccurredAt(occurredAt) + ); + } + + public static TransportEvents.ModerationVoteKickCreatedEvent toVoteKickCreatedEvent( + String targetUuid, + Integer targetPid, + String targetName, + String starterName, + Integer starterPid, + String starterDiscordId, + String reason, + List votesFor, + List votesAgainst, + String server, + Instant occurredAt + ) { + return new TransportEvents.ModerationVoteKickCreatedEvent( + toVoteKickCreated( + targetUuid, + targetPid, + targetName, + starterName, + starterPid, + starterDiscordId, + reason, + votesFor, + votesAgainst, + server, + occurredAt + ) + ); + } + + public static VoteKickParticipantV1 toVoteKickParticipant(String name, Integer pid, String discordId) { + return new VoteKickParticipantV1(resolveActorName(name), normalizeOptionalPid(pid), normalizeOptional(discordId)); + } + + public static ModerationKickBannedCommandV1 toKickBannedCommand( + String playerUuid, + Integer playerPid, + String playerName, + String ip, + String server, + Instant requestedAt + ) { + return new ModerationKickBannedCommandV1( + new PlayerCommandTargetV1( + requireNonBlank(playerUuid, "playerUuid"), + normalizeOptionalPid(playerPid), + normalizeOptional(playerName), + normalizeOptional(ip) + ), + requireNonBlank(server, "server"), + toOccurredAt(requestedAt) + ); + } + + public static TransportEvents.ModerationKickBannedCommandEvent toKickBannedCommandEvent( + String playerUuid, + Integer playerPid, + String playerName, + String ip, + String server, + Instant requestedAt + ) { + return new TransportEvents.ModerationKickBannedCommandEvent( + toKickBannedCommand(playerUuid, playerPid, playerName, ip, server, requestedAt) + ); + } + + public static ModerationPardonCommandV1 toPardonCommand( + String playerUuid, + Integer playerPid, + String playerName, + String server, + Instant requestedAt + ) { + return new ModerationPardonCommandV1( + new PlayerCommandTargetV1(requireNonBlank(playerUuid, "playerUuid"), normalizeOptionalPid(playerPid), normalizeOptional(playerName), null), + requireNonBlank(server, "server"), + toOccurredAt(requestedAt) + ); + } + + public static TransportEvents.ModerationPardonCommandEvent toPardonCommandEvent( + String playerUuid, + Integer playerPid, + String playerName, + String server, + Instant requestedAt + ) { + return new TransportEvents.ModerationPardonCommandEvent( + toPardonCommand(playerUuid, playerPid, playerName, server, requestedAt) + ); + } + + public static ModerationAuditAppendedV1 toAuditAppended(AuditRecord record, String server) { + Objects.requireNonNull(record, "record must not be null"); + + return new ModerationAuditAppendedV1( + toAuditEntryType(record.action), + new PlayerRefV1( + requireNonBlank(record.target == null ? null : record.target.uuid, "audit target uuid"), + record.target == null ? null : normalizeOptionalPid(record.target.pid), + requirePlayerName(record.target == null ? null : record.target.nameSnapshot), + null + ), + new ActorRefV1( + resolveActorName(record.actor == null ? null : record.actor.nameSnapshot), + normalizeOptional(record.actor == null ? null : record.actor.discordId), + toProtocolActorType(record.actor == null ? null : record.actor.type) + ), + resolveReason(record.reason), + normalizeOptional(resolveAuditServer(record, server)), + toOccurredAt(record.occurredAt), + toAuditDetails(record) + ); + } + + public static TransportEvents.ModerationAuditAppendedProtocolEvent toAuditAppendedEvent(AuditRecord record, String server) { + return new TransportEvents.ModerationAuditAppendedProtocolEvent(toAuditAppended(record, server)); + } + + private static PlayerRefV1 toPlayerRef(Punishment punishment) { + return new PlayerRefV1( + requireNonBlank(punishment.uuid, "playerUuid"), + null, + requirePlayerName(punishment.name), + punishment instanceof BanData banData ? normalizeOptional(banData.ip) : null + ); + } + + private static ActorRefV1 toActorRef(Punishment punishment) { + return new ActorRefV1( + resolveActorName(punishment.adminName), + normalizeOptional(punishment.adminDiscordId), + resolveActorType(punishment.adminDiscordId) + ); + } + + private static ExpirationInfoV1 toExpirationInfo(Punishment punishment) { + if (punishment.expireDate == null) { return new ExpirationInfoV1(null, true); } - return new ExpirationInfoV1(ban.expireDate.toString(), false); + return new ExpirationInfoV1(punishment.expireDate.toString(), false); + } + + private static Map toAuditDetails(AuditRecord record) { + LinkedHashMap details = new LinkedHashMap<>(); + if (record.details != null) { + putIfNotNull(details, "durationMs", record.details.durationMs); + putIfNotNull(details, "expiresAt", record.details.expiresAt == null ? null : record.details.expiresAt.toString()); + putIfNotNull(details, "visibility", normalizeOptional(record.details.visibility)); + if (record.details.extra != null) { + record.details.extra.forEach((key, value) -> putIfNotNull(details, key, normalizeOptional(value))); + } + } + putIfNotNull(details, "relatedAuditId", normalizeOptional(record.relatedAuditId)); + return details.isEmpty() ? null : Map.copyOf(details); + } + + private static void putIfNotNull(Map details, String key, Object value) { + if (value != null) { + details.put(key, value); + } + } + + private static String resolveAuditServer(AuditRecord record, String server) { + String auditServer = record.origin == null ? null : normalizeOptional(record.origin.serverId); + return auditServer != null ? auditServer : server; + } + + private static String toAuditEntryType(AuditAction action) { + if (action == null) { + return "other"; + } + return switch (action) { + case BAN -> "ban"; + case MUTE -> "mute"; + case UNBAN, UNMUTE -> "pardon"; + default -> "other"; + }; } - private static String resolveActorName(BanData ban) { - return normalizeOptional(ban.adminName) == null ? "Unknown" : ban.adminName; + private static String toProtocolActorType(AuditActorType actorType) { + if (actorType == null) { + return "system"; + } + return switch (actorType) { + case DISCORD_USER -> "discord"; + case PLAYER_ADMIN -> "player_admin"; + case SERVER_CONSOLE -> "server_console"; + case SYSTEM -> "system"; + }; + } + + private static String resolveActorName(String actorName) { + String normalized = normalizeOptional(actorName); + return normalized == null ? "Unknown" : normalized; } - private static String resolveActorType(BanData ban) { - return normalizeOptional(ban.adminDiscordId) == null ? "unknown" : "discord"; + private static String resolveActorType(String actorDiscordId) { + return normalizeOptional(actorDiscordId) == null ? "unknown" : "discord"; } private static String resolveReason(String reason) { - return normalizeOptional(reason) == null ? "Not Specified" : reason; + String normalized = normalizeOptional(reason); + return normalized == null ? "Not Specified" : normalized; + } + + private static String requirePlayerName(String playerName) { + String normalized = normalizeOptional(playerName); + return normalized == null ? "Unknown" : normalized; + } + + private static String requireNonBlank(String value, String fieldName) { + String normalized = normalizeOptional(value); + if (normalized == null) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return normalized; + } + + private static String toOccurredAt(Instant occurredAt) { + return Objects.requireNonNull(occurredAt, "occurredAt must not be null").toString(); + } + + private static Integer normalizeOptionalPid(Integer pid) { + return pid == null || pid < 0 ? null : pid; } private static String normalizeOptional(String value) { diff --git a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java index 04c1aae..7def354 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -18,8 +18,8 @@ import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.event.TransportEvents.Request; import org.xcore.plugin.event.TransportEvents.Response; -import org.xcore.plugin.model.BanData; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -30,7 +30,6 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import java.time.Instant; @Singleton @@ -275,9 +274,6 @@ public boolean supportsSubscribeType(Class type) { if (router.isReadOnlyType(type)) { return true; } - if (type == TransportEvents.KickBannedPlayer.class) { - return true; - } if (router.isRpcRequestType(type)) { return true; } @@ -306,12 +302,23 @@ public boolean supportsRespond(Request request) { } private String payloadJson(Object event, long now) { - if (event instanceof BanData banData) { - return gson.toJson(ModerationProtocolMapper.toBanCreated( - banData, - config.server, - Instant.ofEpochMilli(now) - )); + if (event instanceof TransportEvents.ModerationBanCreatedEvent canonicalEvent) { + return gson.toJson(canonicalEvent.payload()); + } + if (event instanceof TransportEvents.ModerationMuteCreatedEvent canonicalEvent) { + return gson.toJson(canonicalEvent.payload()); + } + if (event instanceof TransportEvents.ModerationVoteKickCreatedEvent canonicalEvent) { + return gson.toJson(canonicalEvent.payload()); + } + if (event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent canonicalEvent) { + return gson.toJson(canonicalEvent.payload()); + } + if (event instanceof TransportEvents.ModerationKickBannedCommandEvent canonicalEvent) { + return gson.toJson(canonicalEvent.payload()); + } + if (event instanceof TransportEvents.ModerationPardonCommandEvent canonicalEvent) { + return gson.toJson(canonicalEvent.payload()); } return gson.toJson(event); } @@ -497,7 +504,7 @@ private boolean dispatchStreamMessage(RedisCommands consumer } try { - T event = gson.fromJson(payloadJson, type); + T event = decodeEvent(payloadJson, type); if (event instanceof Request request && router.isRpcRequestType(type)) { String correlationId = message.getBody().getOrDefault("correlation_id", ""); String replyTo = message.getBody().getOrDefault("reply_to", "xcore:rpc:resp:" + config.server); @@ -517,6 +524,35 @@ private boolean dispatchStreamMessage(RedisCommands consumer } } + @SuppressWarnings("unchecked") + private T decodeEvent(String payloadJson, Class type) { + if (type == TransportEvents.ModerationBanCreatedEvent.class) { + var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1.class); + return (T) new TransportEvents.ModerationBanCreatedEvent(payload); + } + if (type == TransportEvents.ModerationMuteCreatedEvent.class) { + var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1.class); + return (T) new TransportEvents.ModerationMuteCreatedEvent(payload); + } + if (type == TransportEvents.ModerationVoteKickCreatedEvent.class) { + var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1.class); + return (T) new TransportEvents.ModerationVoteKickCreatedEvent(payload); + } + if (type == TransportEvents.ModerationAuditAppendedProtocolEvent.class) { + var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1.class); + return (T) new TransportEvents.ModerationAuditAppendedProtocolEvent(payload); + } + if (type == TransportEvents.ModerationKickBannedCommandEvent.class) { + var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1.class); + return (T) new TransportEvents.ModerationKickBannedCommandEvent(payload); + } + if (type == TransportEvents.ModerationPardonCommandEvent.class) { + var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1.class); + return (T) new TransportEvents.ModerationPardonCommandEvent(payload); + } + return gson.fromJson(payloadJson, type); + } + private void awaitRpcResponse(String replyTo, String correlationId, Class responseType, diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 01c703f..f3094ec 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -110,11 +110,11 @@ private void registerDefaults() { register(readOnly(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); - register(readOnly(BanData.class, "xcore:evt:moderation:ban", "moderation.ban", 120_000L, RedisServerResolver.broadcast())); - register(readOnly(MuteData.class, "xcore:evt:moderation:mute", "moderation.mute", 120_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.VoteKickEvent.class, "xcore:evt:moderation:votekick", "moderation.votekick", 120_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.ModerationAuditAppendedEvent.class, "xcore:evt:moderation:audit", "moderation.audit", 120_000L, RedisServerResolver.broadcast())); - register(mutating(TransportEvents.KickBannedPlayer.class, "xcore:cmd:kick-banned:{server}", "moderation.kick_banned", 120_000L, RedisServerResolver.defaultServer())); + register(readOnly(TransportEvents.ModerationBanCreatedEvent.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, RedisServerResolver.broadcast())); + register(readOnly(TransportEvents.ModerationMuteCreatedEvent.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, RedisServerResolver.broadcast())); + register(readOnly(TransportEvents.ModerationVoteKickCreatedEvent.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, RedisServerResolver.broadcast())); + register(readOnly(TransportEvents.ModerationAuditAppendedProtocolEvent.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, RedisServerResolver.broadcast())); + register(mutating(TransportEvents.ModerationKickBannedCommandEvent.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerCustomNicknameChanged.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom_nickname", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerActiveBadgeChanged.class, "xcore:cmd:player-active-badge:{server}", "player.active_badge", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, RedisServerResolver.defaultServer())); @@ -128,7 +128,7 @@ private void registerDefaults() { register(mutating(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, RedisServerResolver.broadcast())); - register(mutating(TransportEvents.PardonPlayer.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon", 120_000L, RedisServerResolver.defaultServer())); + register(mutating(TransportEvents.ModerationPardonCommandEvent.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, RedisServerResolver.defaultServer())); register(rpc(TransportEvents.MapsListRequest.class, "xcore:rpc:req:{server}", "maps.list", 10_000L, PAYLOAD_SERVER_RESOLVER, TransportEvents.MapsListResponse.class)); register(rpc(TransportEvents.MapRemoveRequest.class, "xcore:rpc:req:{server}", "maps.remove", 10_000L, PAYLOAD_SERVER_RESOLVER, TransportEvents.MapRemoveResponse.class)); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 00a2916..ac7feb4 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -46,11 +46,11 @@ public record RouteSpec( route(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), route(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(BanData.class, "xcore:evt:moderation:ban", "moderation.ban", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(MuteData.class, "xcore:evt:moderation:mute", "moderation.mute", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.VoteKickEvent.class, "xcore:evt:moderation:votekick", "moderation.votekick", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ModerationAuditAppendedEvent.class, "xcore:evt:moderation:audit", "moderation.audit", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.KickBannedPlayer.class, "xcore:cmd:kick-banned:{server}", "moderation.kick_banned", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(TransportEvents.ModerationBanCreatedEvent.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(TransportEvents.ModerationMuteCreatedEvent.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(TransportEvents.ModerationVoteKickCreatedEvent.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(TransportEvents.ModerationAuditAppendedProtocolEvent.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(TransportEvents.ModerationKickBannedCommandEvent.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerCustomNicknameChanged.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom_nickname", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerActiveBadgeChanged.class, "xcore:cmd:player-active-badge:{server}", "player.active_badge", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), @@ -64,7 +64,7 @@ public record RouteSpec( route(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), - route(TransportEvents.PardonPlayer.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(TransportEvents.ModerationPardonCommandEvent.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), rpcRoute(TransportEvents.MapsListRequest.class, "xcore:rpc:req:{server}", "maps.list", 10_000L, ServerScope.PAYLOAD_SERVER, TransportEvents.MapsListResponse.class), rpcRoute(TransportEvents.MapRemoveRequest.class, "xcore:rpc:req:{server}", "maps.remove", 10_000L, ServerScope.PAYLOAD_SERVER, TransportEvents.MapRemoveResponse.class) ); diff --git a/src/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index 76ff675..d852e7d 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -18,8 +18,11 @@ import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.network.ModerationProtocolMapper; +import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import java.util.ArrayList; +import java.time.Instant; import java.util.List; import static arc.util.Strings.stripColors; @@ -111,12 +114,12 @@ public void vote(Player player, int sign) { } } - private TransportEvents.VoteKickEvent buildVoteKickEvent(String status) { + private TransportEvents.ModerationVoteKickCreatedEvent buildVoteKickEvent() { var targetData = sessionService.getOrLoadFromDb(target.uuid()); var starterData = sessionService.getOrLoadFromDb(starter.uuid()); - var votesFor = new ArrayList(); - var votesAgainst = new ArrayList(); + var votesFor = new ArrayList(); + var votesAgainst = new ArrayList(); sessionService.forEachOnline(session -> { var onlinePlayer = session.player; @@ -133,24 +136,23 @@ private TransportEvents.VoteKickEvent buildVoteKickEvent(String status) { } }); - return new TransportEvents.VoteKickEvent( - safePlayerName(targetData, target), - safePid(targetData), + return ModerationProtocolMapper.toVoteKickCreatedEvent( target.uuid(), + safePid(targetData), + safePlayerName(targetData, target), safePlayerName(starterData, starter), safePid(starterData), safeDiscordId(starterData), reason, List.copyOf(votesFor), List.copyOf(votesAgainst), - status, config.server, - System.currentTimeMillis() + Instant.now() ); } - private TransportEvents.VoteKickParticipant toParticipant(PlayerData data) { - return new TransportEvents.VoteKickParticipant( + private VoteKickParticipantV1 toParticipant(PlayerData data) { + return ModerationProtocolMapper.toVoteKickParticipant( safeNickname(data), safePid(data), safeDiscordId(data) @@ -206,7 +208,7 @@ public void success() { target.kick(Packets.KickReason.vote, (long) globalConfig.voteKickBanDurationMinutes * 60 * 1000); if (network != null) { - network.post(buildVoteKickEvent("success")); + network.post(buildVoteKickEvent()); network.post(new TransportEvents.ServerActionEvent( systemLocal.format("votekick-success", bundleArgs), config.server)); } diff --git a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java index 19c9453..ced531e 100644 --- a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java +++ b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java @@ -10,11 +10,18 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.database.repository.MuteDataRepository; import org.xcore.plugin.database.repository.PlayerDataRepository; +import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; +import org.xcore.plugin.model.AuditAction; +import org.xcore.plugin.model.AuditActor; +import org.xcore.plugin.model.AuditActorType; +import org.xcore.plugin.model.AuditOrigin; import org.xcore.plugin.model.AuditRecord; +import org.xcore.plugin.model.AuditTarget; import org.xcore.plugin.model.BanData; import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.PlayerData; @@ -50,6 +57,7 @@ class ModerationServiceAvajeTest { private FindService find; private TimeService time; private AuditService auditService; + private Config config; private Administration admins; @BeforeEach @@ -85,9 +93,10 @@ void setUp() { find = scope.get(FindService.class); time = scope.get(TimeService.class); auditService = scope.get(AuditService.class); + config = scope.get(Config.class); when(auditService.append(any())).thenReturn(org.xcore.plugin.model.AuditAppendResult.success( - AuditRecord.builder().auditId("audit-1").build() + validAuditRecord() )); } @@ -126,20 +135,37 @@ void tempBanSuccess() { order.verify(banDataRepository).save(any(BanData.class)); order.verify(auditService).append(any()); order.verify(network).post(argThat(event -> - event instanceof BanData ban - && "uuid-1".equals(ban.getUuid()) - && "1.2.3.4".equals(ban.getIp()) - && "admin".equals(ban.getAdminName()) - && "12345".equals(ban.getAdminDiscordId()) - && "Unknown".equals(ban.getName()) - && "Not Specified".equals(ban.getReason()) - && !ban.getExpireDate().isBefore(before.plus(duration)) - && !ban.getExpireDate().isAfter(after.plus(duration)))); - order.verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); + event instanceof TransportEvents.ModerationBanCreatedEvent canonical + && canonical.payload() != null + && ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE.equals(canonical.payload().MESSAGE_TYPE) + && ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION == canonical.payload().MESSAGE_VERSION + && canonical.payload().target() != null + && "uuid-1".equals(canonical.payload().target().playerUuid()) + && "Unknown".equals(canonical.payload().target().playerName()) + && "1.2.3.4".equals(canonical.payload().target().ip()) + && canonical.payload().actor() != null + && "admin".equals(canonical.payload().actor().actorName()) + && "12345".equals(canonical.payload().actor().actorDiscordId()) + && "discord".equals(canonical.payload().actor().actorType()) + && "Not Specified".equals(canonical.payload().reason()) + && canonical.payload().expiration() != null + && !canonical.payload().expiration().permanent() + && "test-server".equals(canonical.payload().server()) + && canonical.payload().occurredAt() != null)); + order.verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent + && auditEvent.payload() != null + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE) + && "test-server".equals(auditEvent.payload().server()))); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.KickBannedPlayer kick - && "uuid-1".equals(kick.uuid()) - && "1.2.3.4".equals(kick.ip()))); + event instanceof TransportEvents.ModerationKickBannedCommandEvent kick + && kick.payload() != null + && ModerationMessages.ModerationKickBannedCommandV1.MESSAGE_TYPE.equals(kick.payload().MESSAGE_TYPE) + && "uuid-1".equals(kick.payload().target().playerUuid()) + && "Unknown".equals(kick.payload().target().playerName()) + && "1.2.3.4".equals(kick.payload().target().ip()) + && "test-server".equals(kick.payload().server()) + && kick.payload().requestedAt() != null)); verify(banDataRepository).save(argThat(ban -> "uuid-1".equals(ban.getUuid()) @@ -169,9 +195,9 @@ void tempBanAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.tempBanByUuidOrIp("uuid-1", "1.2.3.4", "name", Duration.ofMinutes(10), "reason", "admin", null); assertThat(result.isSuccess()).isTrue(); - verify(network).post(argThat(event -> event instanceof BanData)); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.KickBannedPlayer)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationBanCreatedEvent)); + verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationKickBannedCommandEvent)); } @Test @@ -192,6 +218,17 @@ void tempUnbanSuccess() { var result = moderationService.tempUnban("uuid-2", null, "console", null); assertThat(result.isSuccess()).isTrue(); + verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent + && auditEvent.payload() != null + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationPardonCommandEvent pardon + && pardon.payload() != null + && ModerationMessages.ModerationPardonCommandV1.MESSAGE_TYPE.equals(pardon.payload().MESSAGE_TYPE) + && "uuid-2".equals(pardon.payload().target().playerUuid()) + && "Unknown".equals(pardon.payload().target().playerName()) + && "test-server".equals(pardon.payload().server()))); verify(banDataRepository).delete("uuid-2", null); } @@ -204,7 +241,8 @@ void tempUnbanAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.tempUnban("uuid-2", null, "console", null); assertThat(result.isSuccess()).isTrue(); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); + verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationPardonCommandEvent)); } @Test @@ -285,8 +323,22 @@ void muteByIdSuccess() { order.verify(muteDataRepository).save(any(MuteData.class)); verify(auditService).append(any()); order.verify(network).post(argThat(event -> - event instanceof MuteData mute && "uuid-3".equals(mute.getUuid()))); - order.verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); + event instanceof TransportEvents.ModerationMuteCreatedEvent mute + && mute.payload() != null + && ModerationMessages.ModerationMuteCreatedV1.MESSAGE_TYPE.equals(mute.payload().MESSAGE_TYPE) + && "uuid-3".equals(mute.payload().target().playerUuid()) + && "Target".equals(mute.payload().target().playerName()) + && "admin".equals(mute.payload().actor().actorName()) + && "777".equals(mute.payload().actor().actorDiscordId()) + && "Not Specified".equals(mute.payload().reason()) + && mute.payload().expiration() != null + && !mute.payload().expiration().permanent() + && "test-server".equals(mute.payload().server()) + && mute.payload().occurredAt() != null)); + order.verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent + && auditEvent.payload() != null + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); } @Test @@ -317,8 +369,8 @@ void muteByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.muteById(7, "admin", null, null, Duration.ofMinutes(15)); assertThat(result.isSuccess()).isTrue(); - verify(network).post(argThat(event -> event instanceof MuteData)); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationMuteCreatedEvent)); + verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); } @Test @@ -334,6 +386,16 @@ void unmuteByIdSuccess() { var result = moderationService.unmuteById(8, "admin", "123"); assertThat(result.isSuccess()).isTrue(); + verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent + && auditEvent.payload() != null + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationPardonCommandEvent pardon + && pardon.payload() != null + && ModerationMessages.ModerationPardonCommandV1.MESSAGE_TYPE.equals(pardon.payload().MESSAGE_TYPE) + && "uuid-4".equals(pardon.payload().target().playerUuid()) + && "Target2".equals(pardon.payload().target().playerName()))); verify(muteDataRepository).delete("uuid-4"); } @@ -348,7 +410,8 @@ void unmuteByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.unmuteById(8, "admin", "123"); assertThat(result.isSuccess()).isTrue(); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); + verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationPardonCommandEvent)); } @Test @@ -385,9 +448,26 @@ void banByIdSavesBeforeSideEffects() { order.verify(banDataRepository).save(any(BanData.class)); verify(auditService).append(any()); order.verify(network).post(argThat(event -> - event instanceof BanData ban && "999".equals(ban.getAdminDiscordId()))); - order.verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); - order.verify(network).post(argThat(event -> event instanceof TransportEvents.KickBannedPlayer)); + event instanceof TransportEvents.ModerationBanCreatedEvent canonical + && canonical.payload() != null + && ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION == canonical.payload().MESSAGE_VERSION + && canonical.payload().target() != null + && "uuid-9".equals(canonical.payload().target().playerUuid()) + && "Target9".equals(canonical.payload().target().playerName()) + && canonical.payload().actor() != null + && "999".equals(canonical.payload().actor().actorDiscordId()) + && "test-server".equals(canonical.payload().server()))); + order.verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent + && auditEvent.payload() != null + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + order.verify(network).post(argThat(event -> + event instanceof TransportEvents.ModerationKickBannedCommandEvent kick + && kick.payload() != null + && ModerationMessages.ModerationKickBannedCommandV1.MESSAGE_TYPE.equals(kick.payload().MESSAGE_TYPE) + && "uuid-9".equals(kick.payload().target().playerUuid()) + && "Target9".equals(kick.payload().target().playerName()) + && "test-server".equals(kick.payload().server()))); } @Test @@ -419,9 +499,9 @@ void banByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.banById(9, "admin", null, null, Duration.ofMinutes(10), true); assertThat(result.isSuccess()).isTrue(); - verify(network).post(argThat(event -> event instanceof BanData)); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.KickBannedPlayer)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationBanCreatedEvent)); + verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationKickBannedCommandEvent)); } @Test @@ -452,7 +532,8 @@ void unbanByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.unbanById(10, "admin", "123"); assertThat(result.isSuccess()).isTrue(); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedEvent)); + verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); + verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationPardonCommandEvent)); } @Test @@ -500,6 +581,25 @@ void findPlayerData_whenFindReturnsNull_returnsNull() { verify(find).playerData("unknown"); } + private static AuditRecord validAuditRecord() { + return AuditRecord.builder() + .auditId("audit-1") + .action(AuditAction.NOTE) + .target(AuditTarget.builder() + .uuid("audit-target-uuid") + .nameSnapshot("Audit Target") + .build()) + .actor(AuditActor.builder() + .type(AuditActorType.SYSTEM) + .nameSnapshot("system") + .build()) + .origin(AuditOrigin.builder() + .serverId("test-server") + .build()) + .occurredAt(Instant.parse("2026-04-27T16:00:00Z")) + .build(); + } + private static final class ModerationServiceModule implements AvajeModule { @Override public Class[] classes() { @@ -508,6 +608,11 @@ public Class[] classes() { @Override public void build(Builder builder) { + if (builder.isBeanAbsent(Config.class)) { + Config config = new Config(); + config.server = "test-server"; + builder.register(config); + } if (builder.isBeanAbsent(ModerationService.class)) { builder.register(new ModerationService( builder.get(PlayerDataRepository.class), @@ -517,7 +622,8 @@ public void build(Builder builder) { builder.get(NetworkService.class), builder.get(FindService.class), builder.get(TimeService.class), - builder.get(AuditService.class) + builder.get(AuditService.class), + builder.get(Config.class) )); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 1ad87c4..ccbbd04 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -13,9 +13,11 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.BanData; +import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.Punishment; import java.time.Instant; @@ -161,7 +163,7 @@ void executeCommandBroadcastDeliveredAcrossServers() throws InterruptedException } @Test - @DisplayName("send serializes BanData with Instant without reflection failure") + @DisplayName("send serializes canonical moderation ban created event on primary route") void sendSerializesBanDataInstant() { Config config = baseConfig("alpha"); requesterBackend = new RedisNetworkBackend(config); @@ -169,7 +171,11 @@ void sendSerializesBanDataInstant() { BanData banData = punishment(new BanData(), "u-1", "player"); banData.ip = "1.2.3.4"; - requesterBackend.send(banData); + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreatedEvent( + banData, + "alpha", + Instant.parse("2026-04-26T00:00:00Z") + )); assertThat(requesterBackend.metricsSnapshot().getOrDefault("publish_failures", 0L)).isEqualTo(0L); @@ -183,55 +189,113 @@ void sendSerializesBanDataInstant() { var last = messages.get(messages.size() - 1).getBody(); @SuppressWarnings("unchecked") Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); - assertThat(last.get("event_type")).isEqualTo("moderation.ban"); + assertThat(last.get("event_type")).isEqualTo("moderation.ban.created"); assertThat(payload) - .containsEntry("messageType", "moderation.ban.created") + .containsEntry("messageType", ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE) .containsEntry("messageVersion", 1.0) .containsEntry("reason", "rule") .containsEntry("server", "alpha") - .containsKey("occurredAt") + .containsEntry("occurredAt", "2026-04-26T00:00:00Z") .containsKeys("target", "actor", "expiration") .doesNotContainKeys("uuid", "name", "adminName", "expireDate"); + } + } + @Test + @DisplayName("send serializes canonical moderation mute created event") + void sendSerializesCanonicalModerationMuteCreatedEvent() { + Config config = baseConfig("alpha"); + requesterBackend = new RedisNetworkBackend(config); + requesterBackend.connect(); + + MuteData muteData = punishment(new MuteData(), "u-1", "player"); + var canonicalEvent = org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreatedEvent( + muteData, + "alpha", + Instant.parse("2026-04-26T00:00:00Z") + ); + requesterBackend.send(canonicalEvent); + + assertThat(requesterBackend.metricsSnapshot().getOrDefault("publish_failures", 0L)).isEqualTo(0L); + + try (RedisClient client = RedisClient.create(config.redisUrl); + StatefulRedisConnection connection = client.connect()) { + List> messages = connection.sync().xread( + XReadArgs.StreamOffset.from("xcore:evt:moderation:mute", "0-0") + ); + + assertThat(messages).isNotEmpty(); + var last = messages.get(messages.size() - 1).getBody(); @SuppressWarnings("unchecked") - Map target = (Map) payload.get("target"); - assertThat(target) - .containsEntry("playerUuid", "u-1") - .containsEntry("playerName", "player") - .containsEntry("ip", "1.2.3.4"); - @SuppressWarnings("unchecked") - Map actor = (Map) payload.get("actor"); - assertThat(actor) - .containsEntry("actorName", "admin") - .containsEntry("actorType", "unknown"); - @SuppressWarnings("unchecked") - Map expiration = (Map) payload.get("expiration"); - assertThat(expiration) - .containsEntry("permanent", false) - .containsKey("expiresAt"); + Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); + assertThat(last.get("event_type")).isEqualTo("moderation.mute.created"); + assertThat(payload) + .containsEntry("messageType", ModerationMessages.ModerationMuteCreatedV1.MESSAGE_TYPE) + .containsEntry("messageVersion", 1.0) + .containsEntry("reason", "rule") + .containsEntry("server", "alpha") + .containsEntry("occurredAt", "2026-04-26T00:00:00Z") + .containsKeys("target", "actor", "expiration") + .doesNotContainKeys("uuid", "name", "adminName", "expireDate"); } } @Test - @DisplayName("send serializes vote-kick event to moderation votekick stream") + @DisplayName("subscribe consumes canonical moderation ban created event from primary route") + void subscribeConsumesCanonicalModerationBanCreatedEvent() throws InterruptedException { + Config config = baseConfig("alpha"); + requesterBackend = new RedisNetworkBackend(config); + requesterBackend.connect(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + + Subscription subscription = requesterBackend.subscribe( + TransportEvents.ModerationBanCreatedEvent.class, + event -> { + received.set(event); + latch.countDown(); + } + ); + + BanData banData = punishment(new BanData(), "u-1", "player"); + banData.ip = "1.2.3.4"; + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreatedEvent( + banData, + "alpha", + Instant.parse("2026-04-26T00:00:00Z") + )); + + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(received.get()).isNotNull(); + assertThat(received.get().payload()).isNotNull(); + assertThat(received.get().payload().MESSAGE_TYPE).isEqualTo(ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE); + assertThat(ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION).isEqualTo(1); + assertThat(received.get().payload().target().playerUuid()).isEqualTo("u-1"); + assertThat(received.get().payload().server()).isEqualTo("alpha"); + + subscription.unsubscribe(); + } + + @Test + @DisplayName("send serializes canonical vote-kick event to moderation votekick stream") void sendSerializesVoteKickEvent() { Config config = baseConfig("alpha"); requesterBackend = new RedisNetworkBackend(config); requesterBackend.connect(); - requesterBackend.send(new TransportEvents.VoteKickEvent( - "Target", - 42, + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickCreatedEvent( "uuid-target", + 42, + "Target", "Starter", 7, "123456", "griefing", - List.of(new TransportEvents.VoteKickParticipant("Starter", 7, "123456")), - List.of(new TransportEvents.VoteKickParticipant("Voter2", 8, "654321")), - "started", + List.of(org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickParticipant("Starter", 7, "123456")), + List.of(org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickParticipant("Voter2", 8, "654321")), "alpha", - 123456789L + Instant.parse("2026-04-26T00:00:00Z") )); assertThat(requesterBackend.metricsSnapshot().getOrDefault("publish_failures", 0L)).isEqualTo(0L); @@ -244,11 +308,16 @@ void sendSerializesVoteKickEvent() { assertThat(messages).isNotEmpty(); var last = messages.get(messages.size() - 1).getBody(); - assertThat(last.get("event_type")).isEqualTo("moderation.votekick"); - assertThat(last.get("payload_json")).contains("Target"); - assertThat(last.get("payload_json")).contains("votesFor"); - assertThat(last.get("payload_json")).contains("votesAgainst"); - assertThat(last.get("payload_json")).doesNotContain("participants"); + @SuppressWarnings("unchecked") + Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); + assertThat(last.get("event_type")).isEqualTo("moderation.vote-kick.created"); + assertThat(payload) + .containsEntry("messageType", ModerationMessages.ModerationVoteKickCreatedV1.MESSAGE_TYPE) + .containsEntry("messageVersion", 1.0) + .containsEntry("reason", "griefing") + .containsEntry("server", "alpha") + .containsEntry("occurredAt", "2026-04-26T00:00:00Z") + .containsKeys("target", "starter", "votesFor", "votesAgainst"); } } @@ -279,7 +348,7 @@ void subscribeConsumesReadOnlyStreamMessages() throws InterruptedException { } @Test - @DisplayName("kick-banned subscribe works") + @DisplayName("kick-banned canonical command subscribe works") void kickBannedSubscribeWorks() throws InterruptedException { Config config = baseConfig("alpha"); @@ -287,21 +356,30 @@ void kickBannedSubscribeWorks() throws InterruptedException { requesterBackend.connect(); CountDownLatch latch = new CountDownLatch(1); - AtomicReference received = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); - Subscription subscription = requesterBackend.subscribe( - TransportEvents.KickBannedPlayer.class, + Subscription subscription = requesterBackend.subscribe( + TransportEvents.ModerationKickBannedCommandEvent.class, event -> { received.set(event); latch.countDown(); } ); - requesterBackend.send(new TransportEvents.KickBannedPlayer("uuid-a", "1.2.3.4")); + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toKickBannedCommandEvent( + "uuid-a", + null, + "Unknown", + "1.2.3.4", + "alpha", + Instant.parse("2026-04-26T00:00:00Z") + )); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(received.get()).isNotNull(); - assertThat(received.get().uuid()).isEqualTo("uuid-a"); + assertThat(received.get().payload().target().playerUuid()).isEqualTo("uuid-a"); + assertThat(received.get().payload().target().ip()).isEqualTo("1.2.3.4"); + assertThat(received.get().payload().server()).isEqualTo("alpha"); subscription.unsubscribe(); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 560bcce..a4dbdd0 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -3,7 +3,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.model.BanData; import static org.assertj.core.api.Assertions.assertThat; @@ -54,7 +53,12 @@ void rpcRouteDescriptorCarriesResponseType() { @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(TransportEvents.GlobalChatEvent.class)).isTrue(); - assertThat(registry.isReadOnlyType(BanData.class)).isTrue(); + assertThat(registry.isReadOnlyType(TransportEvents.ModerationBanCreatedEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(TransportEvents.ModerationMuteCreatedEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(TransportEvents.ModerationVoteKickCreatedEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(TransportEvents.ModerationAuditAppendedProtocolEvent.class)).isTrue(); + assertThat(registry.isMutatingType(TransportEvents.ModerationKickBannedCommandEvent.class)).isTrue(); + assertThat(registry.isMutatingType(TransportEvents.ModerationPardonCommandEvent.class)).isTrue(); assertThat(registry.isMutatingType(TransportEvents.ExecuteCommand.class)).isTrue(); assertThat(registry.isMutatingType(TransportEvents.GlobalChatEvent.class)).isFalse(); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 09b9821..fc32016 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -2,9 +2,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.event.TransportEvents.VoteKickEvent; -import org.xcore.plugin.event.TransportEvents.VoteKickParticipant; import org.xcore.plugin.model.BanData; import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.Punishment; @@ -26,29 +27,49 @@ void routeReadOnlyEvents() { var messageRoute = router.route(new TransportEvents.MessageEvent("a", "b", "mini-pvp"), "mini-pvp"); var joinRoute = router.route(new TransportEvents.PlayerJoinLeaveEvent("p", "mini-pvp", true), "mini-pvp"); - var banRoute = router.route(banData, "mini-pvp"); - var muteRoute = router.route(muteData, "mini-pvp"); + var banRoute = router.route( + org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreatedEvent( + banData, + "mini-pvp", + Instant.parse("2026-04-26T00:00:00Z") + ), + "mini-pvp" + ); + var muteRoute = router.route( + org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreatedEvent( + muteData, + "mini-pvp", + Instant.parse("2026-04-26T00:00:01Z") + ), + "mini-pvp" + ); var voteKickRoute = router.route( - new VoteKickEvent( - "target", - 42, + org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickCreatedEvent( "uuid-target", + 42, + "target", "starter", 7, "123", "griefing", - List.of(new VoteKickParticipant("starter", 7, "123")), + List.of(org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickParticipant("starter", 7, "123")), List.of(), - "started", "mini-pvp", - 10L + Instant.parse("2026-04-26T00:00:02Z") ), "mini-pvp" ); var auditRoute = router.route( - new TransportEvents.ModerationAuditAppendedEvent( - "audit-1", "BAN", "uuid-target", 42, "target", "PLAYER_ADMIN", "admin-1", "Admin", "reason", 60000L, - Instant.now().plusSeconds(60), null, "mini-pvp", Instant.now() + new TransportEvents.ModerationAuditAppendedProtocolEvent( + new ModerationMessages.ModerationAuditAppendedV1( + "ban", + new PlayerRefV1("uuid-target", 42, "target", null), + new ActorRefV1("Admin", "admin-1", "discord"), + "reason", + "mini-pvp", + Instant.parse("2026-04-26T00:00:03Z").toString(), + java.util.Map.of("durationMs", 60000L) + ) ), "mini-pvp" ); @@ -60,16 +81,16 @@ void routeReadOnlyEvents() { assertThat(joinRoute.eventType()).isEqualTo("player.join_leave"); assertThat(banRoute.streamKey()).isEqualTo("xcore:evt:moderation:ban"); - assertThat(banRoute.eventType()).isEqualTo("moderation.ban"); + assertThat(banRoute.eventType()).isEqualTo("moderation.ban.created"); assertThat(muteRoute.streamKey()).isEqualTo("xcore:evt:moderation:mute"); - assertThat(muteRoute.eventType()).isEqualTo("moderation.mute"); + assertThat(muteRoute.eventType()).isEqualTo("moderation.mute.created"); assertThat(voteKickRoute.streamKey()).isEqualTo("xcore:evt:moderation:votekick"); - assertThat(voteKickRoute.eventType()).isEqualTo("moderation.votekick"); + assertThat(voteKickRoute.eventType()).isEqualTo("moderation.vote-kick.created"); assertThat(auditRoute.streamKey()).isEqualTo("xcore:evt:moderation:audit"); - assertThat(auditRoute.eventType()).isEqualTo("moderation.audit"); + assertThat(auditRoute.eventType()).isEqualTo("moderation.audit.appended"); } @Test @@ -130,28 +151,36 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(TransportEvents.DiscordAdminAccessChanged.class, "mini-pvp")) .containsExactly("xcore:cmd:discord-admin-access:mini-pvp"); - assertThat(router.subscribeStreamsFor(BanData.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(TransportEvents.ModerationBanCreatedEvent.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:ban"); - assertThat(router.subscribeStreamsFor(MuteData.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(TransportEvents.ModerationMuteCreatedEvent.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:mute"); - assertThat(router.subscribeStreamsFor(VoteKickEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(TransportEvents.ModerationVoteKickCreatedEvent.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:votekick"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationAuditAppendedEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(TransportEvents.ModerationAuditAppendedProtocolEvent.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:audit"); + + assertThat(router.subscribeStreamsFor(TransportEvents.ModerationKickBannedCommandEvent.class, "mini-pvp")) + .containsExactly("xcore:cmd:kick-banned:mini-pvp"); + + assertThat(router.subscribeStreamsFor(TransportEvents.ModerationPardonCommandEvent.class, "mini-pvp")) + .containsExactly("xcore:cmd:pardon-player:mini-pvp"); } @Test @DisplayName("type classification and rpc response mapping are correct") void classificationAndResponseMapping() { assertThat(router.isReadOnlyType(TransportEvents.DiscordLinkStatusChangedEvent.class)).isTrue(); - assertThat(router.isReadOnlyType(BanData.class)).isTrue(); - assertThat(router.isReadOnlyType(MuteData.class)).isTrue(); - assertThat(router.isReadOnlyType(VoteKickEvent.class)).isTrue(); - assertThat(router.isReadOnlyType(TransportEvents.ModerationAuditAppendedEvent.class)).isTrue(); + assertThat(router.isReadOnlyType(TransportEvents.ModerationBanCreatedEvent.class)).isTrue(); + assertThat(router.isReadOnlyType(TransportEvents.ModerationMuteCreatedEvent.class)).isTrue(); + assertThat(router.isReadOnlyType(TransportEvents.ModerationVoteKickCreatedEvent.class)).isTrue(); + assertThat(router.isReadOnlyType(TransportEvents.ModerationAuditAppendedProtocolEvent.class)).isTrue(); assertThat(router.isReadOnlyType(TransportEvents.DiscordAdminAccessChanged.class)).isFalse(); + assertThat(router.isMutatingType(TransportEvents.ModerationKickBannedCommandEvent.class)).isTrue(); + assertThat(router.isMutatingType(TransportEvents.ModerationPardonCommandEvent.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerPasswordReset.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerBadgeSymbolColorModeChanged.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index ed83b33..0873f83 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -3,7 +3,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.model.BanData; import java.util.List; @@ -138,17 +137,21 @@ void rpcRequestAndResponseEnvelopeFieldsStayStableAndDirectionSpecific() { void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Arrange RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(TransportEvents.GlobalChatEvent.class); - RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(BanData.class); + RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(TransportEvents.ModerationBanCreatedEvent.class); + RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(TransportEvents.ModerationMuteCreatedEvent.class); RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(TransportEvents.MapsListRequest.class); + RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(TransportEvents.ModerationKickBannedCommandEvent.class); // Act RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; + RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; RedisTransportTopology.RouteSpec stableCommandRoute = commandRoute; RedisTransportTopology.RouteSpec stableBroadcastCommandRoute = broadcastCommandRoute; RedisTransportTopology.RouteSpec stableRpcRoute = rpcRoute; + RedisTransportTopology.RouteSpec stableKickBannedRoute = kickBannedRoute; // Assert assertThat(stableEventRoute).isNotNull(); @@ -161,10 +164,16 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableModerationRoute).isNotNull(); assertThat(stableModerationRoute.streamPattern()).isEqualTo("xcore:evt:moderation:ban"); - assertThat(stableModerationRoute.eventType()).isEqualTo("moderation.ban"); + assertThat(stableModerationRoute.eventType()).isEqualTo("moderation.ban.created"); assertThat(stableModerationRoute.readOnly()).isTrue(); assertThat(stableModerationRoute.rpcRequest()).isFalse(); + assertThat(stableMuteRoute).isNotNull(); + assertThat(stableMuteRoute.streamPattern()).isEqualTo("xcore:evt:moderation:mute"); + assertThat(stableMuteRoute.eventType()).isEqualTo("moderation.mute.created"); + assertThat(stableMuteRoute.readOnly()).isTrue(); + assertThat(stableMuteRoute.rpcRequest()).isFalse(); + assertThat(stableCommandRoute).isNotNull(); assertThat(stableCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-password-reset:{server}"); assertThat(stableCommandRoute.eventType()).isEqualTo("player.password_reset"); @@ -178,6 +187,12 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableBroadcastCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); assertThat(stableBroadcastCommandRoute.readOnly()).isFalse(); + assertThat(stableKickBannedRoute).isNotNull(); + assertThat(stableKickBannedRoute.streamPattern()).isEqualTo("xcore:cmd:kick-banned:{server}"); + assertThat(stableKickBannedRoute.eventType()).isEqualTo("moderation.kick-banned.command"); + assertThat(stableKickBannedRoute.readOnly()).isFalse(); + assertThat(stableKickBannedRoute.rpcRequest()).isFalse(); + assertThat(stableRpcRoute).isNotNull(); assertThat(stableRpcRoute.streamPattern()).isEqualTo("xcore:rpc:req:{server}"); assertThat(stableRpcRoute.eventType()).isEqualTo("maps.list"); From fd61b2bd5ee79d6975ff010c57da39f0577a7bcf Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:22:27 +0300 Subject: [PATCH 04/26] refactor(network): use direct moderation DTOs Send and receive moderation transport messages as generated xcore-protocol DTOs so the wire layer no longer depends on local wrapper events. Align routing, handlers, and tests with the canonical moderation contracts while keeping stream identities and build validation green. --- .../transport/ModerationTransportHandler.java | 14 +- .../service/moderation/ModerationService.java | 22 +-- .../network/ModerationProtocolMapper.java | 68 ------- .../service/network/RedisNetworkBackend.java | 46 +---- .../service/network/RedisRouteRegistry.java | 51 ++++- .../network/RedisTransportTopology.java | 20 +- .../java/org/xcore/plugin/vote/VoteKick.java | 5 +- .../ModerationTransportHandlerTest.java | 7 +- .../ModerationServiceAvajeTest.java | 180 +++++++++--------- .../RedisNetworkBackendIntegrationTest.java | 41 ++-- .../network/RedisRouteRegistryTest.java | 18 +- .../network/RedisStreamRouterTest.java | 54 +++--- .../network/RedisTransportContractsTest.java | 9 +- 13 files changed, 234 insertions(+), 301 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index 08e8543..f93154c 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -12,10 +12,11 @@ import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.DiscordAdminAccessService; -import org.xcore.plugin.service.FindService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import java.util.HashSet; import java.util.function.Consumer; @@ -28,7 +29,6 @@ public class ModerationTransportHandler { private final NetworkService network; private final SessionService sessionService; - private final FindService find; private final Config config; private final PlayerDisplayService playerDisplayService; private final DiscordAdminAccessService discordAdminAccessService; @@ -36,22 +36,20 @@ public class ModerationTransportHandler { @Inject public ModerationTransportHandler(NetworkService network, SessionService sessionService, - FindService find, Config config, PlayerDisplayService playerDisplayService, DiscordAdminAccessService discordAdminAccessService) { this.network = network; this.sessionService = sessionService; - this.find = find; this.config = config; this.playerDisplayService = playerDisplayService; this.discordAdminAccessService = discordAdminAccessService; } public void registerListeners() { - network.subscribe(TransportEvents.ModerationKickBannedCommandEvent.class, e -> Groups.player.each( + network.subscribe(ModerationKickBannedCommandV1.class, e -> Groups.player.each( p -> { - var target = e.payload().target(); + var target = e.target(); return p.uuid().equals(target.playerUuid()) || (target.ip() != null && target.ip().equals(p.ip())); }, @@ -71,8 +69,8 @@ public void registerListeners() { } }); - network.subscribe(TransportEvents.ModerationPardonCommandEvent.class, e -> { - Administration.PlayerInfo info = netServer.admins.getInfoOptional(e.payload().target().playerUuid()); + network.subscribe(ModerationPardonCommandV1.class, e -> { + Administration.PlayerInfo info = netServer.admins.getInfoOptional(e.target().playerUuid()); if (info != null) { info.lastKicked = 0; diff --git a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java index 1a7e349..fa15ecc 100644 --- a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java +++ b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java @@ -6,7 +6,6 @@ import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.database.repository.MuteDataRepository; import org.xcore.plugin.database.repository.PlayerDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.AuditAction; import org.xcore.plugin.model.AuditActor; import org.xcore.plugin.model.AuditActorType; @@ -24,6 +23,7 @@ import org.xcore.plugin.service.network.ModerationProtocolMapper; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.service.TimeService; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import java.time.Duration; import java.time.Instant; @@ -126,7 +126,7 @@ public ModerationResult banById(int id, String adminName, String adminD postAuditEvent(audit); if (kickOnline) { - network.post(ModerationProtocolMapper.toKickBannedCommandEvent( + network.post(ModerationProtocolMapper.toKickBannedCommand( target.uuid, target.pid, target.nickname, @@ -166,7 +166,7 @@ public ModerationResult unbanById(int id, String adminName, String a ); postAuditEvent(audit); - network.post(toPardonCommandEvent(target.uuid, target.pid, target.nickname, audit)); + network.post(toPardonCommand(target.uuid, target.pid, target.nickname, audit)); return ModerationResult.success("Player '" + target.nickname + "' unbanned successfully", target); } @@ -211,7 +211,7 @@ public ModerationResult muteById(int id, String adminName, String admi null ); - network.post(ModerationProtocolMapper.toMuteCreatedEvent(mute, config.server, eventOccurredAt(audit))); + network.post(ModerationProtocolMapper.toMuteCreated(mute, config.server, eventOccurredAt(audit))); postAuditEvent(audit); return ModerationResult.success("Player '" + target.nickname + "' muted successfully", mute); @@ -244,7 +244,7 @@ public ModerationResult unmuteById(int id, String adminName, String ); postAuditEvent(audit); - network.post(toPardonCommandEvent(target.uuid, target.pid, target.nickname, audit)); + network.post(toPardonCommand(target.uuid, target.pid, target.nickname, audit)); return ModerationResult.success("Player '" + target.nickname + "' unmuted successfully", target); } @@ -293,7 +293,7 @@ public ModerationResult tempBanByUuidOrIp(String uuid, String ip, Strin postBanEvents(ban, audit); postAuditEvent(audit); - network.post(ModerationProtocolMapper.toKickBannedCommandEvent( + network.post(ModerationProtocolMapper.toKickBannedCommand( uuid, null, ban.name, @@ -332,7 +332,7 @@ public ModerationResult tempUnban(String uuid, String ip, String adminName ); postAuditEvent(audit); - network.post(toPardonCommandEvent(uuid, null, UNKNOWN_PLAYER_NAME, audit)); + network.post(toPardonCommand(uuid, null, UNKNOWN_PLAYER_NAME, audit)); return ModerationResult.success("Unbanned: UUID=" + uuid + " / IP=" + ip, null); } @@ -395,16 +395,16 @@ private AuditRecord appendAudit(AuditAction action, private void postAuditEvent(AuditRecord audit) { if (audit != null) { - network.post(ModerationProtocolMapper.toAuditAppendedEvent(audit, config.server)); + network.post(ModerationProtocolMapper.toAuditAppended(audit, config.server)); } } private void postBanEvents(BanData ban, AuditRecord audit) { - network.post(ModerationProtocolMapper.toBanCreatedEvent(ban, config.server, eventOccurredAt(audit))); + network.post(ModerationProtocolMapper.toBanCreated(ban, config.server, eventOccurredAt(audit))); } - private TransportEvents.ModerationPardonCommandEvent toPardonCommandEvent(String uuid, Integer pid, String playerName, AuditRecord audit) { - return ModerationProtocolMapper.toPardonCommandEvent( + private ModerationPardonCommandV1 toPardonCommand(String uuid, Integer pid, String playerName, AuditRecord audit) { + return ModerationProtocolMapper.toPardonCommand( uuid, pid, playerName, diff --git a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java index d898f68..cd05a84 100644 --- a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java @@ -1,6 +1,5 @@ package org.xcore.plugin.service.network; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.AuditAction; import org.xcore.plugin.model.AuditActorType; import org.xcore.plugin.model.AuditRecord; @@ -40,10 +39,6 @@ public static ModerationBanCreatedV1 toBanCreated(BanData ban, String server, In ); } - public static TransportEvents.ModerationBanCreatedEvent toBanCreatedEvent(BanData ban, String server, Instant occurredAt) { - return new TransportEvents.ModerationBanCreatedEvent(toBanCreated(ban, server, occurredAt)); - } - public static ModerationMuteCreatedV1 toMuteCreated(MuteData mute, String server, Instant occurredAt) { return new ModerationMuteCreatedV1( toPlayerRef(mute), @@ -55,10 +50,6 @@ public static ModerationMuteCreatedV1 toMuteCreated(MuteData mute, String server ); } - public static TransportEvents.ModerationMuteCreatedEvent toMuteCreatedEvent(MuteData mute, String server, Instant occurredAt) { - return new TransportEvents.ModerationMuteCreatedEvent(toMuteCreated(mute, server, occurredAt)); - } - public static ModerationVoteKickCreatedV1 toVoteKickCreated( String targetUuid, Integer targetPid, @@ -83,36 +74,6 @@ public static ModerationVoteKickCreatedV1 toVoteKickCreated( ); } - public static TransportEvents.ModerationVoteKickCreatedEvent toVoteKickCreatedEvent( - String targetUuid, - Integer targetPid, - String targetName, - String starterName, - Integer starterPid, - String starterDiscordId, - String reason, - List votesFor, - List votesAgainst, - String server, - Instant occurredAt - ) { - return new TransportEvents.ModerationVoteKickCreatedEvent( - toVoteKickCreated( - targetUuid, - targetPid, - targetName, - starterName, - starterPid, - starterDiscordId, - reason, - votesFor, - votesAgainst, - server, - occurredAt - ) - ); - } - public static VoteKickParticipantV1 toVoteKickParticipant(String name, Integer pid, String discordId) { return new VoteKickParticipantV1(resolveActorName(name), normalizeOptionalPid(pid), normalizeOptional(discordId)); } @@ -137,19 +98,6 @@ public static ModerationKickBannedCommandV1 toKickBannedCommand( ); } - public static TransportEvents.ModerationKickBannedCommandEvent toKickBannedCommandEvent( - String playerUuid, - Integer playerPid, - String playerName, - String ip, - String server, - Instant requestedAt - ) { - return new TransportEvents.ModerationKickBannedCommandEvent( - toKickBannedCommand(playerUuid, playerPid, playerName, ip, server, requestedAt) - ); - } - public static ModerationPardonCommandV1 toPardonCommand( String playerUuid, Integer playerPid, @@ -164,18 +112,6 @@ public static ModerationPardonCommandV1 toPardonCommand( ); } - public static TransportEvents.ModerationPardonCommandEvent toPardonCommandEvent( - String playerUuid, - Integer playerPid, - String playerName, - String server, - Instant requestedAt - ) { - return new TransportEvents.ModerationPardonCommandEvent( - toPardonCommand(playerUuid, playerPid, playerName, server, requestedAt) - ); - } - public static ModerationAuditAppendedV1 toAuditAppended(AuditRecord record, String server) { Objects.requireNonNull(record, "record must not be null"); @@ -199,10 +135,6 @@ public static ModerationAuditAppendedV1 toAuditAppended(AuditRecord record, Stri ); } - public static TransportEvents.ModerationAuditAppendedProtocolEvent toAuditAppendedEvent(AuditRecord record, String server) { - return new TransportEvents.ModerationAuditAppendedProtocolEvent(toAuditAppended(record, server)); - } - private static PlayerRefV1 toPlayerRef(Punishment punishment) { return new PlayerRefV1( requireNonBlank(punishment.uuid, "playerUuid"), diff --git a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java index 7def354..8b84e27 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -147,7 +147,7 @@ public void send(Object event) { try { var route = router.route(event, config.server); long now = System.currentTimeMillis(); - String payloadJson = payloadJson(event, now); + String payloadJson = payloadJson(event); RedisCommands commands = connectionManager.commands(); streamSupport.xaddWithTrim(commands, route.streamKey(), envelopeFactory.eventFields(route, payloadJson, now)); publishedEvents.incrementAndGet(); @@ -301,25 +301,7 @@ public boolean supportsRespond(Request request) { return rpcTracker.contains(request); } - private String payloadJson(Object event, long now) { - if (event instanceof TransportEvents.ModerationBanCreatedEvent canonicalEvent) { - return gson.toJson(canonicalEvent.payload()); - } - if (event instanceof TransportEvents.ModerationMuteCreatedEvent canonicalEvent) { - return gson.toJson(canonicalEvent.payload()); - } - if (event instanceof TransportEvents.ModerationVoteKickCreatedEvent canonicalEvent) { - return gson.toJson(canonicalEvent.payload()); - } - if (event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent canonicalEvent) { - return gson.toJson(canonicalEvent.payload()); - } - if (event instanceof TransportEvents.ModerationKickBannedCommandEvent canonicalEvent) { - return gson.toJson(canonicalEvent.payload()); - } - if (event instanceof TransportEvents.ModerationPardonCommandEvent canonicalEvent) { - return gson.toJson(canonicalEvent.payload()); - } + private String payloadJson(Object event) { return gson.toJson(event); } @@ -526,30 +508,6 @@ private boolean dispatchStreamMessage(RedisCommands consumer @SuppressWarnings("unchecked") private T decodeEvent(String payloadJson, Class type) { - if (type == TransportEvents.ModerationBanCreatedEvent.class) { - var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1.class); - return (T) new TransportEvents.ModerationBanCreatedEvent(payload); - } - if (type == TransportEvents.ModerationMuteCreatedEvent.class) { - var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1.class); - return (T) new TransportEvents.ModerationMuteCreatedEvent(payload); - } - if (type == TransportEvents.ModerationVoteKickCreatedEvent.class) { - var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1.class); - return (T) new TransportEvents.ModerationVoteKickCreatedEvent(payload); - } - if (type == TransportEvents.ModerationAuditAppendedProtocolEvent.class) { - var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1.class); - return (T) new TransportEvents.ModerationAuditAppendedProtocolEvent(payload); - } - if (type == TransportEvents.ModerationKickBannedCommandEvent.class) { - var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1.class); - return (T) new TransportEvents.ModerationKickBannedCommandEvent(payload); - } - if (type == TransportEvents.ModerationPardonCommandEvent.class) { - var payload = gson.fromJson(payloadJson, org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1.class); - return (T) new TransportEvents.ModerationPardonCommandEvent(payload); - } return gson.fromJson(payloadJson, type); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index f3094ec..eef0db2 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,8 +1,12 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.model.BanData; -import org.xcore.plugin.model.MuteData; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -11,7 +15,16 @@ import java.util.Map; public final class RedisRouteRegistry { + private static final RedisServerResolver MODERATION_SERVER_RESOLVER = (payload, defaultServer) -> { + String server = moderationServer(payload); + return server == null || server.isBlank() ? defaultServer : server; + }; + private static final RedisServerResolver PAYLOAD_SERVER_RESOLVER = (payload, defaultServer) -> { + String moderationServer = moderationServer(payload); + if (moderationServer != null && !moderationServer.isBlank()) { + return moderationServer; + } if (payload instanceof TransportEvents.ServerScopedEvent serverScopedEvent) { String server = serverScopedEvent.server(); if (server != null && !server.isBlank()) { @@ -110,11 +123,11 @@ private void registerDefaults() { register(readOnly(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.ModerationBanCreatedEvent.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.ModerationMuteCreatedEvent.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.ModerationVoteKickCreatedEvent.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.ModerationAuditAppendedProtocolEvent.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, RedisServerResolver.broadcast())); - register(mutating(TransportEvents.ModerationKickBannedCommandEvent.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, RedisServerResolver.defaultServer())); + register(readOnly(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, RedisServerResolver.broadcast())); + register(readOnly(ModerationMuteCreatedV1.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, RedisServerResolver.broadcast())); + register(readOnly(ModerationVoteKickCreatedV1.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, RedisServerResolver.broadcast())); + register(readOnly(ModerationAuditAppendedV1.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, RedisServerResolver.broadcast())); + register(mutating(ModerationKickBannedCommandV1.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, MODERATION_SERVER_RESOLVER)); register(mutating(TransportEvents.PlayerCustomNicknameChanged.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom_nickname", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerActiveBadgeChanged.class, "xcore:cmd:player-active-badge:{server}", "player.active_badge", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, RedisServerResolver.defaultServer())); @@ -128,11 +141,33 @@ private void registerDefaults() { register(mutating(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, RedisServerResolver.broadcast())); - register(mutating(TransportEvents.ModerationPardonCommandEvent.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, RedisServerResolver.defaultServer())); + register(mutating(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, MODERATION_SERVER_RESOLVER)); register(rpc(TransportEvents.MapsListRequest.class, "xcore:rpc:req:{server}", "maps.list", 10_000L, PAYLOAD_SERVER_RESOLVER, TransportEvents.MapsListResponse.class)); register(rpc(TransportEvents.MapRemoveRequest.class, "xcore:rpc:req:{server}", "maps.remove", 10_000L, PAYLOAD_SERVER_RESOLVER, TransportEvents.MapRemoveResponse.class)); } + private static String moderationServer(Object payload) { + if (payload instanceof ModerationBanCreatedV1 event) { + return event.server(); + } + if (payload instanceof ModerationMuteCreatedV1 event) { + return event.server(); + } + if (payload instanceof ModerationVoteKickCreatedV1 event) { + return event.server(); + } + if (payload instanceof ModerationAuditAppendedV1 event) { + return event.server(); + } + if (payload instanceof ModerationKickBannedCommandV1 command) { + return command.server(); + } + if (payload instanceof ModerationPardonCommandV1 command) { + return command.server(); + } + return null; + } + private void register(RedisRouteDescriptor descriptor) { descriptorsByType.put(descriptor.payloadType(), descriptor); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index ac7feb4..5b16c07 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -1,8 +1,12 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.model.BanData; -import org.xcore.plugin.model.MuteData; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import java.util.List; import java.util.Map; @@ -46,11 +50,11 @@ public record RouteSpec( route(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), route(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ModerationBanCreatedEvent.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ModerationMuteCreatedEvent.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ModerationVoteKickCreatedEvent.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ModerationAuditAppendedProtocolEvent.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ModerationKickBannedCommandEvent.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ModerationMuteCreatedV1.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ModerationVoteKickCreatedV1.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ModerationAuditAppendedV1.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ModerationKickBannedCommandV1.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.PlayerCustomNicknameChanged.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom_nickname", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerActiveBadgeChanged.class, "xcore:cmd:player-active-badge:{server}", "player.active_badge", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), @@ -64,7 +68,7 @@ public record RouteSpec( route(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), - route(TransportEvents.ModerationPardonCommandEvent.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), rpcRoute(TransportEvents.MapsListRequest.class, "xcore:rpc:req:{server}", "maps.list", 10_000L, ServerScope.PAYLOAD_SERVER, TransportEvents.MapsListResponse.class), rpcRoute(TransportEvents.MapRemoveRequest.class, "xcore:rpc:req:{server}", "maps.remove", 10_000L, ServerScope.PAYLOAD_SERVER, TransportEvents.MapRemoveResponse.class) ); diff --git a/src/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index d852e7d..ec54ef3 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -19,6 +19,7 @@ import org.xcore.plugin.session.SessionService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.network.ModerationProtocolMapper; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import java.util.ArrayList; @@ -114,7 +115,7 @@ public void vote(Player player, int sign) { } } - private TransportEvents.ModerationVoteKickCreatedEvent buildVoteKickEvent() { + private ModerationVoteKickCreatedV1 buildVoteKickEvent() { var targetData = sessionService.getOrLoadFromDb(target.uuid()); var starterData = sessionService.getOrLoadFromDb(starter.uuid()); @@ -136,7 +137,7 @@ private TransportEvents.ModerationVoteKickCreatedEvent buildVoteKickEvent() { } }); - return ModerationProtocolMapper.toVoteKickCreatedEvent( + return ModerationProtocolMapper.toVoteKickCreated( target.uuid(), safePid(targetData), safePlayerName(targetData, target), diff --git a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java index 419ed6f..de60056 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -11,7 +11,6 @@ import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.service.DiscordAdminAccessService; -import org.xcore.plugin.service.FindService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.service.network.RedisNetworkBackend; @@ -48,14 +47,13 @@ void tearDown() { void discordAdminAccessEvent_appliesPersistedAdminFlags() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); - FindService find = mock(FindService.class); PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); Config config = new Config(); config.server = "mini-pvp"; - ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, find, config, playerDisplayService, discordAdminAccessService); + ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); Map, Cons> listeners = new HashMap<>(); captureListeners(network, listeners); @@ -78,14 +76,13 @@ void discordAdminAccessEvent_appliesPersistedAdminFlags() { void discordAdminRevokeEvent_clearsPersistedAdminFlags() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); - FindService find = mock(FindService.class); PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); Config config = new Config(); config.server = "mini-pvp"; - ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, find, config, playerDisplayService, discordAdminAccessService); + ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); Map, Cons> listeners = new HashMap<>(); captureListeners(network, listeners); diff --git a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java index ced531e..7c00fec 100644 --- a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java +++ b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java @@ -11,11 +11,15 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.database.repository.MuteDataRepository; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.AuditAction; import org.xcore.plugin.model.AuditActor; import org.xcore.plugin.model.AuditActorType; @@ -135,37 +139,34 @@ void tempBanSuccess() { order.verify(banDataRepository).save(any(BanData.class)); order.verify(auditService).append(any()); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationBanCreatedEvent canonical - && canonical.payload() != null - && ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE.equals(canonical.payload().MESSAGE_TYPE) - && ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION == canonical.payload().MESSAGE_VERSION - && canonical.payload().target() != null - && "uuid-1".equals(canonical.payload().target().playerUuid()) - && "Unknown".equals(canonical.payload().target().playerName()) - && "1.2.3.4".equals(canonical.payload().target().ip()) - && canonical.payload().actor() != null - && "admin".equals(canonical.payload().actor().actorName()) - && "12345".equals(canonical.payload().actor().actorDiscordId()) - && "discord".equals(canonical.payload().actor().actorType()) - && "Not Specified".equals(canonical.payload().reason()) - && canonical.payload().expiration() != null - && !canonical.payload().expiration().permanent() - && "test-server".equals(canonical.payload().server()) - && canonical.payload().occurredAt() != null)); + event instanceof ModerationBanCreatedV1 canonical + && ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE.equals(ModerationBanCreatedV1.MESSAGE_TYPE) + && ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION == ModerationBanCreatedV1.MESSAGE_VERSION + && canonical.target() != null + && "uuid-1".equals(canonical.target().playerUuid()) + && "Unknown".equals(canonical.target().playerName()) + && "1.2.3.4".equals(canonical.target().ip()) + && canonical.actor() != null + && "admin".equals(canonical.actor().actorName()) + && "12345".equals(canonical.actor().actorDiscordId()) + && "discord".equals(canonical.actor().actorType()) + && "Not Specified".equals(canonical.reason()) + && canonical.expiration() != null + && !canonical.expiration().permanent() + && "test-server".equals(canonical.server()) + && canonical.occurredAt() != null)); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent - && auditEvent.payload() != null - && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE) - && "test-server".equals(auditEvent.payload().server()))); + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE) + && "test-server".equals(auditEvent.server()))); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationKickBannedCommandEvent kick - && kick.payload() != null - && ModerationMessages.ModerationKickBannedCommandV1.MESSAGE_TYPE.equals(kick.payload().MESSAGE_TYPE) - && "uuid-1".equals(kick.payload().target().playerUuid()) - && "Unknown".equals(kick.payload().target().playerName()) - && "1.2.3.4".equals(kick.payload().target().ip()) - && "test-server".equals(kick.payload().server()) - && kick.payload().requestedAt() != null)); + event instanceof ModerationKickBannedCommandV1 kick + && ModerationMessages.ModerationKickBannedCommandV1.MESSAGE_TYPE.equals(ModerationKickBannedCommandV1.MESSAGE_TYPE) + && "uuid-1".equals(kick.target().playerUuid()) + && "Unknown".equals(kick.target().playerName()) + && "1.2.3.4".equals(kick.target().ip()) + && "test-server".equals(kick.server()) + && kick.requestedAt() != null)); verify(banDataRepository).save(argThat(ban -> "uuid-1".equals(ban.getUuid()) @@ -195,9 +196,9 @@ void tempBanAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.tempBanByUuidOrIp("uuid-1", "1.2.3.4", "name", Duration.ofMinutes(10), "reason", "admin", null); assertThat(result.isSuccess()).isTrue(); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationBanCreatedEvent)); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationKickBannedCommandEvent)); + verify(network).post(argThat(event -> event instanceof ModerationBanCreatedV1)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationKickBannedCommandV1)); } @Test @@ -219,16 +220,14 @@ void tempUnbanSuccess() { assertThat(result.isSuccess()).isTrue(); verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent - && auditEvent.payload() != null - && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationPardonCommandEvent pardon - && pardon.payload() != null - && ModerationMessages.ModerationPardonCommandV1.MESSAGE_TYPE.equals(pardon.payload().MESSAGE_TYPE) - && "uuid-2".equals(pardon.payload().target().playerUuid()) - && "Unknown".equals(pardon.payload().target().playerName()) - && "test-server".equals(pardon.payload().server()))); + event instanceof ModerationPardonCommandV1 pardon + && ModerationMessages.ModerationPardonCommandV1.MESSAGE_TYPE.equals(ModerationPardonCommandV1.MESSAGE_TYPE) + && "uuid-2".equals(pardon.target().playerUuid()) + && "Unknown".equals(pardon.target().playerName()) + && "test-server".equals(pardon.server()))); verify(banDataRepository).delete("uuid-2", null); } @@ -241,8 +240,8 @@ void tempUnbanAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.tempUnban("uuid-2", null, "console", null); assertThat(result.isSuccess()).isTrue(); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationPardonCommandEvent)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationPardonCommandV1)); } @Test @@ -323,22 +322,20 @@ void muteByIdSuccess() { order.verify(muteDataRepository).save(any(MuteData.class)); verify(auditService).append(any()); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationMuteCreatedEvent mute - && mute.payload() != null - && ModerationMessages.ModerationMuteCreatedV1.MESSAGE_TYPE.equals(mute.payload().MESSAGE_TYPE) - && "uuid-3".equals(mute.payload().target().playerUuid()) - && "Target".equals(mute.payload().target().playerName()) - && "admin".equals(mute.payload().actor().actorName()) - && "777".equals(mute.payload().actor().actorDiscordId()) - && "Not Specified".equals(mute.payload().reason()) - && mute.payload().expiration() != null - && !mute.payload().expiration().permanent() - && "test-server".equals(mute.payload().server()) - && mute.payload().occurredAt() != null)); + event instanceof ModerationMuteCreatedV1 mute + && ModerationMessages.ModerationMuteCreatedV1.MESSAGE_TYPE.equals(ModerationMuteCreatedV1.MESSAGE_TYPE) + && "uuid-3".equals(mute.target().playerUuid()) + && "Target".equals(mute.target().playerName()) + && "admin".equals(mute.actor().actorName()) + && "777".equals(mute.actor().actorDiscordId()) + && "Not Specified".equals(mute.reason()) + && mute.expiration() != null + && !mute.expiration().permanent() + && "test-server".equals(mute.server()) + && mute.occurredAt() != null)); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent - && auditEvent.payload() != null - && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); } @Test @@ -369,8 +366,8 @@ void muteByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.muteById(7, "admin", null, null, Duration.ofMinutes(15)); assertThat(result.isSuccess()).isTrue(); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationMuteCreatedEvent)); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); + verify(network).post(argThat(event -> event instanceof ModerationMuteCreatedV1)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); } @Test @@ -387,15 +384,13 @@ void unmuteByIdSuccess() { assertThat(result.isSuccess()).isTrue(); verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent - && auditEvent.payload() != null - && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationPardonCommandEvent pardon - && pardon.payload() != null - && ModerationMessages.ModerationPardonCommandV1.MESSAGE_TYPE.equals(pardon.payload().MESSAGE_TYPE) - && "uuid-4".equals(pardon.payload().target().playerUuid()) - && "Target2".equals(pardon.payload().target().playerName()))); + event instanceof ModerationPardonCommandV1 pardon + && ModerationMessages.ModerationPardonCommandV1.MESSAGE_TYPE.equals(ModerationPardonCommandV1.MESSAGE_TYPE) + && "uuid-4".equals(pardon.target().playerUuid()) + && "Target2".equals(pardon.target().playerName()))); verify(muteDataRepository).delete("uuid-4"); } @@ -410,8 +405,8 @@ void unmuteByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.unmuteById(8, "admin", "123"); assertThat(result.isSuccess()).isTrue(); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationPardonCommandEvent)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationPardonCommandV1)); } @Test @@ -448,26 +443,23 @@ void banByIdSavesBeforeSideEffects() { order.verify(banDataRepository).save(any(BanData.class)); verify(auditService).append(any()); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationBanCreatedEvent canonical - && canonical.payload() != null - && ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION == canonical.payload().MESSAGE_VERSION - && canonical.payload().target() != null - && "uuid-9".equals(canonical.payload().target().playerUuid()) - && "Target9".equals(canonical.payload().target().playerName()) - && canonical.payload().actor() != null - && "999".equals(canonical.payload().actor().actorDiscordId()) - && "test-server".equals(canonical.payload().server()))); + event instanceof ModerationBanCreatedV1 canonical + && ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION == ModerationBanCreatedV1.MESSAGE_VERSION + && canonical.target() != null + && "uuid-9".equals(canonical.target().playerUuid()) + && "Target9".equals(canonical.target().playerName()) + && canonical.actor() != null + && "999".equals(canonical.actor().actorDiscordId()) + && "test-server".equals(canonical.server()))); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent auditEvent - && auditEvent.payload() != null - && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(auditEvent.payload().MESSAGE_TYPE))); + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); order.verify(network).post(argThat(event -> - event instanceof TransportEvents.ModerationKickBannedCommandEvent kick - && kick.payload() != null - && ModerationMessages.ModerationKickBannedCommandV1.MESSAGE_TYPE.equals(kick.payload().MESSAGE_TYPE) - && "uuid-9".equals(kick.payload().target().playerUuid()) - && "Target9".equals(kick.payload().target().playerName()) - && "test-server".equals(kick.payload().server()))); + event instanceof ModerationKickBannedCommandV1 kick + && ModerationMessages.ModerationKickBannedCommandV1.MESSAGE_TYPE.equals(ModerationKickBannedCommandV1.MESSAGE_TYPE) + && "uuid-9".equals(kick.target().playerUuid()) + && "Target9".equals(kick.target().playerName()) + && "test-server".equals(kick.server()))); } @Test @@ -499,9 +491,9 @@ void banByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.banById(9, "admin", null, null, Duration.ofMinutes(10), true); assertThat(result.isSuccess()).isTrue(); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationBanCreatedEvent)); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationKickBannedCommandEvent)); + verify(network).post(argThat(event -> event instanceof ModerationBanCreatedV1)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationKickBannedCommandV1)); } @Test @@ -532,8 +524,8 @@ void unbanByIdAuditFailureDoesNotFlipResultToFailure() { var result = moderationService.unbanById(10, "admin", "123"); assertThat(result.isSuccess()).isTrue(); - verify(network, never()).post(argThat(event -> event instanceof TransportEvents.ModerationAuditAppendedProtocolEvent)); - verify(network).post(argThat(event -> event instanceof TransportEvents.ModerationPardonCommandEvent)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationPardonCommandV1)); } @Test diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index ccbbd04..0ff1eff 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -13,6 +13,10 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; @@ -171,7 +175,7 @@ void sendSerializesBanDataInstant() { BanData banData = punishment(new BanData(), "u-1", "player"); banData.ip = "1.2.3.4"; - requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreatedEvent( + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreated( banData, "alpha", Instant.parse("2026-04-26T00:00:00Z") @@ -209,7 +213,7 @@ void sendSerializesCanonicalModerationMuteCreatedEvent() { requesterBackend.connect(); MuteData muteData = punishment(new MuteData(), "u-1", "player"); - var canonicalEvent = org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreatedEvent( + var canonicalEvent = org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreated( muteData, "alpha", Instant.parse("2026-04-26T00:00:00Z") @@ -248,19 +252,19 @@ void subscribeConsumesCanonicalModerationBanCreatedEvent() throws InterruptedExc requesterBackend.connect(); CountDownLatch latch = new CountDownLatch(1); - AtomicReference received = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); - Subscription subscription = requesterBackend.subscribe( - TransportEvents.ModerationBanCreatedEvent.class, + Subscription subscription = requesterBackend.subscribe( + ModerationBanCreatedV1.class, event -> { received.set(event); latch.countDown(); } ); - + BanData banData = punishment(new BanData(), "u-1", "player"); banData.ip = "1.2.3.4"; - requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreatedEvent( + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreated( banData, "alpha", Instant.parse("2026-04-26T00:00:00Z") @@ -268,11 +272,10 @@ void subscribeConsumesCanonicalModerationBanCreatedEvent() throws InterruptedExc assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(received.get()).isNotNull(); - assertThat(received.get().payload()).isNotNull(); - assertThat(received.get().payload().MESSAGE_TYPE).isEqualTo(ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE); + assertThat(ModerationBanCreatedV1.MESSAGE_TYPE).isEqualTo(ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE); assertThat(ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION).isEqualTo(1); - assertThat(received.get().payload().target().playerUuid()).isEqualTo("u-1"); - assertThat(received.get().payload().server()).isEqualTo("alpha"); + assertThat(received.get().target().playerUuid()).isEqualTo("u-1"); + assertThat(received.get().server()).isEqualTo("alpha"); subscription.unsubscribe(); } @@ -284,7 +287,7 @@ void sendSerializesVoteKickEvent() { requesterBackend = new RedisNetworkBackend(config); requesterBackend.connect(); - requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickCreatedEvent( + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickCreated( "uuid-target", 42, "Target", @@ -356,17 +359,17 @@ void kickBannedSubscribeWorks() throws InterruptedException { requesterBackend.connect(); CountDownLatch latch = new CountDownLatch(1); - AtomicReference received = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); - Subscription subscription = requesterBackend.subscribe( - TransportEvents.ModerationKickBannedCommandEvent.class, + Subscription subscription = requesterBackend.subscribe( + ModerationKickBannedCommandV1.class, event -> { received.set(event); latch.countDown(); } ); - requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toKickBannedCommandEvent( + requesterBackend.send(org.xcore.plugin.service.network.ModerationProtocolMapper.toKickBannedCommand( "uuid-a", null, "Unknown", @@ -377,9 +380,9 @@ void kickBannedSubscribeWorks() throws InterruptedException { assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(received.get()).isNotNull(); - assertThat(received.get().payload().target().playerUuid()).isEqualTo("uuid-a"); - assertThat(received.get().payload().target().ip()).isEqualTo("1.2.3.4"); - assertThat(received.get().payload().server()).isEqualTo("alpha"); + assertThat(received.get().target().playerUuid()).isEqualTo("uuid-a"); + assertThat(received.get().target().ip()).isEqualTo("1.2.3.4"); + assertThat(received.get().server()).isEqualTo("alpha"); subscription.unsubscribe(); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index a4dbdd0..d2bf02d 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,6 +2,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.plugin.event.TransportEvents; import static org.assertj.core.api.Assertions.assertThat; @@ -53,12 +59,12 @@ void rpcRouteDescriptorCarriesResponseType() { @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(TransportEvents.GlobalChatEvent.class)).isTrue(); - assertThat(registry.isReadOnlyType(TransportEvents.ModerationBanCreatedEvent.class)).isTrue(); - assertThat(registry.isReadOnlyType(TransportEvents.ModerationMuteCreatedEvent.class)).isTrue(); - assertThat(registry.isReadOnlyType(TransportEvents.ModerationVoteKickCreatedEvent.class)).isTrue(); - assertThat(registry.isReadOnlyType(TransportEvents.ModerationAuditAppendedProtocolEvent.class)).isTrue(); - assertThat(registry.isMutatingType(TransportEvents.ModerationKickBannedCommandEvent.class)).isTrue(); - assertThat(registry.isMutatingType(TransportEvents.ModerationPardonCommandEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ModerationAuditAppendedV1.class)).isTrue(); + assertThat(registry.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); + assertThat(registry.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(TransportEvents.ExecuteCommand.class)).isTrue(); assertThat(registry.isMutatingType(TransportEvents.GlobalChatEvent.class)).isFalse(); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index fc32016..9801046 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -2,6 +2,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.protocol.generated.shared.ActorRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; @@ -28,7 +34,7 @@ void routeReadOnlyEvents() { var messageRoute = router.route(new TransportEvents.MessageEvent("a", "b", "mini-pvp"), "mini-pvp"); var joinRoute = router.route(new TransportEvents.PlayerJoinLeaveEvent("p", "mini-pvp", true), "mini-pvp"); var banRoute = router.route( - org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreatedEvent( + org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreated( banData, "mini-pvp", Instant.parse("2026-04-26T00:00:00Z") @@ -36,7 +42,7 @@ void routeReadOnlyEvents() { "mini-pvp" ); var muteRoute = router.route( - org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreatedEvent( + org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreated( muteData, "mini-pvp", Instant.parse("2026-04-26T00:00:01Z") @@ -44,7 +50,7 @@ void routeReadOnlyEvents() { "mini-pvp" ); var voteKickRoute = router.route( - org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickCreatedEvent( + org.xcore.plugin.service.network.ModerationProtocolMapper.toVoteKickCreated( "uuid-target", 42, "target", @@ -60,16 +66,14 @@ void routeReadOnlyEvents() { "mini-pvp" ); var auditRoute = router.route( - new TransportEvents.ModerationAuditAppendedProtocolEvent( - new ModerationMessages.ModerationAuditAppendedV1( - "ban", - new PlayerRefV1("uuid-target", 42, "target", null), - new ActorRefV1("Admin", "admin-1", "discord"), - "reason", - "mini-pvp", - Instant.parse("2026-04-26T00:00:03Z").toString(), - java.util.Map.of("durationMs", 60000L) - ) + new ModerationAuditAppendedV1( + "ban", + new PlayerRefV1("uuid-target", 42, "target", null), + new ActorRefV1("Admin", "admin-1", "discord"), + "reason", + "mini-pvp", + Instant.parse("2026-04-26T00:00:03Z").toString(), + java.util.Map.of("durationMs", 60000L) ), "mini-pvp" ); @@ -151,22 +155,22 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(TransportEvents.DiscordAdminAccessChanged.class, "mini-pvp")) .containsExactly("xcore:cmd:discord-admin-access:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationBanCreatedEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationBanCreatedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:ban"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationMuteCreatedEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationMuteCreatedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:mute"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationVoteKickCreatedEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationVoteKickCreatedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:votekick"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationAuditAppendedProtocolEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationAuditAppendedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:audit"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationKickBannedCommandEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationKickBannedCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:kick-banned:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationPardonCommandEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationPardonCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:pardon-player:mini-pvp"); } @@ -174,13 +178,13 @@ void subscribeStreamsForTypes() { @DisplayName("type classification and rpc response mapping are correct") void classificationAndResponseMapping() { assertThat(router.isReadOnlyType(TransportEvents.DiscordLinkStatusChangedEvent.class)).isTrue(); - assertThat(router.isReadOnlyType(TransportEvents.ModerationBanCreatedEvent.class)).isTrue(); - assertThat(router.isReadOnlyType(TransportEvents.ModerationMuteCreatedEvent.class)).isTrue(); - assertThat(router.isReadOnlyType(TransportEvents.ModerationVoteKickCreatedEvent.class)).isTrue(); - assertThat(router.isReadOnlyType(TransportEvents.ModerationAuditAppendedProtocolEvent.class)).isTrue(); + assertThat(router.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ModerationAuditAppendedV1.class)).isTrue(); assertThat(router.isReadOnlyType(TransportEvents.DiscordAdminAccessChanged.class)).isFalse(); - assertThat(router.isMutatingType(TransportEvents.ModerationKickBannedCommandEvent.class)).isTrue(); - assertThat(router.isMutatingType(TransportEvents.ModerationPardonCommandEvent.class)).isTrue(); + assertThat(router.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerPasswordReset.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerBadgeSymbolColorModeChanged.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 0873f83..ce06d3d 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -2,6 +2,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; import org.xcore.plugin.event.TransportEvents; import java.util.List; @@ -137,12 +140,12 @@ void rpcRequestAndResponseEnvelopeFieldsStayStableAndDirectionSpecific() { void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Arrange RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(TransportEvents.GlobalChatEvent.class); - RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(TransportEvents.ModerationBanCreatedEvent.class); - RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(TransportEvents.ModerationMuteCreatedEvent.class); + RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); + RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(TransportEvents.MapsListRequest.class); - RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(TransportEvents.ModerationKickBannedCommandEvent.class); + RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(ModerationKickBannedCommandV1.class); // Act RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; From 3e339d4f10ea439d9f553bfddc762c46fa8439f1 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:52:22 +0300 Subject: [PATCH 05/26] refactor(event): remove dead moderation wrappers Drop unused moderation wrapper records so the transport layer matches the direct DTO moderation flow. This reduces leftover legacy surface without changing runtime behavior. --- .../xcore/plugin/event/TransportEvents.java | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 21a1b9e..465c204 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -2,13 +2,6 @@ import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; - import java.time.Instant; import java.util.List; import java.util.Set; @@ -156,48 +149,6 @@ public record ModerationAuditAppendedEvent( Instant occurredAt ) implements Event, ServerScopedEvent {} - public record ModerationBanCreatedEvent(ModerationBanCreatedV1 payload) implements Event, ServerScopedEvent { - @Override - public String server() { - return payload == null ? null : payload.server(); - } - } - - public record ModerationMuteCreatedEvent(ModerationMuteCreatedV1 payload) implements Event, ServerScopedEvent { - @Override - public String server() { - return payload == null ? null : payload.server(); - } - } - - public record ModerationVoteKickCreatedEvent(ModerationVoteKickCreatedV1 payload) implements Event, ServerScopedEvent { - @Override - public String server() { - return payload == null ? null : payload.server(); - } - } - - public record ModerationAuditAppendedProtocolEvent(ModerationAuditAppendedV1 payload) implements Event, ServerScopedEvent { - @Override - public String server() { - return payload == null ? null : payload.server(); - } - } - - public record ModerationKickBannedCommandEvent(ModerationKickBannedCommandV1 payload) implements Event, ServerScopedEvent { - @Override - public String server() { - return payload == null ? null : payload.server(); - } - } - - public record ModerationPardonCommandEvent(ModerationPardonCommandV1 payload) implements Event, ServerScopedEvent { - @Override - public String server() { - return payload == null ? null : payload.server(); - } - } - public static class ReloadPlayerDataCache {} public record LoadMapsV2(FileURL[] urls, String server) implements ServerScopedEvent {} From e819e984e763384086f3f47da291ba7f03f977ee Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:33:35 +0300 Subject: [PATCH 06/26] feat(network): migrate discord transport DTOs Switch the Discord link and admin transport flows to released xcore-protocol DTOs and canonical routes so XCore-plugin no longer depends on local wrapper events for this family. --- gradle/libs.versions.toml | 2 +- .../xcore/plugin/event/TransportEvents.java | 53 -------- .../DiscordLinkTransportHandler.java | 28 +++-- .../transport/ModerationTransportHandler.java | 15 ++- .../plugin/service/DiscordLinkService.java | 17 +-- .../network/DiscordProtocolMapper.java | 119 ++++++++++++++++++ .../service/network/RedisRouteRegistry.java | 38 +++++- .../network/RedisTransportTopology.java | 15 ++- .../DiscordLinkTransportHandlerTest.java | 32 +++-- .../ModerationTransportHandlerTest.java | 39 ++++-- .../service/DiscordLinkServiceTest.java | 16 +-- .../network/RedisRouteRegistryTest.java | 39 +++++- .../network/RedisStreamRouterTest.java | 111 +++++++++++++--- .../network/RedisTransportContractsTest.java | 33 +++++ 14 files changed, 426 insertions(+), 131 deletions(-) create mode 100644 src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 590e0f8..f253cc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] mindustry = "157" -xcore-protocol = "0.1.0-SNAPSHOT" +xcore-protocol = "0.1.0" # Plugins toxopid = "4.1.2" diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 465c204..40e2cfd 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -58,59 +58,6 @@ public record PlayerBadgeInventoryChanged(String uuid, String activeBadge, Set { + network.subscribe(DiscordLinkConfirmCommandV1.class, e -> { + Integer playerPid = e.player().playerPid(); + if (playerPid == null) { + return; + } + var result = discordLinkService.confirmLink( e.code(), - e.playerUuid(), - e.playerPid(), - e.discordId(), - e.discordUsername() + e.player().playerUuid(), + playerPid, + e.discord().discordId(), + e.discord().discordUsername() ); if (!result.success()) { return; } - var session = sessionService.get(e.playerUuid()); + var session = sessionService.get(e.player().playerUuid()); if (session != null) { session.locale().send("commands-discord-link-confirmed", args( - "discordUsername", e.discordUsername() + "discordUsername", e.discord().discordUsername() )); } }); - network.subscribe(TransportEvents.DiscordUnlinkEvent.class, e -> { - var session = sessionService.get(e.playerUuid()); - if (discordLinkService.unlink(e.playerUuid()) && session != null) { + network.subscribe(DiscordUnlinkCommandV1.class, e -> { + String playerUuid = e.player().playerUuid(); + var session = sessionService.get(playerUuid); + if (discordLinkService.unlink(playerUuid) && session != null) { session.locale().send("commands-discord-unlink-success", args()); } }); diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index f93154c..d8f2b5b 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -15,6 +15,7 @@ import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; @@ -56,16 +57,20 @@ public void registerListeners() { p -> p.kick(Packets.KickReason.banned) )); - network.subscribe(TransportEvents.DiscordAdminAccessChanged.class, e -> { + network.subscribe(DiscordAdminAccessChangedCommandV1.class, e -> { if (e.admin()) { - if (discordAdminAccessService.applyDiscordAdminAccess(e.playerUuid(), e.discordId(), e.discordUsername())) { - info("Granted discord admin access: @", e.playerUuid()); + if (discordAdminAccessService.applyDiscordAdminAccess( + e.player().playerUuid(), + e.discord().discordId(), + e.discord().discordUsername() + )) { + info("Granted discord admin access: @", e.player().playerUuid()); } return; } - if (discordAdminAccessService.revokeDiscordAdminAccess(e.playerUuid())) { - info("Revoked discord admin access: @", e.playerUuid()); + if (discordAdminAccessService.revokeDiscordAdminAccess(e.player().playerUuid())) { + info("Revoked discord admin access: @", e.player().playerUuid()); } }); diff --git a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java index 7e3f51e..c2d6200 100644 --- a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java +++ b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java @@ -6,6 +6,7 @@ import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.service.network.DiscordProtocolMapper; import org.xcore.plugin.service.network.RedisDiscordLinkCodeStore; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; @@ -73,7 +74,7 @@ public LinkCodeResult createCode(Session session) { return LinkCodeResult.error("save-failed"); } - networkService.post(new TransportEvents.DiscordLinkCodeCreatedEvent( + networkService.post(DiscordProtocolMapper.toLinkCodeCreated( code, data.uuid, data.pid, @@ -135,7 +136,7 @@ public boolean unlink(Session session) { if (!revoked) { return false; } - publishAdminAccessChanged(data.uuid, data.pid, discordId, discordUsername, false, DiscordAdminAccessService.SOURCE_NONE, "plugin/unlink", "discord unlink"); + publishAdminAccessChanged(data.uuid, data.pid, data.nickname, discordId, discordUsername, false, DiscordAdminAccessService.SOURCE_NONE, "plugin/unlink", "discord unlink"); publishStatusChanged(data, discordId, "", "unlinked", System.currentTimeMillis()); return true; } @@ -166,7 +167,7 @@ public boolean unlink(String playerUuid) { if (!revoked) { return false; } - publishAdminAccessChanged(playerUuid, data.pid, discordId, discordUsername, false, DiscordAdminAccessService.SOURCE_NONE, "plugin/unlink", "discord unlink"); + publishAdminAccessChanged(playerUuid, data.pid, data.nickname, discordId, discordUsername, false, DiscordAdminAccessService.SOURCE_NONE, "plugin/unlink", "discord unlink"); publishStatusChanged(data, discordId, "", "unlinked", System.currentTimeMillis()); return true; @@ -244,10 +245,8 @@ private void publishStatusChanged(PlayerData data, String discordUsername, String status, long timestamp) { - networkService.post(new TransportEvents.DiscordLinkStatusChangedEvent( - data.uuid, - data.pid, - data.nickname, + networkService.post(DiscordProtocolMapper.toLinkStatusChanged( + data, discordId, discordUsername, status, @@ -258,15 +257,17 @@ private void publishStatusChanged(PlayerData data, private void publishAdminAccessChanged(String playerUuid, int playerPid, + String playerName, String discordId, String discordUsername, boolean admin, String adminSource, String requestedBy, String reason) { - networkService.post(new TransportEvents.DiscordAdminAccessChanged( + networkService.post(DiscordProtocolMapper.toAdminAccessChangedCommand( playerUuid, playerPid, + playerName, discordId, discordUsername, admin, diff --git a/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java new file mode 100644 index 0000000..cca5699 --- /dev/null +++ b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java @@ -0,0 +1,119 @@ +package org.xcore.plugin.service.network; + +import org.xcore.plugin.model.PlayerData; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; + +import java.time.Instant; +import java.util.Objects; + +public final class DiscordProtocolMapper { + private DiscordProtocolMapper() { + } + + public static DiscordLinkCodeCreatedV1 toLinkCodeCreated( + String code, + String playerUuid, + int playerPid, + String playerName, + String server, + long createdAt, + long expiresAt + ) { + return new DiscordLinkCodeCreatedV1( + requireNonBlank(code, "code"), + toPlayerRef(playerUuid, playerPid, playerName), + requireNonBlank(server, "server"), + toOccurredAt(createdAt), + toOccurredAt(expiresAt) + ); + } + + public static DiscordLinkStatusChangedV1 toLinkStatusChanged( + PlayerData playerData, + String discordId, + String discordUsername, + String action, + String server, + long occurredAt + ) { + Objects.requireNonNull(playerData, "playerData must not be null"); + + return new DiscordLinkStatusChangedV1( + toPlayerRef(playerData.uuid, playerData.pid, playerData.nickname), + toDiscordIdentity(discordId, discordUsername), + requireNonBlank(action, "action"), + requireNonBlank(server, "server"), + toOccurredAt(occurredAt) + ); + } + + public static DiscordAdminAccessChangedCommandV1 toAdminAccessChangedCommand( + String playerUuid, + int playerPid, + String playerName, + String discordId, + String discordUsername, + boolean admin, + String adminSource, + String requestedBy, + String reason, + String server, + long occurredAt + ) { + return new DiscordAdminAccessChangedCommandV1( + toPlayerRef(playerUuid, playerPid, playerName), + toDiscordIdentity(discordId, discordUsername), + admin, + requireNonBlank(adminSource, "adminSource"), + requireNonBlank(requestedBy, "requestedBy"), + requireNonBlank(reason, "reason"), + requireNonBlank(server, "server"), + toOccurredAt(occurredAt) + ); + } + + private static PlayerRefV1 toPlayerRef(String playerUuid, Integer playerPid, String playerName) { + return new PlayerRefV1( + requireNonBlank(playerUuid, "playerUuid"), + normalizeOptionalPid(playerPid), + requirePlayerName(playerName), + null + ); + } + + private static DiscordIdentityRefV1 toDiscordIdentity(String discordId, String discordUsername) { + return new DiscordIdentityRefV1( + requireNonBlank(discordId, "discordId"), + normalizeOptional(discordUsername) + ); + } + + private static String requirePlayerName(String playerName) { + String normalized = normalizeOptional(playerName); + return normalized == null ? "Unknown" : normalized; + } + + private static String requireNonBlank(String value, String fieldName) { + String normalized = normalizeOptional(value); + if (normalized == null) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return normalized; + } + + private static Integer normalizeOptionalPid(Integer pid) { + return pid == null || pid < 0 ? null : pid; + } + + private static String normalizeOptional(String value) { + return value == null || value.isBlank() ? null : value; + } + + private static String toOccurredAt(long epochMillis) { + return Instant.ofEpochMilli(epochMillis).toString(); + } +} diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index eef0db2..71e9084 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,6 +1,11 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -25,6 +30,10 @@ public final class RedisRouteRegistry { if (moderationServer != null && !moderationServer.isBlank()) { return moderationServer; } + String discordServer = discordServer(payload); + if (discordServer != null && !discordServer.isBlank()) { + return discordServer; + } if (payload instanceof TransportEvents.ServerScopedEvent serverScopedEvent) { String server = serverScopedEvent.server(); if (server != null && !server.isBlank()) { @@ -133,11 +142,11 @@ private void registerDefaults() { register(mutating(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerBadgeInventoryChanged.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge_inventory", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerPasswordReset.class, "xcore:cmd:player-password-reset:{server}", "player.password_reset", 120_000L, RedisServerResolver.defaultServer())); - register(readOnly(TransportEvents.DiscordLinkCodeCreatedEvent.class, "xcore:evt:discord:link-code", "discord.link_code_created", 120_000L, RedisServerResolver.broadcast())); - register(mutating(TransportEvents.DiscordLinkConfirmEvent.class, "xcore:cmd:discord-link-confirm:{server}", "discord.link_confirm", 120_000L, PAYLOAD_SERVER_RESOLVER)); - register(mutating(TransportEvents.DiscordUnlinkEvent.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink", 120_000L, PAYLOAD_SERVER_RESOLVER)); - register(readOnly(TransportEvents.DiscordLinkStatusChangedEvent.class, "xcore:evt:discord:link-status", "discord.link_status_changed", 120_000L, RedisServerResolver.broadcast())); - register(mutating(TransportEvents.DiscordAdminAccessChanged.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin_access_changed", 120_000L, PAYLOAD_SERVER_RESOLVER)); + register(readOnly(DiscordLinkCodeCreatedV1.class, "xcore:evt:discord:link-code", "discord.link-code-created", 120_000L, RedisServerResolver.broadcast())); + register(mutating(DiscordLinkConfirmCommandV1.class, "xcore:cmd:discord-link-confirm:{server}", "discord.link.confirm.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); + register(mutating(DiscordUnlinkCommandV1.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); + register(readOnly(DiscordLinkStatusChangedV1.class, "xcore:evt:discord:link-status", "discord.link.status-changed", 120_000L, RedisServerResolver.broadcast())); + register(mutating(DiscordAdminAccessChangedCommandV1.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin-access.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, RedisServerResolver.broadcast())); @@ -168,6 +177,25 @@ private static String moderationServer(Object payload) { return null; } + private static String discordServer(Object payload) { + if (payload instanceof DiscordLinkCodeCreatedV1 event) { + return event.server(); + } + if (payload instanceof DiscordLinkConfirmCommandV1 command) { + return command.server(); + } + if (payload instanceof DiscordLinkStatusChangedV1 event) { + return event.server(); + } + if (payload instanceof DiscordUnlinkCommandV1 command) { + return command.server(); + } + if (payload instanceof DiscordAdminAccessChangedCommandV1 command) { + return command.server(); + } + return null; + } + private void register(RedisRouteDescriptor descriptor) { descriptorsByType.put(descriptor.payloadType(), descriptor); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 5b16c07..3855a75 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -1,6 +1,11 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -60,11 +65,11 @@ public record RouteSpec( route(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerBadgeInventoryChanged.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge_inventory", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerPasswordReset.class, "xcore:cmd:player-password-reset:{server}", "player.password_reset", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), - route(TransportEvents.DiscordLinkCodeCreatedEvent.class, "xcore:evt:discord:link-code", "discord.link_code_created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.DiscordLinkConfirmEvent.class, "xcore:cmd:discord-link-confirm:{server}", "discord.link_confirm", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - route(TransportEvents.DiscordUnlinkEvent.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - route(TransportEvents.DiscordLinkStatusChangedEvent.class, "xcore:evt:discord:link-status", "discord.link_status_changed", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.DiscordAdminAccessChanged.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin_access_changed", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(DiscordLinkCodeCreatedV1.class, "xcore:evt:discord:link-code", "discord.link-code-created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(DiscordLinkConfirmCommandV1.class, "xcore:cmd:discord-link-confirm:{server}", "discord.link.confirm.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(DiscordUnlinkCommandV1.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(DiscordLinkStatusChangedV1.class, "xcore:evt:discord:link-status", "discord.link.status-changed", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(DiscordAdminAccessChangedCommandV1.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin-access.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), diff --git a/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java index 3790f9b..d52fcc1 100644 --- a/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java @@ -11,6 +11,10 @@ import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; import java.util.HashMap; import java.util.Map; @@ -25,8 +29,8 @@ class DiscordLinkTransportHandlerTest { @Test - @DisplayName("discord link confirm event confirms link and notifies online player") - void discordLinkConfirmEvent_confirmsLinkAndNotifiesOnlinePlayer() { + @DisplayName("discord link confirm command confirms link and notifies online player") + void discordLinkConfirmCommand_confirmsLinkAndNotifiesOnlinePlayer() { NetworkService network = mock(NetworkService.class); DiscordLinkService discordLinkService = mock(DiscordLinkService.class); SessionService sessionService = mock(SessionService.class); @@ -51,15 +55,21 @@ void discordLinkConfirmEvent_confirmsLinkAndNotifiesOnlinePlayer() { handler.registerListeners(); - listener(listeners, TransportEvents.DiscordLinkConfirmEvent.class) - .get(new TransportEvents.DiscordLinkConfirmEvent("ABC123", "uuid-7", 7, "123", "discord-user", "mini-pvp", 1L)); + listener(listeners, DiscordLinkConfirmCommandV1.class) + .get(new DiscordLinkConfirmCommandV1( + "ABC123", + new PlayerRefV1("uuid-7", 7, "Target", null), + new DiscordIdentityRefV1("123", "discord-user"), + "mini-pvp", + "2026-04-28T00:00:01Z" + )); verify(localization).send(any(), any()); } @Test - @DisplayName("discord unlink event updates offline player data without online session") - void discordUnlinkEvent_updatesOfflinePlayerDataWithoutOnlineSession() { + @DisplayName("discord unlink command updates offline player data without online session") + void discordUnlinkCommand_updatesOfflinePlayerDataWithoutOnlineSession() { NetworkService network = mock(NetworkService.class); DiscordLinkService discordLinkService = mock(DiscordLinkService.class); SessionService sessionService = mock(SessionService.class); @@ -78,8 +88,14 @@ void discordUnlinkEvent_updatesOfflinePlayerDataWithoutOnlineSession() { handler.registerListeners(); - listener(listeners, TransportEvents.DiscordUnlinkEvent.class) - .get(new TransportEvents.DiscordUnlinkEvent("uuid-7", 7, "123", "discord", "mini-other", 1L)); + listener(listeners, DiscordUnlinkCommandV1.class) + .get(new DiscordUnlinkCommandV1( + new PlayerRefV1("uuid-7", 7, "Target", null), + new DiscordIdentityRefV1("123", "discord"), + "discord", + "mini-other", + "2026-04-28T00:00:01Z" + )); verify(discordLinkService).unlink("uuid-7"); } diff --git a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java index de60056..95f3732 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -15,6 +15,9 @@ import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.service.network.RedisNetworkBackend; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; import java.util.HashMap; import java.util.Map; @@ -43,8 +46,8 @@ void tearDown() { } @Test - @DisplayName("discord admin access event applies persisted admin flags") - void discordAdminAccessEvent_appliesPersistedAdminFlags() { + @DisplayName("discord admin access command applies persisted admin flags") + void discordAdminAccessCommand_appliesPersistedAdminFlags() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); @@ -62,18 +65,24 @@ void discordAdminAccessEvent_appliesPersistedAdminFlags() { handler.registerListeners(); - listener(listeners, TransportEvents.DiscordAdminAccessChanged.class) - .get(new TransportEvents.DiscordAdminAccessChanged( - "uuid-1", 7, "123", "discord-user", true, - DiscordAdminAccessService.SOURCE_DISCORD_ROLE, "tester", "sync", "mini-pvp", 10L + listener(listeners, DiscordAdminAccessChangedCommandV1.class) + .get(new DiscordAdminAccessChangedCommandV1( + new PlayerRefV1("uuid-1", 7, "Player", null), + new DiscordIdentityRefV1("123", "discord-user"), + true, + DiscordAdminAccessService.SOURCE_DISCORD_ROLE, + "tester", + "sync", + "mini-pvp", + "2026-04-28T00:00:10Z" )); verify(discordAdminAccessService).applyDiscordAdminAccess("uuid-1", "123", "discord-user"); } @Test - @DisplayName("discord admin revoke event clears persisted admin flags") - void discordAdminRevokeEvent_clearsPersistedAdminFlags() { + @DisplayName("discord admin revoke command clears persisted admin flags") + void discordAdminRevokeCommand_clearsPersistedAdminFlags() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); @@ -91,10 +100,16 @@ void discordAdminRevokeEvent_clearsPersistedAdminFlags() { handler.registerListeners(); - listener(listeners, TransportEvents.DiscordAdminAccessChanged.class) - .get(new TransportEvents.DiscordAdminAccessChanged( - "uuid-1", 7, "123", "discord-user", false, - DiscordAdminAccessService.SOURCE_DISCORD_ROLE, "tester", "sync", "mini-pvp", 11L + listener(listeners, DiscordAdminAccessChangedCommandV1.class) + .get(new DiscordAdminAccessChangedCommandV1( + new PlayerRefV1("uuid-1", 7, "Player", null), + new DiscordIdentityRefV1("123", "discord-user"), + false, + DiscordAdminAccessService.SOURCE_DISCORD_ROLE, + "tester", + "sync", + "mini-pvp", + "2026-04-28T00:00:11Z" )); verify(discordAdminAccessService).revokeDiscordAdminAccess("uuid-1"); diff --git a/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java b/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java index fbdd843..fe523df 100644 --- a/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java @@ -4,11 +4,13 @@ import org.junit.jupiter.api.Test; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.PlayerDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.network.RedisDiscordLinkCodeStore; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -46,7 +48,7 @@ void createCode_invalidatesOldCodesAndPublishesCreationEvent() { assertThat(result.code()).hasSize(6); verify(codeStore).invalidatePendingByPlayerUuid("uuid-7"); verify(codeStore).store(any()); - verify(networkService).post(any(TransportEvents.DiscordLinkCodeCreatedEvent.class)); + verify(networkService).post(any(DiscordLinkCodeCreatedV1.class)); } @Test @@ -128,7 +130,7 @@ void getOrCreateActiveCode_returnsExistingActiveCodeWithoutCreatingNewOne() { assertThat(result.success()).isTrue(); assertThat(result.code()).isEqualTo("ABC123"); verify(codeStore, never()).store(any()); - verify(networkService, never()).post(any(TransportEvents.DiscordLinkCodeCreatedEvent.class)); + verify(networkService, never()).post(any(DiscordLinkCodeCreatedV1.class)); } @Test @@ -197,7 +199,7 @@ void confirmLink_allowsSameDiscordAccountAcrossPlayers() { assertThat(result.success()).isTrue(); assertThat(playerData.discordId).isEqualTo("123"); assertThat(playerData.discordUsername).isEqualTo("discord-user"); - verify(networkService).post(any(TransportEvents.DiscordLinkStatusChangedEvent.class)); + verify(networkService).post(any(DiscordLinkStatusChangedV1.class)); } @Test @@ -262,8 +264,8 @@ void unlinkByUuid_updatesOfflinePlayerData() { var result = service.unlink("uuid-7"); assertThat(result).isTrue(); - verify(networkService).post(any(TransportEvents.DiscordLinkStatusChangedEvent.class)); - verify(networkService).post(any(TransportEvents.DiscordAdminAccessChanged.class)); + verify(networkService).post(any(DiscordLinkStatusChangedV1.class)); + verify(networkService).post(any(DiscordAdminAccessChangedCommandV1.class)); verify(discordAdminAccessService).revokeDiscordAdminAccess("uuid-7"); } @@ -310,7 +312,7 @@ void unlinkByUuid_clearsOnlineSessionDiscordState() { assertThat(session.data.discordId).isBlank(); assertThat(session.data.discordUsername).isBlank(); assertThat(session.data.discordLinkedAt).isZero(); - verify(networkService).post(any(TransportEvents.DiscordAdminAccessChanged.class)); + verify(networkService).post(any(DiscordAdminAccessChangedCommandV1.class)); verify(discordAdminAccessService).revokeDiscordAdminAccess("uuid-7"); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index d2bf02d..9b38bb6 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,12 +2,18 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; +import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.plugin.event.TransportEvents; import static org.assertj.core.api.Assertions.assertThat; @@ -19,17 +25,43 @@ class RedisRouteRegistryTest { @Test @DisplayName("payload server resolver uses typed server contract") void payloadServerResolverUsesTypedContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(TransportEvents.DiscordLinkConfirmEvent.class); + RedisRouteDescriptor descriptor = registry.routeDescriptorFor(DiscordLinkConfirmCommandV1.class); String stream = registry.resolveStreamKey( descriptor, - new TransportEvents.DiscordLinkConfirmEvent("code", "uuid", 1, "discord", "user", "survival", 123L), + new DiscordLinkConfirmCommandV1( + "code", + new PlayerRefV1("uuid", 1, "Player", null), + new DiscordIdentityRefV1("discord", "user"), + "survival", + "2026-04-28T00:00:00Z" + ), "mini-pvp" ); assertThat(stream).isEqualTo("xcore:cmd:discord-link-confirm:survival"); } + @Test + @DisplayName("unlink command uses typed payload server contract") + void unlinkCommandUsesTypedPayloadServerContract() { + RedisRouteDescriptor descriptor = registry.routeDescriptorFor(DiscordUnlinkCommandV1.class); + + String stream = registry.resolveStreamKey( + descriptor, + new DiscordUnlinkCommandV1( + new PlayerRefV1("uuid", 1, "Player", null), + new DiscordIdentityRefV1("discord", "user"), + "moderator", + "survival", + "2026-04-28T00:00:00Z" + ), + "mini-pvp" + ); + + assertThat(stream).isEqualTo("xcore:cmd:discord-unlink:survival"); + } + @Test @DisplayName("default server resolver keeps server-local mutating events on current server") void defaultServerResolverUsesDefaultServer() { @@ -59,6 +91,9 @@ void rpcRouteDescriptorCarriesResponseType() { @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(TransportEvents.GlobalChatEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); + assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); + assertThat(registry.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 9801046..2199cbd 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -2,6 +2,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -10,6 +15,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.BanData; @@ -77,6 +83,16 @@ void routeReadOnlyEvents() { ), "mini-pvp" ); + var discordLinkCodeRoute = router.route( + new DiscordLinkCodeCreatedV1( + "ABC123", + new PlayerRefV1("uuid-7", 7, "Target", null), + "mini-pvp", + Instant.parse("2026-04-26T00:00:04Z").toString(), + Instant.parse("2026-04-26T00:10:04Z").toString() + ), + "mini-pvp" + ); assertThat(messageRoute.streamKey()).isEqualTo("xcore:evt:chat:message"); assertThat(messageRoute.eventType()).isEqualTo("chat.message"); @@ -95,6 +111,9 @@ void routeReadOnlyEvents() { assertThat(auditRoute.streamKey()).isEqualTo("xcore:evt:moderation:audit"); assertThat(auditRoute.eventType()).isEqualTo("moderation.audit.appended"); + + assertThat(discordLinkCodeRoute.streamKey()).isEqualTo("xcore:evt:discord:link-code"); + assertThat(discordLinkCodeRoute.eventType()).isEqualTo("discord.link-code-created"); } @Test @@ -105,9 +124,49 @@ void routeServerTargetedEvents() { var badgeRoute = router.route(new TransportEvents.PlayerBadgeInventoryChanged("uuid-7", "translator", java.util.Set.of("translator")), "mini-pvp"); var badgeColorModeRoute = router.route(new TransportEvents.PlayerBadgeSymbolColorModeChanged("uuid-7", "player-color"), "mini-pvp"); var passwordRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); - var discordLinkConfirmRoute = router.route(new TransportEvents.DiscordLinkConfirmEvent("ABC123", "uuid-7", 7, "123", "discord-user", "mini-hexed", 10L), "mini-pvp"); - var discordLinkStatusRoute = router.route(new TransportEvents.DiscordLinkStatusChangedEvent("uuid-7", 7, "Nick", "123", "discord-user", "linked", "mini-pvp", 10L), "mini-pvp"); - var discordAdminAccessRoute = router.route(new TransportEvents.DiscordAdminAccessChanged("uuid-7", 7, "123", "discord-user", true, "DISCORD_ROLE", "tester", "sync", "mini-pvp", 11L), "mini-pvp"); + var discordLinkConfirmRoute = router.route( + new DiscordLinkConfirmCommandV1( + "ABC123", + new PlayerRefV1("uuid-7", 7, "Nick", null), + new DiscordIdentityRefV1("123", "discord-user"), + "mini-hexed", + Instant.parse("2026-04-26T00:00:10Z").toString() + ), + "mini-pvp" + ); + var discordLinkStatusRoute = router.route( + new DiscordLinkStatusChangedV1( + new PlayerRefV1("uuid-7", 7, "Nick", null), + new DiscordIdentityRefV1("123", "discord-user"), + "linked", + "mini-pvp", + Instant.parse("2026-04-26T00:00:10Z").toString() + ), + "mini-pvp" + ); + var discordAdminAccessRoute = router.route( + new DiscordAdminAccessChangedCommandV1( + new PlayerRefV1("uuid-7", 7, "Nick", null), + new DiscordIdentityRefV1("123", "discord-user"), + true, + "DISCORD_ROLE", + "tester", + "sync", + "mini-pvp", + Instant.parse("2026-04-26T00:00:11Z").toString() + ), + "mini-pvp" + ); + var discordUnlinkRoute = router.route( + new DiscordUnlinkCommandV1( + new PlayerRefV1("uuid-7", 7, "Nick", null), + new DiscordIdentityRefV1("123", "discord-user"), + "tester", + "mini-hexed", + Instant.parse("2026-04-26T00:00:13Z").toString() + ), + "mini-pvp" + ); assertThat(discordRoute.streamKey()).isEqualTo("xcore:cmd:discord-message:mini-hexed"); assertThat(mapsRoute.streamKey()).isEqualTo("xcore:cmd:maps-load:event"); @@ -118,13 +177,27 @@ void routeServerTargetedEvents() { assertThat(passwordRoute.streamKey()).isEqualTo("xcore:cmd:player-password-reset:mini-pvp"); assertThat(passwordRoute.eventType()).isEqualTo("player.password_reset"); assertThat(discordLinkConfirmRoute.streamKey()).isEqualTo("xcore:cmd:discord-link-confirm:mini-hexed"); - assertThat(discordLinkConfirmRoute.eventType()).isEqualTo("discord.link_confirm"); + assertThat(discordLinkConfirmRoute.eventType()).isEqualTo("discord.link.confirm.command"); assertThat(discordLinkStatusRoute.streamKey()).isEqualTo("xcore:evt:discord:link-status"); - assertThat(discordLinkStatusRoute.eventType()).isEqualTo("discord.link_status_changed"); + assertThat(discordLinkStatusRoute.eventType()).isEqualTo("discord.link.status-changed"); assertThat(discordAdminAccessRoute.streamKey()).isEqualTo("xcore:cmd:discord-admin-access:mini-pvp"); - assertThat(discordAdminAccessRoute.eventType()).isEqualTo("discord.admin_access_changed"); - - var discordAdminAccessOtherServerRoute = router.route(new TransportEvents.DiscordAdminAccessChanged("uuid-8", 8, "456", "other-user", false, "NONE", "tester", "sync", "survival", 12L), "mini-pvp"); + assertThat(discordAdminAccessRoute.eventType()).isEqualTo("discord.admin-access.changed.command"); + assertThat(discordUnlinkRoute.streamKey()).isEqualTo("xcore:cmd:discord-unlink:mini-hexed"); + assertThat(discordUnlinkRoute.eventType()).isEqualTo("discord.unlink.command"); + + var discordAdminAccessOtherServerRoute = router.route( + new DiscordAdminAccessChangedCommandV1( + new PlayerRefV1("uuid-8", 8, "Other", null), + new DiscordIdentityRefV1("456", "other-user"), + false, + "NONE", + "tester", + "sync", + "survival", + Instant.parse("2026-04-26T00:00:12Z").toString() + ), + "mini-pvp" + ); assertThat(discordAdminAccessOtherServerRoute.streamKey()).isEqualTo("xcore:cmd:discord-admin-access:survival"); } @@ -146,15 +219,21 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "mini-pvp")) .containsExactly("xcore:cmd:player-badge-symbol-color-mode:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.DiscordLinkConfirmEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(DiscordLinkConfirmCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:discord-link-confirm:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.DiscordLinkStatusChangedEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(DiscordLinkCodeCreatedV1.class, "mini-pvp")) + .containsExactly("xcore:evt:discord:link-code"); + + assertThat(router.subscribeStreamsFor(DiscordLinkStatusChangedV1.class, "mini-pvp")) .containsExactly("xcore:evt:discord:link-status"); - assertThat(router.subscribeStreamsFor(TransportEvents.DiscordAdminAccessChanged.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(DiscordAdminAccessChangedCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:discord-admin-access:mini-pvp"); + assertThat(router.subscribeStreamsFor(DiscordUnlinkCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:discord-unlink:mini-pvp"); + assertThat(router.subscribeStreamsFor(ModerationBanCreatedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:ban"); @@ -177,19 +256,21 @@ void subscribeStreamsForTypes() { @Test @DisplayName("type classification and rpc response mapping are correct") void classificationAndResponseMapping() { - assertThat(router.isReadOnlyType(TransportEvents.DiscordLinkStatusChangedEvent.class)).isTrue(); + assertThat(router.isReadOnlyType(DiscordLinkCodeCreatedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationAuditAppendedV1.class)).isTrue(); - assertThat(router.isReadOnlyType(TransportEvents.DiscordAdminAccessChanged.class)).isFalse(); + assertThat(router.isReadOnlyType(DiscordAdminAccessChangedCommandV1.class)).isFalse(); assertThat(router.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerPasswordReset.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerBadgeSymbolColorModeChanged.class)).isTrue(); - assertThat(router.isMutatingType(TransportEvents.DiscordLinkConfirmEvent.class)).isTrue(); - assertThat(router.isMutatingType(TransportEvents.DiscordAdminAccessChanged.class)).isTrue(); + assertThat(router.isMutatingType(DiscordLinkConfirmCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.MessageEvent.class)).isFalse(); assertThat(router.isRpcRequestType(TransportEvents.MapsListRequest.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index ce06d3d..ed3e778 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -2,6 +2,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; @@ -142,7 +145,10 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(TransportEvents.GlobalChatEvent.class); RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); + RedisTransportTopology.RouteSpec discordLinkCodeRoute = RedisTransportTopology.routeFor(DiscordLinkCodeCreatedV1.class); + RedisTransportTopology.RouteSpec discordStatusRoute = RedisTransportTopology.routeFor(DiscordLinkStatusChangedV1.class); RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); + RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(TransportEvents.MapsListRequest.class); RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(ModerationKickBannedCommandV1.class); @@ -151,7 +157,10 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; + RedisTransportTopology.RouteSpec stableDiscordLinkCodeRoute = discordLinkCodeRoute; + RedisTransportTopology.RouteSpec stableDiscordStatusRoute = discordStatusRoute; RedisTransportTopology.RouteSpec stableCommandRoute = commandRoute; + RedisTransportTopology.RouteSpec stableDiscordAdminCommandRoute = discordAdminCommandRoute; RedisTransportTopology.RouteSpec stableBroadcastCommandRoute = broadcastCommandRoute; RedisTransportTopology.RouteSpec stableRpcRoute = rpcRoute; RedisTransportTopology.RouteSpec stableKickBannedRoute = kickBannedRoute; @@ -177,6 +186,22 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableMuteRoute.readOnly()).isTrue(); assertThat(stableMuteRoute.rpcRequest()).isFalse(); + assertThat(stableDiscordLinkCodeRoute).isNotNull(); + assertThat(stableDiscordLinkCodeRoute.streamPattern()).isEqualTo("xcore:evt:discord:link-code"); + assertThat(stableDiscordLinkCodeRoute.eventType()).isEqualTo("discord.link-code-created"); + assertThat(stableDiscordLinkCodeRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stableDiscordLinkCodeRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stableDiscordLinkCodeRoute.readOnly()).isTrue(); + assertThat(stableDiscordLinkCodeRoute.rpcRequest()).isFalse(); + + assertThat(stableDiscordStatusRoute).isNotNull(); + assertThat(stableDiscordStatusRoute.streamPattern()).isEqualTo("xcore:evt:discord:link-status"); + assertThat(stableDiscordStatusRoute.eventType()).isEqualTo("discord.link.status-changed"); + assertThat(stableDiscordStatusRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stableDiscordStatusRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stableDiscordStatusRoute.readOnly()).isTrue(); + assertThat(stableDiscordStatusRoute.rpcRequest()).isFalse(); + assertThat(stableCommandRoute).isNotNull(); assertThat(stableCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-password-reset:{server}"); assertThat(stableCommandRoute.eventType()).isEqualTo("player.password_reset"); @@ -185,6 +210,14 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableCommandRoute.readOnly()).isFalse(); assertThat(stableCommandRoute.rpcRequest()).isFalse(); + assertThat(stableDiscordAdminCommandRoute).isNotNull(); + assertThat(stableDiscordAdminCommandRoute.streamPattern()).isEqualTo("xcore:cmd:discord-admin-access:{server}"); + assertThat(stableDiscordAdminCommandRoute.eventType()).isEqualTo("discord.admin-access.changed.command"); + assertThat(stableDiscordAdminCommandRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); + assertThat(stableDiscordAdminCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableDiscordAdminCommandRoute.readOnly()).isFalse(); + assertThat(stableDiscordAdminCommandRoute.rpcRequest()).isFalse(); + assertThat(stableBroadcastCommandRoute).isNotNull(); assertThat(stableBroadcastCommandRoute.streamPattern()).isEqualTo("xcore:cmd:execute-command:broadcast"); assertThat(stableBroadcastCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); From 61e5d38efbc0c8b7d45e1ca291e9cc0ccdd65fd2 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:28:46 +0300 Subject: [PATCH 07/26] feat(network): migrate maps list transport DTOs Generalize RPC request and response handling so generated protocol DTOs can participate in Redis roundtrips, then switch the maps.list flow to canonical request and response messages. This keeps the transport surface aligned with xcore-protocol while preserving existing maps.remove behavior for a later slice. --- .../event/transport/MapTransportHandler.java | 34 +++------- .../xcore/plugin/service/NetworkService.java | 6 +- .../service/network/MapsProtocolMapper.java | 47 ++++++++++++++ .../service/network/RedisNetworkBackend.java | 33 +++++----- .../service/network/RedisRouteDescriptor.java | 4 +- .../service/network/RedisRouteRegistry.java | 19 +++++- .../service/network/RedisRpcTracker.java | 23 +++---- .../service/network/RedisStreamRouter.java | 4 +- .../network/RedisTransportTopology.java | 8 ++- .../RedisNetworkBackendIntegrationTest.java | 63 ++++++++----------- .../network/RedisRouteRegistryTest.java | 8 ++- .../network/RedisStreamRouterTest.java | 14 +++-- .../network/RedisTransportContractsTest.java | 20 +++--- 13 files changed, 156 insertions(+), 127 deletions(-) create mode 100644 src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java diff --git a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java index ad52ba5..1b29374 100644 --- a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java @@ -5,13 +5,17 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import mindustry.maps.Map; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.shared.MapEntryV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.MapData; import org.xcore.plugin.service.MapService; import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.network.MapsProtocolMapper; +import java.util.ArrayList; import java.util.concurrent.atomic.AtomicInteger; import static mindustry.Vars.customMapDirectory; @@ -39,38 +43,20 @@ public MapTransportHandler(NetworkService network, } public void registerListeners() { - network.subscribe(TransportEvents.MapsListRequest.class, request -> { - if (!request.server.equals(config.server)) return; + network.subscribe(MapsListRequestV1.class, request -> { + if (!request.server().equals(config.server)) return; var customMaps = maps.customMaps(); String currentGameMode = state.rules.mode().name(); - var mapsList = new TransportEvents.MapEntry[customMaps.size]; + var mapsList = new ArrayList(customMaps.size); for (int i = 0; i < customMaps.size; i++) { Map map = customMaps.get(i); - String fileName = map.file == null ? "" : map.file.name(); - String rawAuthor = map.author(); - String author = rawAuthor == null ? "Unknown" : rawAuthor; - MapData persistedMap = mapDataRepository.find(map.plainName(), rawAuthor, currentGameMode) + MapData persistedMap = mapDataRepository.find(map.plainName(), map.author(), currentGameMode) .orElse(null); - TransportEvents.MapEntry entry = new TransportEvents.MapEntry(); - entry.name = map.plainName(); - entry.fileName = fileName; - entry.author = author; - entry.width = map.width; - entry.height = map.height; - entry.fileSizeBytes = map.file == null ? null : map.file.length(); - entry.like = persistedMap == null ? null : persistedMap.like; - entry.dislike = persistedMap == null ? null : persistedMap.dislike; - entry.reputation = persistedMap == null ? null : persistedMap.reputation; - entry.popularity = persistedMap == null ? null : persistedMap.popularity; - entry.interest = persistedMap == null ? null : persistedMap.interest; - entry.gameMode = persistedMap == null ? currentGameMode : persistedMap.gameMode; - mapsList[i] = entry; + mapsList.add(MapsProtocolMapper.toMapEntry(map, currentGameMode, persistedMap)); } - TransportEvents.MapsListResponse response = new TransportEvents.MapsListResponse(); - response.maps = mapsList; - network.respond(request, response); + network.respond(request, MapsProtocolMapper.toMapsListResponse(request.server(), mapsList)); }); network.subscribe(TransportEvents.MapRemoveRequest.class, request -> { diff --git a/src/main/java/org/xcore/plugin/service/NetworkService.java b/src/main/java/org/xcore/plugin/service/NetworkService.java index 6f4f4a3..5c7bf74 100644 --- a/src/main/java/org/xcore/plugin/service/NetworkService.java +++ b/src/main/java/org/xcore/plugin/service/NetworkService.java @@ -2,8 +2,6 @@ import arc.func.Cons; import arc.util.Log; -import org.xcore.plugin.event.TransportEvents.Request; -import org.xcore.plugin.event.TransportEvents.Response; import org.xcore.plugin.service.network.RedisNetworkBackend.Subscription; import org.xcore.plugin.service.network.RedisNetworkBackend.RequestSubscription; import io.avaje.inject.PostConstruct; @@ -68,11 +66,11 @@ public Subscription subscribe(Class type, Cons listener) { return backend.subscribe(type, listener); } - public RequestSubscription request(Request request, Cons listener, Runnable timeout) { + public RequestSubscription request(REQ request, Cons listener, Runnable timeout) { return backend.request(request, listener, timeout); } - public void respond(Request request, T response) { + public void respond(Object request, Object response) { backend.respond(request, response); } diff --git a/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java new file mode 100644 index 0000000..e823a2f --- /dev/null +++ b/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java @@ -0,0 +1,47 @@ +package org.xcore.plugin.service.network; + +import mindustry.maps.Map; +import org.xcore.plugin.model.MapData; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.shared.MapEntryV1; + +import java.util.List; + +public final class MapsProtocolMapper { + private MapsProtocolMapper() { + } + + public static MapsListResponseV1 toMapsListResponse(String server, List maps) { + return new MapsListResponseV1(server, maps); + } + + public static MapEntryV1 toMapEntry(Map map, String currentGameMode, MapData persistedMap) { + String fileName = map.file == null || map.file.name() == null || map.file.name().isBlank() + ? map.plainName() + ".msav" + : map.file.name(); + String rawAuthor = map.author(); + String author = rawAuthor == null || rawAuthor.isBlank() ? "Unknown" : rawAuthor; + + return new MapEntryV1( + map.plainName(), + fileName, + author, + map.width, + map.height, + toFileSizeBytes(map.file == null ? null : map.file.length()), + persistedMap == null ? null : persistedMap.like, + persistedMap == null ? null : persistedMap.dislike, + persistedMap == null ? null : persistedMap.reputation, + persistedMap == null ? null : persistedMap.popularity, + persistedMap == null ? null : persistedMap.interest, + persistedMap == null ? currentGameMode : persistedMap.gameMode + ); + } + + private static Integer toFileSizeBytes(Long fileSizeBytes) { + if (fileSizeBytes == null || fileSizeBytes < 0L || fileSizeBytes > Integer.MAX_VALUE) { + return null; + } + return fileSizeBytes.intValue(); + } +} diff --git a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java index 8b84e27..80fedc1 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -15,9 +15,6 @@ import jakarta.inject.Named; import jakarta.inject.Singleton; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.event.TransportEvents.Request; -import org.xcore.plugin.event.TransportEvents.Response; import java.time.Instant; import java.util.ArrayList; @@ -193,7 +190,7 @@ public Subscription subscribe(Class type, Cons listener) { return subscription; } - public RequestSubscription request(Request request, Cons listener, Runnable timeout) { + public RequestSubscription request(REQ request, Cons listener, Runnable timeout) { if (!supportsRequestType(request.getClass())) { throw new UnsupportedOperationException("Redis request does not support type: " + request.getClass().getName()); } @@ -201,8 +198,8 @@ public RequestSubscription request(Request request, C throw new IllegalStateException("Redis backend is unavailable for request"); } - Class responseType = router.responseTypeForRequest(request.getClass()); - RedisRequestHandle requestHandle = new RedisRequestHandle<>(null); + Class responseType = router.responseTypeForRequest(request.getClass()); + RedisRequestHandle requestHandle = new RedisRequestHandle<>(null); requestHandles.add(requestHandle); requestHandle.onFinish(() -> requestHandles.remove(requestHandle)); if (responseType == null) { @@ -249,7 +246,7 @@ public RequestSubscription request(Request request, C return requestHandle; } - public void respond(Request request, T response) { + public void respond(Object request, Object response) { RedisRpcTracker.RpcInboundContext context = rpcTracker.take(request); if (context == null) { Log.warn("Redis respond context is missing for request: @", request.getClass().getName()); @@ -297,7 +294,7 @@ public T withCommands(java.util.function.Function request) { + public boolean supportsRespond(Object request) { return rpcTracker.contains(request); } @@ -487,11 +484,11 @@ private boolean dispatchStreamMessage(RedisCommands consumer try { T event = decodeEvent(payloadJson, type); - if (event instanceof Request request && router.isRpcRequestType(type)) { + if (router.isRpcRequestType(type)) { String correlationId = message.getBody().getOrDefault("correlation_id", ""); String replyTo = message.getBody().getOrDefault("reply_to", "xcore:rpc:resp:" + config.server); String rpcType = message.getBody().getOrDefault("rpc_type", "rpc.unknown"); - rpcTracker.registerInbound(request, correlationId, replyTo, rpcType, System.currentTimeMillis()); + rpcTracker.registerInbound(event, correlationId, replyTo, rpcType, System.currentTimeMillis()); } consumedEvents.incrementAndGet(); listener.get(event); @@ -511,14 +508,14 @@ private T decodeEvent(String payloadJson, Class type) { return gson.fromJson(payloadJson, type); } - private void awaitRpcResponse(String replyTo, - String correlationId, - Class responseType, - Cons listener, - Runnable timeout, - long timeoutMs, - CountDownLatch listenerReady, - RedisRequestHandle requestHandle) { + private void awaitRpcResponse(String replyTo, + String correlationId, + Class responseType, + Cons listener, + Runnable timeout, + long timeoutMs, + CountDownLatch listenerReady, + RedisRequestHandle requestHandle) { rpcTracker.awaitResponse(connectionManager.client(), replyTo, correlationId, responseType, listener, timeout, timeoutMs, listenerReady, rpcTimeouts, requestHandle); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteDescriptor.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteDescriptor.java index 115eda7..bdf84d9 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteDescriptor.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteDescriptor.java @@ -1,7 +1,5 @@ package org.xcore.plugin.service.network; -import org.xcore.plugin.event.TransportEvents; - public record RedisRouteDescriptor( Class payloadType, String streamPattern, @@ -9,7 +7,7 @@ public record RedisRouteDescriptor( long ttlMillis, RedisRouteKind kind, RedisServerResolver serverResolver, - Class responseType + Class responseType ) { public boolean isReadOnly() { return kind == RedisRouteKind.READ_ONLY; diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 71e9084..5f1c130 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -6,6 +6,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -34,6 +36,10 @@ public final class RedisRouteRegistry { if (discordServer != null && !discordServer.isBlank()) { return discordServer; } + String mapsServer = mapsServer(payload); + if (mapsServer != null && !mapsServer.isBlank()) { + return mapsServer; + } if (payload instanceof TransportEvents.ServerScopedEvent serverScopedEvent) { String server = serverScopedEvent.server(); if (server != null && !server.isBlank()) { @@ -95,7 +101,7 @@ public boolean isRpcRequestType(Class type) { return descriptor != null && descriptor.isRpcRequest(); } - public Class responseTypeForRequest(Class type) { + public Class responseTypeForRequest(Class type) { RedisRouteDescriptor descriptor = routeDescriptorFor(type); return descriptor == null ? null : descriptor.responseType(); } @@ -151,7 +157,7 @@ private void registerDefaults() { register(mutating(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, RedisServerResolver.broadcast())); register(mutating(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, MODERATION_SERVER_RESOLVER)); - register(rpc(TransportEvents.MapsListRequest.class, "xcore:rpc:req:{server}", "maps.list", 10_000L, PAYLOAD_SERVER_RESOLVER, TransportEvents.MapsListResponse.class)); + register(rpc(MapsListRequestV1.class, "xcore:rpc:req:{server}", "maps.list.request", 10_000L, PAYLOAD_SERVER_RESOLVER, MapsListResponseV1.class)); register(rpc(TransportEvents.MapRemoveRequest.class, "xcore:rpc:req:{server}", "maps.remove", 10_000L, PAYLOAD_SERVER_RESOLVER, TransportEvents.MapRemoveResponse.class)); } @@ -196,6 +202,13 @@ private static String discordServer(Object payload) { return null; } + private static String mapsServer(Object payload) { + if (payload instanceof MapsListRequestV1 request) { + return request.server(); + } + return null; + } + private void register(RedisRouteDescriptor descriptor) { descriptorsByType.put(descriptor.payloadType(), descriptor); } @@ -221,7 +234,7 @@ private static RedisRouteDescriptor rpc(Class payloadType, String eventType, long ttlMillis, RedisServerResolver serverResolver, - Class responseType) { + Class responseType) { return new RedisRouteDescriptor(payloadType, streamPattern, eventType, ttlMillis, RedisRouteKind.RPC_REQUEST, serverResolver, responseType); } } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRpcTracker.java b/src/main/java/org/xcore/plugin/service/network/RedisRpcTracker.java index eb1c406..e53f966 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRpcTracker.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRpcTracker.java @@ -9,9 +9,6 @@ import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; import jakarta.inject.Singleton; -import org.xcore.plugin.event.TransportEvents.Request; -import org.xcore.plugin.event.TransportEvents.Response; - import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; @@ -25,26 +22,26 @@ final class RedisRpcTracker { private static final long DEFAULT_CONTEXT_TTL_MILLIS = 120_000L; private final Gson gson; - private final Map, RpcInboundContext> inboundRpcContexts = Collections.synchronizedMap(new IdentityHashMap<>()); + private final Map inboundRpcContexts = Collections.synchronizedMap(new IdentityHashMap<>()); RedisRpcTracker(Gson gson) { this.gson = gson; } - void registerInbound(Request request, String correlationId, String replyTo, String rpcType, long createdAtMillis) { + void registerInbound(Object request, String correlationId, String replyTo, String rpcType, long createdAtMillis) { synchronized (inboundRpcContexts) { cleanupExpired(createdAtMillis, DEFAULT_CONTEXT_TTL_MILLIS); inboundRpcContexts.put(request, new RpcInboundContext(correlationId, replyTo, rpcType, createdAtMillis)); } } - RpcInboundContext take(Request request) { + RpcInboundContext take(Object request) { synchronized (inboundRpcContexts) { return inboundRpcContexts.remove(request); } } - boolean contains(Request request) { + boolean contains(Object request) { synchronized (inboundRpcContexts) { return inboundRpcContexts.containsKey(request); } @@ -57,21 +54,21 @@ int size() { } void cleanupExpired(long nowMillis, long ttlMillis) { - List> toRemove = new ArrayList<>(); - for (Map.Entry, RpcInboundContext> entry : inboundRpcContexts.entrySet()) { + List toRemove = new ArrayList<>(); + for (Map.Entry entry : inboundRpcContexts.entrySet()) { if (nowMillis - entry.getValue().createdAtMillis() > ttlMillis) { toRemove.add(entry.getKey()); } } - for (Request request : toRemove) { + for (Object request : toRemove) { inboundRpcContexts.remove(request); } } - void awaitResponse(RedisClient client, + void awaitResponse(RedisClient client, String replyTo, String correlationId, - Class responseType, + Class responseType, Cons listener, Runnable timeout, long timeoutMs, @@ -126,7 +123,7 @@ void awaitResponse(RedisClient client, return; } - Response response = gson.fromJson(payloadJson, responseType); + Object response = gson.fromJson(payloadJson, responseType); if (!requestHandle.isCancelled() && responseType.isInstance(response)) { listener.get((T) responseType.cast(response)); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java b/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java index 96b2f5e..adca1bd 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java @@ -1,7 +1,5 @@ package org.xcore.plugin.service.network; -import org.xcore.plugin.event.TransportEvents; - import java.util.List; import java.util.Locale; @@ -49,7 +47,7 @@ public boolean isRpcRequestType(Class type) { return registry.isRpcRequestType(type); } - public Class responseTypeForRequest(Class type) { + public Class responseTypeForRequest(Class type) { return registry.responseTypeForRequest(type); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 3855a75..ece9b95 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -6,6 +6,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -44,7 +46,7 @@ public record RouteSpec( ServerScope serverScope, boolean readOnly, boolean rpcRequest, - Class responseType + Class responseType ) { } @@ -74,7 +76,7 @@ public record RouteSpec( route(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), route(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - rpcRoute(TransportEvents.MapsListRequest.class, "xcore:rpc:req:{server}", "maps.list", 10_000L, ServerScope.PAYLOAD_SERVER, TransportEvents.MapsListResponse.class), + rpcRoute(MapsListRequestV1.class, "xcore:rpc:req:{server}", "maps.list.request", 10_000L, ServerScope.PAYLOAD_SERVER, MapsListResponseV1.class), rpcRoute(TransportEvents.MapRemoveRequest.class, "xcore:rpc:req:{server}", "maps.remove", 10_000L, ServerScope.PAYLOAD_SERVER, TransportEvents.MapRemoveResponse.class) ); @@ -107,7 +109,7 @@ private static RouteSpec rpcRoute(Class payloadType, String eventType, long ttlMillis, ServerScope serverScope, - Class responseType) { + Class responseType) { return new RouteSpec(payloadType, streamPattern, eventType, ttlMillis, DeliveryMode.RPC_REQUEST, serverScope, false, true, responseType); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 0ff1eff..55c495e 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -13,9 +13,12 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; +import org.xcore.protocol.generated.shared.MapEntryV1; import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.plugin.config.Config; @@ -399,8 +402,8 @@ void rpcRequestResponseRoundtripWorks() throws InterruptedException { serverBackend.connect(); requesterBackend.connect(); - Subscription serverSubscription = - serverBackend.subscribe(TransportEvents.MapsListRequest.class, + Subscription serverSubscription = + serverBackend.subscribe(MapsListRequestV1.class, request -> serverBackend.respond( request, mapsListResponse( @@ -411,9 +414,9 @@ void rpcRequestResponseRoundtripWorks() throws InterruptedException { CountDownLatch responseLatch = new CountDownLatch(1); CountDownLatch timeoutLatch = new CountDownLatch(1); - AtomicReference responseRef = new AtomicReference<>(); + AtomicReference responseRef = new AtomicReference<>(); - RequestSubscription requestHandle = requesterBackend.request(mapsListRequest("target"), response -> { + RequestSubscription requestHandle = requesterBackend.request(mapsListRequest("target"), response -> { responseRef.set(response); responseLatch.countDown(); }, timeoutLatch::countDown); @@ -422,10 +425,10 @@ void rpcRequestResponseRoundtripWorks() throws InterruptedException { assertThat(responseLatch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(timeoutLatch.getCount()).isEqualTo(1); assertThat(responseRef.get()).isNotNull(); - assertThat(responseRef.get().maps).extracting(entry -> entry.name).containsExactly("A", "B"); - assertThat(responseRef.get().maps).extracting(entry -> entry.like).containsExactly(3, null); - assertThat(responseRef.get().maps).extracting(entry -> entry.reputation).containsExactly(2, null); - assertThat(responseRef.get().maps).extracting(entry -> entry.gameMode).containsExactly("pvp", null); + assertThat(responseRef.get().maps()).extracting(MapEntryV1::name).containsExactly("A", "B"); + assertThat(responseRef.get().maps()).extracting(MapEntryV1::like).containsExactly(3, null); + assertThat(responseRef.get().maps()).extracting(MapEntryV1::reputation).containsExactly(2, null); + assertThat(responseRef.get().maps()).extracting(MapEntryV1::gameMode).containsExactly("pvp", null); assertThat(requesterBackend.metricsSnapshot().getOrDefault("rpc_requests", 0L)).isGreaterThanOrEqualTo(1L); assertThat(serverBackend.metricsSnapshot().getOrDefault("rpc_responses", 0L)).isGreaterThanOrEqualTo(1L); @@ -445,8 +448,8 @@ void rpcDispatchDoesNotCrossTriggerHandlers() throws InterruptedException { requesterBackend.connect(); CountDownLatch listLatch = new CountDownLatch(1); - Subscription listSubscription = - serverBackend.subscribe(TransportEvents.MapsListRequest.class, request -> { + Subscription listSubscription = + serverBackend.subscribe(MapsListRequestV1.class, request -> { listLatch.countDown(); serverBackend.respond( request, @@ -490,15 +493,15 @@ void expiredRpcRequestIsDropped() throws InterruptedException { serverBackend.connect(); CountDownLatch handlerLatch = new CountDownLatch(1); - Subscription subscription = - serverBackend.subscribe(TransportEvents.MapsListRequest.class, request -> handlerLatch.countDown()); + Subscription subscription = + serverBackend.subscribe(MapsListRequestV1.class, request -> handlerLatch.countDown()); try (RedisClient client = RedisClient.create(serverConfig.redisUrl); StatefulRedisConnection connection = client.connect()) { long now = System.currentTimeMillis(); connection.sync().xadd("xcore:rpc:req:target", java.util.Map.ofEntries( java.util.Map.entry("schema_version", "1"), - java.util.Map.entry("rpc_type", "maps.list"), + java.util.Map.entry("rpc_type", "maps.list.request"), java.util.Map.entry("correlation_id", "c-expired"), java.util.Map.entry("request_id", "r-expired"), java.util.Map.entry("reply_to", "xcore:rpc:resp:discord"), @@ -644,7 +647,7 @@ void requestCancelStopsRpcAwaitLifecycle() { AtomicInteger responses = new AtomicInteger(); AtomicInteger timeouts = new AtomicInteger(); - RequestSubscription requestHandle = requesterBackend.request( + RequestSubscription requestHandle = requesterBackend.request( mapsListRequest("target"), response -> responses.incrementAndGet(), timeouts::incrementAndGet @@ -693,10 +696,8 @@ private static T punishment(T value, String uuid, String return value; } - private static TransportEvents.MapsListRequest mapsListRequest(String server) { - TransportEvents.MapsListRequest request = new TransportEvents.MapsListRequest(); - request.server = server; - return request; + private static MapsListRequestV1 mapsListRequest(String server) { + return new MapsListRequestV1(server); } private static TransportEvents.MapRemoveRequest mapRemoveRequest(String server, String fileName) { @@ -706,10 +707,8 @@ private static TransportEvents.MapRemoveRequest mapRemoveRequest(String server, return request; } - private static TransportEvents.MapsListResponse mapsListResponse(TransportEvents.MapEntry... entries) { - TransportEvents.MapsListResponse response = new TransportEvents.MapsListResponse(); - response.maps = entries; - return response; + private static MapsListResponseV1 mapsListResponse(MapEntryV1... entries) { + return new MapsListResponseV1("target", List.of(entries)); } private static TransportEvents.MapRemoveResponse mapRemoveResponse(String result) { @@ -718,7 +717,7 @@ private static TransportEvents.MapRemoveResponse mapRemoveResponse(String result return response; } - private static TransportEvents.MapEntry mapEntry( + private static MapEntryV1 mapEntry( String name, String fileName, String author, @@ -729,7 +728,7 @@ private static TransportEvents.MapEntry mapEntry( return mapEntry(name, fileName, author, width, height, fileSizeBytes, null, null, null, null, null, null); } - private static TransportEvents.MapEntry mapEntry( + private static MapEntryV1 mapEntry( String name, String fileName, String author, @@ -743,19 +742,7 @@ private static TransportEvents.MapEntry mapEntry( Double interest, String gameMode ) { - TransportEvents.MapEntry entry = new TransportEvents.MapEntry(); - entry.name = name; - entry.fileName = fileName; - entry.author = author; - entry.width = width; - entry.height = height; - entry.fileSizeBytes = fileSizeBytes; - entry.like = like; - entry.dislike = dislike; - entry.reputation = reputation; - entry.popularity = popularity; - entry.interest = interest; - entry.gameMode = gameMode; - return entry; + Integer fileSize = fileSizeBytes == null ? null : Math.toIntExact(fileSizeBytes); + return new MapEntryV1(name, fileName, author, width, height, fileSize, like, dislike, reputation, popularity, interest, gameMode); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 9b38bb6..0fd5fe0 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -6,6 +6,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -79,12 +81,12 @@ void defaultServerResolverUsesDefaultServer() { @Test @DisplayName("rpc route descriptor carries response type metadata") void rpcRouteDescriptorCarriesResponseType() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(TransportEvents.MapsListRequest.class); + RedisRouteDescriptor descriptor = registry.routeDescriptorFor(MapsListRequestV1.class); assertThat(descriptor).isNotNull(); assertThat(descriptor.isRpcRequest()).isTrue(); - assertThat(descriptor.responseType()).isEqualTo(TransportEvents.MapsListResponse.class); - assertThat(registry.rpcTypeForRequestClass(TransportEvents.MapsListRequest.class)).isEqualTo("maps.list"); + assertThat(descriptor.responseType()).isEqualTo(MapsListResponseV1.class); + assertThat(registry.rpcTypeForRequestClass(MapsListRequestV1.class)).isEqualTo("maps.list.request"); } @Test diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 2199cbd..a9b2a89 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -7,6 +7,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -207,7 +209,7 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(TransportEvents.GlobalChatEvent.class, "mini-pvp")) .containsExactly("xcore:evt:chat:global"); - assertThat(router.subscribeStreamsFor(TransportEvents.MapsListRequest.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(MapsListRequestV1.class, "mini-pvp")) .containsExactly("xcore:rpc:req:mini-pvp"); assertThat(router.subscribeStreamsFor(TransportEvents.MapRemoveRequest.class, "mini-pvp")) @@ -273,16 +275,16 @@ void classificationAndResponseMapping() { assertThat(router.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.MessageEvent.class)).isFalse(); - assertThat(router.isRpcRequestType(TransportEvents.MapsListRequest.class)).isTrue(); + assertThat(router.isRpcRequestType(MapsListRequestV1.class)).isTrue(); assertThat(router.isRpcRequestType(TransportEvents.MessageEvent.class)).isFalse(); - assertThat(router.responseTypeForRequest(TransportEvents.MapsListRequest.class)) - .isEqualTo(TransportEvents.MapsListResponse.class); + assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) + .isEqualTo(MapsListResponseV1.class); assertThat(router.responseTypeForRequest(TransportEvents.MapRemoveRequest.class)) .isEqualTo(TransportEvents.MapRemoveResponse.class); - assertThat(router.rpcTypeForRequestClass(TransportEvents.MapsListRequest.class)) - .isEqualTo("maps.list"); + assertThat(router.rpcTypeForRequestClass(MapsListRequestV1.class)) + .isEqualTo("maps.list.request"); assertThat(router.rpcTypeForRequestClass(TransportEvents.MapRemoveRequest.class)) .isEqualTo("maps.remove"); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index ed3e778..f40a0fe 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -5,6 +5,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; @@ -150,7 +152,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); - RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(TransportEvents.MapsListRequest.class); + RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(MapsListRequestV1.class); RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(ModerationKickBannedCommandV1.class); // Act @@ -231,12 +233,12 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableRpcRoute).isNotNull(); assertThat(stableRpcRoute.streamPattern()).isEqualTo("xcore:rpc:req:{server}"); - assertThat(stableRpcRoute.eventType()).isEqualTo("maps.list"); + assertThat(stableRpcRoute.eventType()).isEqualTo("maps.list.request"); assertThat(stableRpcRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.RPC_REQUEST); assertThat(stableRpcRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); assertThat(stableRpcRoute.readOnly()).isFalse(); assertThat(stableRpcRoute.rpcRequest()).isTrue(); - assertThat(stableRpcRoute.responseType()).isEqualTo(TransportEvents.MapsListResponse.class); + assertThat(stableRpcRoute.responseType()).isEqualTo(MapsListResponseV1.class); } @Test @@ -244,13 +246,13 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { void registryAndRouterRemainAlignedWithExplicitTransportTopology() { // Arrange RedisTransportTopology.RouteSpec commandSpec = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); - RedisTransportTopology.RouteSpec rpcSpec = RedisTransportTopology.routeFor(TransportEvents.MapsListRequest.class); + RedisTransportTopology.RouteSpec rpcSpec = RedisTransportTopology.routeFor(MapsListRequestV1.class); RedisRouteDescriptor commandDescriptor = registry.routeDescriptorFor(TransportEvents.PlayerPasswordReset.class); - RedisRouteDescriptor rpcDescriptor = registry.routeDescriptorFor(TransportEvents.MapsListRequest.class); + RedisRouteDescriptor rpcDescriptor = registry.routeDescriptorFor(MapsListRequestV1.class); // Act var commandRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); - List rpcSubscriptions = router.subscribeStreamsFor(TransportEvents.MapsListRequest.class, "mini-pvp"); + List rpcSubscriptions = router.subscribeStreamsFor(MapsListRequestV1.class, "mini-pvp"); // Assert assertThat(commandDescriptor).isNotNull(); @@ -267,10 +269,10 @@ void registryAndRouterRemainAlignedWithExplicitTransportTopology() { assertThat(rpcDescriptor.eventType()).isEqualTo(rpcSpec.eventType()); assertThat(rpcDescriptor.isRpcRequest()).isTrue(); assertThat(rpcDescriptor.responseType()).isEqualTo(rpcSpec.responseType()); - assertThat(router.rpcTypeForRequestClass(TransportEvents.MapsListRequest.class)).isEqualTo("maps.list"); + assertThat(router.rpcTypeForRequestClass(MapsListRequestV1.class)).isEqualTo("maps.list.request"); assertThat(rpcSubscriptions).containsExactly("xcore:rpc:req:mini-pvp"); - assertThat(router.responseTypeForRequest(TransportEvents.MapsListRequest.class)) - .isEqualTo(TransportEvents.MapsListResponse.class); + assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) + .isEqualTo(MapsListResponseV1.class); assertThat(router.responseTypeForRequest(TransportEvents.MessageEvent.class)).isNull(); } } From d15240e8bf61b1f773004cd4cd3b2eebb5fa7809 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:40:37 +0300 Subject: [PATCH 08/26] fix(moderation): support IP-only protocol targets Consume xcore-protocol 0.2.0 and relax moderation command and audit mapping so UUID-or-IP targets can round-trip through the plugin. Skip ban-created emission for IP-only temporary bans while preserving the stricter event contract. --- gradle/libs.versions.toml | 2 +- .../service/moderation/ModerationService.java | 17 ++++-- .../network/ModerationProtocolMapper.java | 19 ++++-- .../ModerationServiceAvajeTest.java | 58 ++++++++++++++++++- .../network/RedisStreamRouterTest.java | 3 +- 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f253cc1..5420af1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] mindustry = "157" -xcore-protocol = "0.1.0" +xcore-protocol = "0.2.0" # Plugins toxopid = "4.1.2" diff --git a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java index fa15ecc..f3b6a48 100644 --- a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java +++ b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java @@ -166,7 +166,7 @@ public ModerationResult unbanById(int id, String adminName, String a ); postAuditEvent(audit); - network.post(toPardonCommand(target.uuid, target.pid, target.nickname, audit)); + network.post(toPardonCommand(target.uuid, target.pid, target.nickname, null, audit)); return ModerationResult.success("Player '" + target.nickname + "' unbanned successfully", target); } @@ -244,7 +244,7 @@ public ModerationResult unmuteById(int id, String adminName, String ); postAuditEvent(audit); - network.post(toPardonCommand(target.uuid, target.pid, target.nickname, audit)); + network.post(toPardonCommand(target.uuid, target.pid, target.nickname, null, audit)); return ModerationResult.success("Player '" + target.nickname + "' unmuted successfully", target); } @@ -291,7 +291,9 @@ public ModerationResult tempBanByUuidOrIp(String uuid, String ip, Strin null ); - postBanEvents(ban, audit); + if (hasUuid(uuid)) { + postBanEvents(ban, audit); + } postAuditEvent(audit); network.post(ModerationProtocolMapper.toKickBannedCommand( uuid, @@ -332,7 +334,7 @@ public ModerationResult tempUnban(String uuid, String ip, String adminName ); postAuditEvent(audit); - network.post(toPardonCommand(uuid, null, UNKNOWN_PLAYER_NAME, audit)); + network.post(toPardonCommand(uuid, null, UNKNOWN_PLAYER_NAME, ip, audit)); return ModerationResult.success("Unbanned: UUID=" + uuid + " / IP=" + ip, null); } @@ -403,11 +405,12 @@ private void postBanEvents(BanData ban, AuditRecord audit) { network.post(ModerationProtocolMapper.toBanCreated(ban, config.server, eventOccurredAt(audit))); } - private ModerationPardonCommandV1 toPardonCommand(String uuid, Integer pid, String playerName, AuditRecord audit) { + private ModerationPardonCommandV1 toPardonCommand(String uuid, Integer pid, String playerName, String ip, AuditRecord audit) { return ModerationProtocolMapper.toPardonCommand( uuid, pid, playerName, + ip, config.server, commandOccurredAt(audit) ); @@ -474,6 +477,10 @@ private static boolean hasNoIdentifier(String uuid, String ip) { return uuid == null && ip == null; } + private static boolean hasUuid(String uuid) { + return uuid != null && !uuid.isBlank(); + } + private static Instant toExpireDate(Duration duration) { return Instant.now().plus(duration); } diff --git a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java index cd05a84..c3bf766 100644 --- a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java @@ -14,6 +14,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.shared.ActorRefV1; import org.xcore.protocol.generated.shared.ExpirationInfoV1; +import org.xcore.protocol.generated.shared.ModerationTargetRefV1; import org.xcore.protocol.generated.shared.PlayerCommandTargetV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.protocol.generated.shared.VoteKickParticipantV1; @@ -88,7 +89,7 @@ public static ModerationKickBannedCommandV1 toKickBannedCommand( ) { return new ModerationKickBannedCommandV1( new PlayerCommandTargetV1( - requireNonBlank(playerUuid, "playerUuid"), + normalizeOptional(playerUuid), normalizeOptionalPid(playerPid), normalizeOptional(playerName), normalizeOptional(ip) @@ -102,11 +103,17 @@ public static ModerationPardonCommandV1 toPardonCommand( String playerUuid, Integer playerPid, String playerName, + String ip, String server, Instant requestedAt ) { return new ModerationPardonCommandV1( - new PlayerCommandTargetV1(requireNonBlank(playerUuid, "playerUuid"), normalizeOptionalPid(playerPid), normalizeOptional(playerName), null), + new PlayerCommandTargetV1( + normalizeOptional(playerUuid), + normalizeOptionalPid(playerPid), + normalizeOptional(playerName), + normalizeOptional(ip) + ), requireNonBlank(server, "server"), toOccurredAt(requestedAt) ); @@ -117,11 +124,11 @@ public static ModerationAuditAppendedV1 toAuditAppended(AuditRecord record, Stri return new ModerationAuditAppendedV1( toAuditEntryType(record.action), - new PlayerRefV1( - requireNonBlank(record.target == null ? null : record.target.uuid, "audit target uuid"), + new ModerationTargetRefV1( + normalizeOptional(record.target == null ? null : record.target.uuid), record.target == null ? null : normalizeOptionalPid(record.target.pid), - requirePlayerName(record.target == null ? null : record.target.nameSnapshot), - null + normalizeOptional(record.target == null ? null : record.target.nameSnapshot), + normalizeOptional(record.target == null ? null : record.target.ipSnapshot) ), new ActorRefV1( resolveActorName(record.actor == null ? null : record.actor.nameSnapshot), diff --git a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java index 7c00fec..ce78989 100644 --- a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java +++ b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java @@ -174,6 +174,30 @@ void tempBanSuccess() { && "Unknown".equals(ban.getName()))); } + @Test + @DisplayName("tempBanByUuidOrIp supports IP-only kick and audit targets") + void tempBanIpOnlyTargetSuccess() { + when(banDataRepository.save(any())).thenReturn(true); + when(auditService.append(any())).thenReturn(org.xcore.plugin.model.AuditAppendResult.success( + validAuditRecord(null, "Unknown", "1.2.3.4") + )); + + var result = moderationService.tempBanByUuidOrIp(null, "1.2.3.4", null, Duration.ofMinutes(30), null, "admin", null); + + assertThat(result.isSuccess()).isTrue(); + verify(network, never()).post(argThat(event -> event instanceof ModerationBanCreatedV1)); + verify(network).post(argThat(event -> + event instanceof ModerationAuditAppendedV1 audit + && audit.target() != null + && audit.target().playerUuid() == null + && "1.2.3.4".equals(audit.target().ip()))); + verify(network).post(argThat(event -> + event instanceof ModerationKickBannedCommandV1 kick + && kick.target() != null + && kick.target().playerUuid() == null + && "1.2.3.4".equals(kick.target().ip()))); + } + @Test @DisplayName("tempBanByUuidOrIp fails when ban persistence fails") void tempBanFailsWhenSaveFails() { @@ -231,6 +255,31 @@ void tempUnbanSuccess() { verify(banDataRepository).delete("uuid-2", null); } + @Test + @DisplayName("tempUnban supports IP-only pardon and audit targets") + void tempUnbanIpOnlySuccess() { + when(banDataRepository.delete(null, "1.2.3.4")).thenReturn(true); + when(auditService.append(any())).thenReturn(org.xcore.plugin.model.AuditAppendResult.success( + validAuditRecord(null, "Unknown", "1.2.3.4") + )); + + var result = moderationService.tempUnban(null, "1.2.3.4", "console", null); + + assertThat(result.isSuccess()).isTrue(); + verify(network).post(argThat(event -> + event instanceof ModerationAuditAppendedV1 audit + && audit.target() != null + && audit.target().playerUuid() == null + && "1.2.3.4".equals(audit.target().ip()))); + verify(network).post(argThat(event -> + event instanceof ModerationPardonCommandV1 pardon + && pardon.target() != null + && pardon.target().playerUuid() == null + && "1.2.3.4".equals(pardon.target().ip()) + && "test-server".equals(pardon.server()))); + verify(banDataRepository).delete(null, "1.2.3.4"); + } + @Test @DisplayName("tempUnban still succeeds when audit append fails after delete") void tempUnbanAuditFailureDoesNotFlipResultToFailure() { @@ -574,12 +623,17 @@ void findPlayerData_whenFindReturnsNull_returnsNull() { } private static AuditRecord validAuditRecord() { + return validAuditRecord("audit-target-uuid", "Audit Target", null); + } + + private static AuditRecord validAuditRecord(String uuid, String nameSnapshot, String ipSnapshot) { return AuditRecord.builder() .auditId("audit-1") .action(AuditAction.NOTE) .target(AuditTarget.builder() - .uuid("audit-target-uuid") - .nameSnapshot("Audit Target") + .uuid(uuid) + .nameSnapshot(nameSnapshot) + .ipSnapshot(ipSnapshot) .build()) .actor(AuditActor.builder() .type(AuditActorType.SYSTEM) diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index a9b2a89..fa1616b 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -18,6 +18,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.protocol.generated.shared.ActorRefV1; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.ModerationTargetRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.BanData; @@ -76,7 +77,7 @@ void routeReadOnlyEvents() { var auditRoute = router.route( new ModerationAuditAppendedV1( "ban", - new PlayerRefV1("uuid-target", 42, "target", null), + new ModerationTargetRefV1("uuid-target", 42, "target", null), new ActorRefV1("Admin", "admin-1", "discord"), "reason", "mini-pvp", From 23f8b99bd96094205aea912f32f0de37ae32f196 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:57:01 +0300 Subject: [PATCH 09/26] feat(network): migrate maps remove transport DTOs Switch the remaining maps.remove RPC flow to generated xcore-protocol DTOs and canonical route metadata so the maps RPC family no longer depends on local transport request and response wrappers. --- .../event/transport/MapTransportHandler.java | 12 ++++----- .../service/network/MapsProtocolMapper.java | 5 ++++ .../service/network/RedisRouteRegistry.java | 7 ++++- .../network/RedisTransportTopology.java | 4 ++- .../RedisNetworkBackendIntegrationTest.java | 27 +++++++++---------- .../network/RedisRouteRegistryTest.java | 8 ++++++ .../network/RedisStreamRouterTest.java | 12 +++++---- .../network/RedisTransportContractsTest.java | 13 +++++++++ 8 files changed, 60 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java index 1b29374..edb16fb 100644 --- a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java @@ -6,6 +6,7 @@ import jakarta.inject.Singleton; import mindustry.maps.Map; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.shared.MapEntryV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.MapDataRepository; @@ -59,20 +60,19 @@ public void registerListeners() { network.respond(request, MapsProtocolMapper.toMapsListResponse(request.server(), mapsList)); }); - network.subscribe(TransportEvents.MapRemoveRequest.class, request -> { - if (!request.server.equals(config.server)) return; + network.subscribe(MapsRemoveRequestV1.class, request -> { + if (!request.server().equals(config.server)) return; - var map = mapService.findMapByFileName(request.fileName); + var map = mapService.findMapByFileName(request.fileName()); if (map != null) { maps.removeMap(map); maps.reload(); } - TransportEvents.MapRemoveResponse response = new TransportEvents.MapRemoveResponse(); - response.result = map == null + String result = map == null ? "Map file not found" : "Successfully removed map " + map.plainName() + " (" + map.file.name() + ")"; - network.respond(request, response); + network.respond(request, MapsProtocolMapper.toMapsRemoveResponse(request.server(), result)); if (map != null) info("Removed map @", map.plainName()); }); diff --git a/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java index e823a2f..375a27f 100644 --- a/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java @@ -1,6 +1,7 @@ package org.xcore.plugin.service.network; import mindustry.maps.Map; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.plugin.model.MapData; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.shared.MapEntryV1; @@ -15,6 +16,10 @@ public static MapsListResponseV1 toMapsListResponse(String server, List, RouteSpec> ROUTES_BY_TYPE = ROUTES.stream() diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 55c495e..f39f1dc 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -15,6 +15,8 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; @@ -460,15 +462,15 @@ void rpcDispatchDoesNotCrossTriggerHandlers() throws InterruptedException { ); }); - Subscription removeSubscription = - serverBackend.subscribe(TransportEvents.MapRemoveRequest.class, - request -> serverBackend.respond(request, mapRemoveResponse("Removed"))); + Subscription removeSubscription = + serverBackend.subscribe(MapsRemoveRequestV1.class, + request -> serverBackend.respond(request, mapRemoveResponse(request.server(), "Removed"))); CountDownLatch responseLatch = new CountDownLatch(1); CountDownLatch timeoutLatch = new CountDownLatch(1); - AtomicReference responseRef = new AtomicReference<>(); + AtomicReference responseRef = new AtomicReference<>(); - RequestSubscription requestHandle = requesterBackend.request(mapRemoveRequest("target", "MapX"), response -> { + RequestSubscription requestHandle = requesterBackend.request(mapRemoveRequest("target", "MapX"), response -> { responseRef.set(response); responseLatch.countDown(); }, timeoutLatch::countDown); @@ -477,7 +479,7 @@ void rpcDispatchDoesNotCrossTriggerHandlers() throws InterruptedException { assertThat(responseLatch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(timeoutLatch.getCount()).isEqualTo(1); assertThat(responseRef.get()).isNotNull(); - assertThat(responseRef.get().result).isEqualTo("Removed"); + assertThat(responseRef.get().result()).isEqualTo("Removed"); assertThat(listLatch.getCount()).isEqualTo(1); listSubscription.unsubscribe(); @@ -700,21 +702,16 @@ private static MapsListRequestV1 mapsListRequest(String server) { return new MapsListRequestV1(server); } - private static TransportEvents.MapRemoveRequest mapRemoveRequest(String server, String fileName) { - TransportEvents.MapRemoveRequest request = new TransportEvents.MapRemoveRequest(); - request.server = server; - request.fileName = fileName; - return request; + private static MapsRemoveRequestV1 mapRemoveRequest(String server, String fileName) { + return new MapsRemoveRequestV1(server, fileName); } private static MapsListResponseV1 mapsListResponse(MapEntryV1... entries) { return new MapsListResponseV1("target", List.of(entries)); } - private static TransportEvents.MapRemoveResponse mapRemoveResponse(String result) { - TransportEvents.MapRemoveResponse response = new TransportEvents.MapRemoveResponse(); - response.result = result; - return response; + private static MapsRemoveResponseV1 mapRemoveResponse(String server, String result) { + return new MapsRemoveResponseV1(server, result); } private static MapEntryV1 mapEntry( diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 0fd5fe0..89ae484 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -8,6 +8,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -82,11 +84,17 @@ void defaultServerResolverUsesDefaultServer() { @DisplayName("rpc route descriptor carries response type metadata") void rpcRouteDescriptorCarriesResponseType() { RedisRouteDescriptor descriptor = registry.routeDescriptorFor(MapsListRequestV1.class); + RedisRouteDescriptor removeDescriptor = registry.routeDescriptorFor(MapsRemoveRequestV1.class); assertThat(descriptor).isNotNull(); assertThat(descriptor.isRpcRequest()).isTrue(); assertThat(descriptor.responseType()).isEqualTo(MapsListResponseV1.class); assertThat(registry.rpcTypeForRequestClass(MapsListRequestV1.class)).isEqualTo("maps.list.request"); + + assertThat(removeDescriptor).isNotNull(); + assertThat(removeDescriptor.isRpcRequest()).isTrue(); + assertThat(removeDescriptor.responseType()).isEqualTo(MapsRemoveResponseV1.class); + assertThat(registry.rpcTypeForRequestClass(MapsRemoveRequestV1.class)).isEqualTo("maps.remove.request"); } @Test diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index fa1616b..4cec268 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -9,6 +9,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -213,7 +215,7 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(MapsListRequestV1.class, "mini-pvp")) .containsExactly("xcore:rpc:req:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.MapRemoveRequest.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(MapsRemoveRequestV1.class, "mini-pvp")) .containsExactly("xcore:rpc:req:mini-pvp"); assertThat(router.subscribeStreamsFor(TransportEvents.PlayerPasswordReset.class, "mini-pvp")) @@ -281,13 +283,13 @@ void classificationAndResponseMapping() { assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) .isEqualTo(MapsListResponseV1.class); - assertThat(router.responseTypeForRequest(TransportEvents.MapRemoveRequest.class)) - .isEqualTo(TransportEvents.MapRemoveResponse.class); + assertThat(router.responseTypeForRequest(MapsRemoveRequestV1.class)) + .isEqualTo(MapsRemoveResponseV1.class); assertThat(router.rpcTypeForRequestClass(MapsListRequestV1.class)) .isEqualTo("maps.list.request"); - assertThat(router.rpcTypeForRequestClass(TransportEvents.MapRemoveRequest.class)) - .isEqualTo("maps.remove"); + assertThat(router.rpcTypeForRequestClass(MapsRemoveRequestV1.class)) + .isEqualTo("maps.remove.request"); } private static T punishment(T value, String uuid, String name) { diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index f40a0fe..587a386 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -7,6 +7,8 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; @@ -153,6 +155,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(MapsListRequestV1.class); + RedisTransportTopology.RouteSpec removeRpcRoute = RedisTransportTopology.routeFor(MapsRemoveRequestV1.class); RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(ModerationKickBannedCommandV1.class); // Act @@ -165,6 +168,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec stableDiscordAdminCommandRoute = discordAdminCommandRoute; RedisTransportTopology.RouteSpec stableBroadcastCommandRoute = broadcastCommandRoute; RedisTransportTopology.RouteSpec stableRpcRoute = rpcRoute; + RedisTransportTopology.RouteSpec stableRemoveRpcRoute = removeRpcRoute; RedisTransportTopology.RouteSpec stableKickBannedRoute = kickBannedRoute; // Assert @@ -239,6 +243,15 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableRpcRoute.readOnly()).isFalse(); assertThat(stableRpcRoute.rpcRequest()).isTrue(); assertThat(stableRpcRoute.responseType()).isEqualTo(MapsListResponseV1.class); + + assertThat(stableRemoveRpcRoute).isNotNull(); + assertThat(stableRemoveRpcRoute.streamPattern()).isEqualTo("xcore:rpc:req:{server}"); + assertThat(stableRemoveRpcRoute.eventType()).isEqualTo("maps.remove.request"); + assertThat(stableRemoveRpcRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.RPC_REQUEST); + assertThat(stableRemoveRpcRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableRemoveRpcRoute.readOnly()).isFalse(); + assertThat(stableRemoveRpcRoute.rpcRequest()).isTrue(); + assertThat(stableRemoveRpcRoute.responseType()).isEqualTo(MapsRemoveResponseV1.class); } @Test From 847c35bb47bdcac49aa53639dbd07a6e90177367 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:25:16 +0300 Subject: [PATCH 10/26] feat(network): migrate heartbeat transport DTOs Switch the heartbeat producer and transport metadata to the generated server.heartbeat DTO so Phase 5 starts on the canonical protocol surface. Remove the dead local heartbeat wrapper now that routing and validation are fully aligned. --- .../java/org/xcore/plugin/event/TransportEvents.java | 10 ---------- .../java/org/xcore/plugin/event/TransportService.java | 5 +++-- .../plugin/service/network/RedisRouteRegistry.java | 2 ++ .../service/network/RedisTransportTopology.java | 2 ++ .../service/network/RedisRouteRegistryTest.java | 2 ++ .../plugin/service/network/RedisStreamRouterTest.java | 9 +++++++++ .../service/network/RedisTransportContractsTest.java | 11 +++++++++++ 7 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 40e2cfd..6660d63 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -36,16 +36,6 @@ public record PrivateMessageEvent( String server ) implements ServerScopedEvent {} - public record ServerHeartbeatEvent( - String serverName, - long discordChannelId, - int players, - int maxPlayers, - String version, - String host, - Integer port - ) implements Event {} - public record KickBannedPlayer(String uuid, String ip) {} public record PlayerCustomNicknameChanged(String uuid, String customNickname) {} diff --git a/src/main/java/org/xcore/plugin/event/TransportService.java b/src/main/java/org/xcore/plugin/event/TransportService.java index e2c797e..f105129 100644 --- a/src/main/java/org/xcore/plugin/event/TransportService.java +++ b/src/main/java/org/xcore/plugin/event/TransportService.java @@ -10,6 +10,7 @@ import mindustry.game.EventType; import mindustry.gen.Groups; import mindustry.net.Administration; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.transport.ChatTransportHandler; import org.xcore.plugin.event.transport.DiscordLinkTransportHandler; @@ -58,9 +59,9 @@ public void init() { Timer.schedule(() -> { try { - network.post(new TransportEvents.ServerHeartbeatEvent( + network.post(new ServerHeartbeatV1( config.server, - config.discordChannelId, + Math.toIntExact(config.discordChannelId), Groups.player.size(), config.getNoAdminPlayerLimit(), Version.buildString(), diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 78eaaf4..ac9bcbc 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,6 +1,7 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -138,6 +139,7 @@ private void registerDefaults() { register(readOnly(TransportEvents.ServerActionEvent.class, "xcore:evt:server:action", "server.action", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); + register(readOnly(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, RedisServerResolver.broadcast())); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index ea98a1b..5cd93c8 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -1,6 +1,7 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -57,6 +58,7 @@ public record RouteSpec( route(TransportEvents.ServerActionEvent.class, "xcore:evt:server:action", "server.action", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), route(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 89ae484..ddf0440 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; @@ -101,6 +102,7 @@ void rpcRouteDescriptorCarriesResponseType() { @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(TransportEvents.GlobalChatEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 4cec268..82e8b22 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -44,6 +45,7 @@ void routeReadOnlyEvents() { var messageRoute = router.route(new TransportEvents.MessageEvent("a", "b", "mini-pvp"), "mini-pvp"); var joinRoute = router.route(new TransportEvents.PlayerJoinLeaveEvent("p", "mini-pvp", true), "mini-pvp"); + var heartbeatRoute = router.route(new ServerHeartbeatV1("mini-pvp", 1, 5, 30, "1.0.0", "127.0.0.1", 6567), "mini-pvp"); var banRoute = router.route( org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreated( banData, @@ -105,6 +107,9 @@ void routeReadOnlyEvents() { assertThat(joinRoute.streamKey()).isEqualTo("xcore:evt:player:joinleave"); assertThat(joinRoute.eventType()).isEqualTo("player.join_leave"); + assertThat(heartbeatRoute.streamKey()).isEqualTo("xcore:evt:server:heartbeat"); + assertThat(heartbeatRoute.eventType()).isEqualTo("server.heartbeat"); + assertThat(banRoute.streamKey()).isEqualTo("xcore:evt:moderation:ban"); assertThat(banRoute.eventType()).isEqualTo("moderation.ban.created"); @@ -212,6 +217,9 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(TransportEvents.GlobalChatEvent.class, "mini-pvp")) .containsExactly("xcore:evt:chat:global"); + assertThat(router.subscribeStreamsFor(ServerHeartbeatV1.class, "mini-pvp")) + .containsExactly("xcore:evt:server:heartbeat"); + assertThat(router.subscribeStreamsFor(MapsListRequestV1.class, "mini-pvp")) .containsExactly("xcore:rpc:req:mini-pvp"); @@ -261,6 +269,7 @@ void subscribeStreamsForTypes() { @Test @DisplayName("type classification and rpc response mapping are correct") void classificationAndResponseMapping() { + assertThat(router.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(router.isReadOnlyType(DiscordLinkCodeCreatedV1.class)).isTrue(); assertThat(router.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 587a386..7664436 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; @@ -147,6 +148,7 @@ void rpcRequestAndResponseEnvelopeFieldsStayStableAndDirectionSpecific() { void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Arrange RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(TransportEvents.GlobalChatEvent.class); + RedisTransportTopology.RouteSpec heartbeatRoute = RedisTransportTopology.routeFor(ServerHeartbeatV1.class); RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); RedisTransportTopology.RouteSpec discordLinkCodeRoute = RedisTransportTopology.routeFor(DiscordLinkCodeCreatedV1.class); @@ -160,6 +162,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Act RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; + RedisTransportTopology.RouteSpec stableHeartbeatRoute = heartbeatRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; RedisTransportTopology.RouteSpec stableDiscordLinkCodeRoute = discordLinkCodeRoute; @@ -180,6 +183,14 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableEventRoute.readOnly()).isTrue(); assertThat(stableEventRoute.rpcRequest()).isFalse(); + assertThat(stableHeartbeatRoute).isNotNull(); + assertThat(stableHeartbeatRoute.streamPattern()).isEqualTo("xcore:evt:server:heartbeat"); + assertThat(stableHeartbeatRoute.eventType()).isEqualTo("server.heartbeat"); + assertThat(stableHeartbeatRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stableHeartbeatRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stableHeartbeatRoute.readOnly()).isTrue(); + assertThat(stableHeartbeatRoute.rpcRequest()).isFalse(); + assertThat(stableModerationRoute).isNotNull(); assertThat(stableModerationRoute.streamPattern()).isEqualTo("xcore:evt:moderation:ban"); assertThat(stableModerationRoute.eventType()).isEqualTo("moderation.ban.created"); From 115ac4af07f60daf0d5f014403358a9dfb91fafc Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:56:56 +0300 Subject: [PATCH 11/26] feat(network): migrate chat broadcast DTOs Switch the chat message and global chat transport flow to generated xcore-protocol DTOs and canonical routes so the Phase 5 broadcast path no longer depends on local wrapper events. --- .../controller/client/SocialController.java | 7 +++-- .../xcore/plugin/event/TransportEvents.java | 4 --- .../event/net/chat/ChatMessageHandler.java | 4 +-- .../event/transport/ChatTransportHandler.java | 3 +- .../service/network/RedisRouteRegistry.java | 6 ++-- .../network/RedisTransportTopology.java | 6 ++-- .../plugin/event/NetEventServiceTest.java | 3 +- .../transport/ChatTransportHandlerTest.java | 5 ++-- .../RedisNetworkBackendIntegrationTest.java | 30 ++++++++++--------- .../network/RedisRouteRegistryTest.java | 7 +++-- .../network/RedisStreamRouterTest.java | 14 ++++++--- .../network/RedisTransportContractsTest.java | 16 ++++++++-- 12 files changed, 66 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java b/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java index 4e5ddb7..cfe2b3f 100644 --- a/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java +++ b/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java @@ -7,12 +7,13 @@ import org.incendo.cloud.annotation.specifier.Greedy; import org.incendo.cloud.annotations.Argument; import org.incendo.cloud.annotations.Command; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.cloud.annotation.PlayTimeLimit; import org.xcore.plugin.cloud.annotation.RequiresMuteCheck; import org.xcore.plugin.cloud.annotation.RequiresPlayTime; import org.xcore.plugin.command.controller.CloudClientController; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.localization.Localization; @@ -75,13 +76,13 @@ public void globalChat(XCoreSender sender, @Argument("message") @Greedy String m Session session = sessionService.get(sender.player().uuid()); if (session == null || session.data == null) return; - network.post(new TransportEvents.GlobalChatEvent( + network.post(new ChatGlobalV1( session.player.coloredName(), message, config.server )); - network.post(new TransportEvents.MessageEvent( + network.post(new ChatMessageV1( session.player.plainName(), "[" + config.server + "] " + message.replace("`", "*"), "global" diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 6660d63..c0594a7 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -16,14 +16,10 @@ public interface ServerScopedEvent { public static abstract class Response {} public static abstract class Request {} - public record MessageEvent(String authorName, String message, String server) implements ServerScopedEvent {} - public record ServerActionEvent(String message, String server) implements ServerScopedEvent {} public record PlayerJoinLeaveEvent(String playerName, String server, Boolean join) implements ServerScopedEvent {} - public record GlobalChatEvent(String authorName, String message, String server) implements ServerScopedEvent {} - public record DiscordMessageEvent(String authorName, String message, String server) implements ServerScopedEvent {} public record PrivateMessageEvent( diff --git a/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java b/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java index c57a362..35f3ac2 100644 --- a/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java +++ b/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java @@ -4,8 +4,8 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import mindustry.gen.Player; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.service.ChatFormatService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.SecurityService; @@ -50,7 +50,7 @@ public String handle(Player author, String text) { author.sendMessage(chatFormatService.formatChat(author, text), author, text); translatorService.translate(author, text); - network.post(new TransportEvents.MessageEvent(author.plainName(), text.replace("`", "*"), config.server)); + network.post(new ChatMessageV1(author.plainName(), text.replace("`", "*"), config.server)); return null; } } diff --git a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java index 1076bc8..dc59958 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java @@ -4,6 +4,7 @@ import arc.util.Strings; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PrivateMessage; @@ -34,7 +35,7 @@ public ChatTransportHandler(NetworkService network, } public void registerListeners() { - network.subscribe(TransportEvents.GlobalChatEvent.class, e -> { + network.subscribe(ChatGlobalV1.class, e -> { sessionService.broadcastFiltered("global-chat-format", args( "server", e.server(), "author", e.authorName(), diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index ac9bcbc..071b97c 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,6 +1,8 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -135,10 +137,10 @@ public List descriptors() { } private void registerDefaults() { - register(readOnly(TransportEvents.MessageEvent.class, "xcore:evt:chat:message", "chat.message", 60_000L, RedisServerResolver.broadcast())); + register(readOnly(ChatMessageV1.class, "xcore:evt:chat:message", "chat.message", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.ServerActionEvent.class, "xcore:evt:server:action", "server.action", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); + register(readOnly(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, RedisServerResolver.broadcast())); register(readOnly(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 5cd93c8..a0f88f2 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -1,6 +1,8 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -54,10 +56,10 @@ public record RouteSpec( } public static final List ROUTES = List.of( - route(TransportEvents.MessageEvent.class, "xcore:evt:chat:message", "chat.message", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ChatMessageV1.class, "xcore:evt:chat:message", "chat.message", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.ServerActionEvent.class, "xcore:evt:server:action", "server.action", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.GlobalChatEvent.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), route(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), diff --git a/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java b/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java index 5f7403f..2c90342 100644 --- a/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java +++ b/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java @@ -6,6 +6,7 @@ import mindustry.net.Packets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.net.admin.AdminRequestHandler; import org.xcore.plugin.event.net.chat.ChatMessageHandler; @@ -123,7 +124,7 @@ void chatHappyPath_formatsTranslatesAndPublishes() { verify(chatFormatService).formatChat(author, "he`llo"); verify(author).sendMessage("formatted", author, "he`llo"); verify(translatorService).translate(author, "he`llo"); - verify(network).post(new TransportEvents.MessageEvent("Tester", "he*llo", "main")); + verify(network).post(new ChatMessageV1("Tester", "he*llo", "main")); } @Test diff --git a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java index 4f3e76d..2ae3d1f 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java @@ -3,6 +3,7 @@ import arc.func.Cons; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.service.NetworkService; @@ -37,8 +38,8 @@ void globalChatEvent_isBroadcastOnlyToPlayersWithGlobalChatEnabled() { handler.registerListeners(); - listener(listeners, TransportEvents.GlobalChatEvent.class) - .get(new TransportEvents.GlobalChatEvent("player", "hello", "alpha")); + listener(listeners, ChatGlobalV1.class) + .get(new ChatGlobalV1("player", "hello", "alpha")); verify(sessionService).broadcastFiltered( org.mockito.Mockito.eq("global-chat-format"), diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index f39f1dc..099faaf 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -66,7 +68,7 @@ void sendPublishesEnvelopeToMappedStream() { requesterBackend = new RedisNetworkBackend(config); requesterBackend.connect(); - requesterBackend.send(new TransportEvents.MessageEvent("tester", "hello", "alpha")); + requesterBackend.send(new ChatMessageV1("tester", "hello", "alpha")); assertThat(requesterBackend.metricsSnapshot().getOrDefault("published_events", 0L)).isGreaterThanOrEqualTo(1L); @@ -96,25 +98,25 @@ void globalChatDeliveredAcrossServers() throws InterruptedException { CountDownLatch alphaLatch = new CountDownLatch(1); CountDownLatch betaLatch = new CountDownLatch(1); - AtomicReference alphaReceived = new AtomicReference<>(); - AtomicReference betaReceived = new AtomicReference<>(); + AtomicReference alphaReceived = new AtomicReference<>(); + AtomicReference betaReceived = new AtomicReference<>(); - Subscription alphaSubscription = serverBackend.subscribe( - TransportEvents.GlobalChatEvent.class, + Subscription alphaSubscription = serverBackend.subscribe( + ChatGlobalV1.class, event -> { alphaReceived.set(event); alphaLatch.countDown(); } ); - Subscription betaSubscription = requesterBackend.subscribe( - TransportEvents.GlobalChatEvent.class, + Subscription betaSubscription = requesterBackend.subscribe( + ChatGlobalV1.class, event -> { betaReceived.set(event); betaLatch.countDown(); } ); - serverBackend.send(new TransportEvents.GlobalChatEvent("player", "hello world", "alpha")); + serverBackend.send(new ChatGlobalV1("player", "hello world", "alpha")); assertThat(alphaLatch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(betaLatch.await(10, TimeUnit.SECONDS)).isTrue(); @@ -338,14 +340,14 @@ void subscribeConsumesReadOnlyStreamMessages() throws InterruptedException { requesterBackend.connect(); CountDownLatch latch = new CountDownLatch(1); - AtomicReference received = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); - Subscription subscription = requesterBackend.subscribe(TransportEvents.MessageEvent.class, event -> { + Subscription subscription = requesterBackend.subscribe(ChatMessageV1.class, event -> { received.set(event); latch.countDown(); }); - requesterBackend.send(new TransportEvents.MessageEvent("tester", "bridge", "alpha")); + requesterBackend.send(new ChatMessageV1("tester", "bridge", "alpha")); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(received.get()).isNotNull(); @@ -580,12 +582,12 @@ void failedConsumeRoutesMessageToDlq() throws InterruptedException { requesterBackend.connect(); CountDownLatch failureSeen = new CountDownLatch(1); - Subscription subscription = requesterBackend.subscribe(TransportEvents.MessageEvent.class, event -> { + Subscription subscription = requesterBackend.subscribe(ChatMessageV1.class, event -> { failureSeen.countDown(); throw new IllegalStateException("intentional failure"); }); - requesterBackend.send(new TransportEvents.MessageEvent("tester", "poison", "alpha")); + requesterBackend.send(new ChatMessageV1("tester", "poison", "alpha")); assertThat(failureSeen.await(10, TimeUnit.SECONDS)).isTrue(); try (RedisClient client = RedisClient.create(config.redisUrl); @@ -628,7 +630,7 @@ void unsubscribeStopsSubscriberLifecycleThreads() { requesterBackend = new RedisNetworkBackend(config); requesterBackend.connect(); - Subscription subscription = requesterBackend.subscribe(TransportEvents.MessageEvent.class, event -> { + Subscription subscription = requesterBackend.subscribe(ChatMessageV1.class, event -> { }); assertThat(requesterBackend.metricsSnapshot().getOrDefault("active_subscriber_threads", 0L)).isEqualTo(2L); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index ddf0440..de48699 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -101,7 +103,8 @@ void rpcRouteDescriptorCarriesResponseType() { @Test @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { - assertThat(registry.isReadOnlyType(TransportEvents.GlobalChatEvent.class)).isTrue(); + assertThat(registry.isReadOnlyType(ChatMessageV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ChatGlobalV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); @@ -113,6 +116,6 @@ void classificationComesFromRegistry() { assertThat(registry.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(TransportEvents.ExecuteCommand.class)).isTrue(); - assertThat(registry.isMutatingType(TransportEvents.GlobalChatEvent.class)).isFalse(); + assertThat(registry.isMutatingType(ChatGlobalV1.class)).isFalse(); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 82e8b22..af74fda 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -43,7 +45,7 @@ void routeReadOnlyEvents() { BanData banData = punishment(new BanData(), "u", "n"); MuteData muteData = punishment(new MuteData(), "u", "n"); - var messageRoute = router.route(new TransportEvents.MessageEvent("a", "b", "mini-pvp"), "mini-pvp"); + var messageRoute = router.route(new ChatMessageV1("a", "b", "mini-pvp"), "mini-pvp"); var joinRoute = router.route(new TransportEvents.PlayerJoinLeaveEvent("p", "mini-pvp", true), "mini-pvp"); var heartbeatRoute = router.route(new ServerHeartbeatV1("mini-pvp", 1, 5, 30, "1.0.0", "127.0.0.1", 6567), "mini-pvp"); var banRoute = router.route( @@ -214,7 +216,10 @@ void routeServerTargetedEvents() { @Test @DisplayName("subscribe streams include read-only and rpc request streams") void subscribeStreamsForTypes() { - assertThat(router.subscribeStreamsFor(TransportEvents.GlobalChatEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ChatMessageV1.class, "mini-pvp")) + .containsExactly("xcore:evt:chat:message"); + + assertThat(router.subscribeStreamsFor(ChatGlobalV1.class, "mini-pvp")) .containsExactly("xcore:evt:chat:global"); assertThat(router.subscribeStreamsFor(ServerHeartbeatV1.class, "mini-pvp")) @@ -285,10 +290,11 @@ void classificationAndResponseMapping() { assertThat(router.isMutatingType(DiscordLinkConfirmCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); - assertThat(router.isMutatingType(TransportEvents.MessageEvent.class)).isFalse(); + assertThat(router.isReadOnlyType(ChatGlobalV1.class)).isTrue(); + assertThat(router.isMutatingType(ChatMessageV1.class)).isFalse(); assertThat(router.isRpcRequestType(MapsListRequestV1.class)).isTrue(); - assertThat(router.isRpcRequestType(TransportEvents.MessageEvent.class)).isFalse(); + assertThat(router.isRpcRequestType(ChatMessageV1.class)).isFalse(); assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) .isEqualTo(MapsListResponseV1.class); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 7664436..b55fd7d 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -147,7 +149,8 @@ void rpcRequestAndResponseEnvelopeFieldsStayStableAndDirectionSpecific() { @DisplayName("topology locks down representative event command and rpc route metadata") void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Arrange - RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(TransportEvents.GlobalChatEvent.class); + RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(ChatGlobalV1.class); + RedisTransportTopology.RouteSpec messageRoute = RedisTransportTopology.routeFor(ChatMessageV1.class); RedisTransportTopology.RouteSpec heartbeatRoute = RedisTransportTopology.routeFor(ServerHeartbeatV1.class); RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); @@ -162,6 +165,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Act RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; + RedisTransportTopology.RouteSpec stableMessageRoute = messageRoute; RedisTransportTopology.RouteSpec stableHeartbeatRoute = heartbeatRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; @@ -183,6 +187,14 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableEventRoute.readOnly()).isTrue(); assertThat(stableEventRoute.rpcRequest()).isFalse(); + assertThat(stableMessageRoute).isNotNull(); + assertThat(stableMessageRoute.streamPattern()).isEqualTo("xcore:evt:chat:message"); + assertThat(stableMessageRoute.eventType()).isEqualTo("chat.message"); + assertThat(stableMessageRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stableMessageRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stableMessageRoute.readOnly()).isTrue(); + assertThat(stableMessageRoute.rpcRequest()).isFalse(); + assertThat(stableHeartbeatRoute).isNotNull(); assertThat(stableHeartbeatRoute.streamPattern()).isEqualTo("xcore:evt:server:heartbeat"); assertThat(stableHeartbeatRoute.eventType()).isEqualTo("server.heartbeat"); @@ -297,6 +309,6 @@ void registryAndRouterRemainAlignedWithExplicitTransportTopology() { assertThat(rpcSubscriptions).containsExactly("xcore:rpc:req:mini-pvp"); assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) .isEqualTo(MapsListResponseV1.class); - assertThat(router.responseTypeForRequest(TransportEvents.MessageEvent.class)).isNull(); + assertThat(router.responseTypeForRequest(ChatMessageV1.class)).isNull(); } } From bae65ceb86dccd219884174be7c2dc3f1ed7d97a Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:21:29 +0300 Subject: [PATCH 12/26] feat(network): migrate discord ingress DTOs Switch the Discord ingress transport flow to the generated protocol command and canonical route metadata so the Phase 5 chat ingress path no longer depends on a local wrapper event. --- .../org/xcore/plugin/event/TransportEvents.java | 2 -- .../event/transport/ChatTransportHandler.java | 3 ++- .../service/network/RedisRouteRegistry.java | 6 +++++- .../service/network/RedisTransportTopology.java | 3 ++- .../transport/ChatTransportHandlerTest.java | 6 +++--- .../service/network/RedisRouteRegistryTest.java | 16 ++++++++++++++++ .../service/network/RedisStreamRouterTest.java | 8 +++++++- .../network/RedisTransportContractsTest.java | 11 +++++++++++ 8 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index c0594a7..9835856 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -20,8 +20,6 @@ public record ServerActionEvent(String message, String server) implements Server public record PlayerJoinLeaveEvent(String playerName, String server, Boolean join) implements ServerScopedEvent {} - public record DiscordMessageEvent(String authorName, String message, String server) implements ServerScopedEvent {} - public record PrivateMessageEvent( String fromUuid, int fromPid, diff --git a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java index dc59958..89e4a14 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java @@ -4,6 +4,7 @@ import arc.util.Strings; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; @@ -44,7 +45,7 @@ public void registerListeners() { Log.infoTag("GLOBAL-" + e.server(), Strings.stripColors(e.authorName()) + ": " + e.message()); }); - network.subscribe(TransportEvents.DiscordMessageEvent.class, e -> { + network.subscribe(ChatDiscordIngressCommandV1.class, e -> { if (!config.server.equals(e.server())) { return; } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 071b97c..047d23d 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,6 +1,7 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -142,7 +143,7 @@ private void registerDefaults() { register(readOnly(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, PAYLOAD_SERVER_RESOLVER)); + register(readOnly(ChatDiscordIngressCommandV1.class, "xcore:cmd:discord-message:{server}", "chat.discord-ingress.command", 60_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationMuteCreatedV1.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, RedisServerResolver.broadcast())); @@ -205,6 +206,9 @@ private static String discordServer(Object payload) { if (payload instanceof DiscordAdminAccessChangedCommandV1 command) { return command.server(); } + if (payload instanceof ChatDiscordIngressCommandV1 command) { + return command.server(); + } return null; } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index a0f88f2..7fd48b4 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -1,6 +1,7 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -61,7 +62,7 @@ public record RouteSpec( route(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.DiscordMessageEvent.class, "xcore:cmd:discord-message:{server}", "chat.discord_ingress", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), + route(ChatDiscordIngressCommandV1.class, "xcore:cmd:discord-message:{server}", "chat.discord-ingress.command", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), route(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationMuteCreatedV1.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), diff --git a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java index 2ae3d1f..74ee414 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java @@ -3,9 +3,9 @@ import arc.func.Cons; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PrivateMessageService; import org.xcore.plugin.service.network.RedisNetworkBackend; @@ -68,8 +68,8 @@ void discordRelayEvent_isIgnoredForOtherServers() { handler.registerListeners(); - listener(listeners, TransportEvents.DiscordMessageEvent.class) - .get(new TransportEvents.DiscordMessageEvent("bot", "hello", "other-server")); + listener(listeners, ChatDiscordIngressCommandV1.class) + .get(new ChatDiscordIngressCommandV1("bot", "hello", "other-server")); verifyNoInteractions(sessionService); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index de48699..fc98e7d 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -49,6 +50,20 @@ void payloadServerResolverUsesTypedContract() { assertThat(stream).isEqualTo("xcore:cmd:discord-link-confirm:survival"); } + @Test + @DisplayName("discord ingress command uses typed payload server contract") + void discordIngressCommandUsesTypedPayloadServerContract() { + RedisRouteDescriptor descriptor = registry.routeDescriptorFor(ChatDiscordIngressCommandV1.class); + + String stream = registry.resolveStreamKey( + descriptor, + new ChatDiscordIngressCommandV1("bot", "hello", "survival"), + "mini-pvp" + ); + + assertThat(stream).isEqualTo("xcore:cmd:discord-message:survival"); + } + @Test @DisplayName("unlink command uses typed payload server contract") void unlinkCommandUsesTypedPayloadServerContract() { @@ -105,6 +120,7 @@ void rpcRouteDescriptorCarriesResponseType() { void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(ChatMessageV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ChatGlobalV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ChatDiscordIngressCommandV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index af74fda..e1202b9 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -131,7 +132,7 @@ void routeReadOnlyEvents() { @Test @DisplayName("route maps server-targeted events using event payload server") void routeServerTargetedEvents() { - var discordRoute = router.route(new TransportEvents.DiscordMessageEvent("bot", "hello", "mini-hexed"), "mini-pvp"); + var discordRoute = router.route(new ChatDiscordIngressCommandV1("bot", "hello", "mini-hexed"), "mini-pvp"); var mapsRoute = router.route(new TransportEvents.LoadMapsV2(new TransportEvents.FileURL[0], "event"), "mini-pvp"); var badgeRoute = router.route(new TransportEvents.PlayerBadgeInventoryChanged("uuid-7", "translator", java.util.Set.of("translator")), "mini-pvp"); var badgeColorModeRoute = router.route(new TransportEvents.PlayerBadgeSymbolColorModeChanged("uuid-7", "player-color"), "mini-pvp"); @@ -181,6 +182,7 @@ void routeServerTargetedEvents() { ); assertThat(discordRoute.streamKey()).isEqualTo("xcore:cmd:discord-message:mini-hexed"); + assertThat(discordRoute.eventType()).isEqualTo("chat.discord-ingress.command"); assertThat(mapsRoute.streamKey()).isEqualTo("xcore:cmd:maps-load:event"); assertThat(badgeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-inventory:mini-pvp"); assertThat(badgeRoute.eventType()).isEqualTo("player.badge_inventory"); @@ -222,6 +224,9 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(ChatGlobalV1.class, "mini-pvp")) .containsExactly("xcore:evt:chat:global"); + assertThat(router.subscribeStreamsFor(ChatDiscordIngressCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:discord-message:mini-pvp"); + assertThat(router.subscribeStreamsFor(ServerHeartbeatV1.class, "mini-pvp")) .containsExactly("xcore:evt:server:heartbeat"); @@ -275,6 +280,7 @@ void subscribeStreamsForTypes() { @DisplayName("type classification and rpc response mapping are correct") void classificationAndResponseMapping() { assertThat(router.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ChatDiscordIngressCommandV1.class)).isTrue(); assertThat(router.isReadOnlyType(DiscordLinkCodeCreatedV1.class)).isTrue(); assertThat(router.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index b55fd7d..778bc14 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -151,6 +152,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Arrange RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(ChatGlobalV1.class); RedisTransportTopology.RouteSpec messageRoute = RedisTransportTopology.routeFor(ChatMessageV1.class); + RedisTransportTopology.RouteSpec discordIngressRoute = RedisTransportTopology.routeFor(ChatDiscordIngressCommandV1.class); RedisTransportTopology.RouteSpec heartbeatRoute = RedisTransportTopology.routeFor(ServerHeartbeatV1.class); RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); @@ -166,6 +168,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { // Act RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; RedisTransportTopology.RouteSpec stableMessageRoute = messageRoute; + RedisTransportTopology.RouteSpec stableDiscordIngressRoute = discordIngressRoute; RedisTransportTopology.RouteSpec stableHeartbeatRoute = heartbeatRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; @@ -195,6 +198,14 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableMessageRoute.readOnly()).isTrue(); assertThat(stableMessageRoute.rpcRequest()).isFalse(); + assertThat(stableDiscordIngressRoute).isNotNull(); + assertThat(stableDiscordIngressRoute.streamPattern()).isEqualTo("xcore:cmd:discord-message:{server}"); + assertThat(stableDiscordIngressRoute.eventType()).isEqualTo("chat.discord-ingress.command"); + assertThat(stableDiscordIngressRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); + assertThat(stableDiscordIngressRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableDiscordIngressRoute.readOnly()).isTrue(); + assertThat(stableDiscordIngressRoute.rpcRequest()).isFalse(); + assertThat(stableHeartbeatRoute).isNotNull(); assertThat(stableHeartbeatRoute.streamPattern()).isEqualTo("xcore:evt:server:heartbeat"); assertThat(stableHeartbeatRoute.eventType()).isEqualTo("server.heartbeat"); From dbd05e6b6ab401df4f201eb9a0e2b1d701eb275e Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:58:24 +0300 Subject: [PATCH 13/26] feat(network): migrate private message DTOs Consume xcore-protocol 0.3.0 and switch the cross-server private-message relay to the canonical chat.private contract so the Phase 5 chat flow no longer depends on a local wrapper event. --- gradle/libs.versions.toml | 2 +- .../xcore/plugin/event/TransportEvents.java | 10 ---- .../event/transport/ChatTransportHandler.java | 4 +- .../plugin/service/PrivateMessageService.java | 4 +- .../service/network/RedisRouteRegistry.java | 3 +- .../network/RedisTransportTopology.java | 3 +- .../transport/ChatTransportHandlerTest.java | 58 +++++++++++++++++++ .../service/PrivateMessageServiceTest.java | 4 +- .../network/RedisRouteRegistryTest.java | 2 + .../network/RedisStreamRouterTest.java | 9 +++ .../network/RedisTransportContractsTest.java | 11 ++++ 11 files changed, 91 insertions(+), 19 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5420af1..ab7a6bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] mindustry = "157" -xcore-protocol = "0.2.0" +xcore-protocol = "0.3.0" # Plugins toxopid = "4.1.2" diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 9835856..7d8c0cd 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -20,16 +20,6 @@ public record ServerActionEvent(String message, String server) implements Server public record PlayerJoinLeaveEvent(String playerName, String server, Boolean join) implements ServerScopedEvent {} - public record PrivateMessageEvent( - String fromUuid, - int fromPid, - String fromName, - String toUuid, - int toPid, - String message, - String server - ) implements ServerScopedEvent {} - public record KickBannedPlayer(String uuid, String ip) {} public record PlayerCustomNicknameChanged(String uuid, String customNickname) {} diff --git a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java index 89e4a14..850660c 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java @@ -6,8 +6,8 @@ import jakarta.inject.Singleton; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PrivateMessage; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PrivateMessageService; @@ -57,7 +57,7 @@ public void registerListeners() { Log.infoTag("DISCORD-" + e.server(), Strings.stripColors(e.authorName()) + ": " + e.message()); }); - network.subscribe(TransportEvents.PrivateMessageEvent.class, e -> { + network.subscribe(ChatPrivateV1.class, e -> { if (config.server.equals(e.server())) { return; } diff --git a/src/main/java/org/xcore/plugin/service/PrivateMessageService.java b/src/main/java/org/xcore/plugin/service/PrivateMessageService.java index 969b464..a784ec1 100644 --- a/src/main/java/org/xcore/plugin/service/PrivateMessageService.java +++ b/src/main/java/org/xcore/plugin/service/PrivateMessageService.java @@ -4,10 +4,10 @@ import jakarta.inject.Singleton; import arc.util.Strings; import org.bson.types.ObjectId; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.database.repository.PrivateMessageRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.model.PrivateMessage; import org.xcore.plugin.session.Session; @@ -332,7 +332,7 @@ private void deliverOrDispatch(PrivateMessage privateMessage, int senderPid) { return; } - networkService.post(new TransportEvents.PrivateMessageEvent( + networkService.post(new ChatPrivateV1( privateMessage.fromUuid, privateMessage.fromPid, privateMessage.fromName, diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 047d23d..263c6f2 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -4,6 +4,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -144,7 +145,7 @@ private void registerDefaults() { register(readOnly(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ChatDiscordIngressCommandV1.class, "xcore:cmd:discord-message:{server}", "chat.discord-ingress.command", 60_000L, PAYLOAD_SERVER_RESOLVER)); - register(readOnly(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); + register(readOnly(ChatPrivateV1.class, "xcore:evt:chat:private", "chat.private", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationMuteCreatedV1.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationVoteKickCreatedV1.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, RedisServerResolver.broadcast())); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 7fd48b4..f6c6e02 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -4,6 +4,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -63,7 +64,7 @@ public record RouteSpec( route(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ChatDiscordIngressCommandV1.class, "xcore:cmd:discord-message:{server}", "chat.discord-ingress.command", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), - route(TransportEvents.PrivateMessageEvent.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ChatPrivateV1.class, "xcore:evt:chat:private", "chat.private", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationBanCreatedV1.class, "xcore:evt:moderation:ban", "moderation.ban.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationMuteCreatedV1.class, "xcore:evt:moderation:mute", "moderation.mute.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationVoteKickCreatedV1.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), diff --git a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java index 74ee414..79c8878 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java @@ -5,7 +5,10 @@ import org.junit.jupiter.api.Test; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.plugin.config.Config; +import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.session.Session; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PrivateMessageService; import org.xcore.plugin.service.network.RedisNetworkBackend; @@ -15,10 +18,14 @@ import java.util.Map; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; class ChatTransportHandlerTest { @@ -74,6 +81,57 @@ void discordRelayEvent_isIgnoredForOtherServers() { verifyNoInteractions(sessionService); } + @Test + @DisplayName("private chat event is delivered only for remote servers") + void privateChatEvent_isDeliveredOnlyForRemoteServers() { + NetworkService network = mock(NetworkService.class); + SessionService sessionService = mock(SessionService.class); + PrivateMessageService privateMessageService = mock(PrivateMessageService.class); + Config config = new Config(); + config.server = "mini-pvp"; + + ChatTransportHandler handler = new ChatTransportHandler(network, sessionService, privateMessageService, config); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + Session recipient = mock(Session.class); + recipient.player = mock(mindustry.gen.Player.class); + recipient.data = PlayerData.builder().uuid("uuid-to").pid(42).nickname("Target").build(); + when(sessionService.get("uuid-to")).thenReturn(recipient); + + listener(listeners, ChatPrivateV1.class) + .get(new ChatPrivateV1("uuid-from", 7, "Sender", "uuid-to", 42, "hello", "survival")); + + verify(privateMessageService).deliverIncoming(any(), same(recipient)); + verify(sessionService).get("uuid-to"); + } + + @Test + @DisplayName("private chat event is ignored for same server") + void privateChatEvent_isIgnoredForSameServer() { + NetworkService network = mock(NetworkService.class); + SessionService sessionService = mock(SessionService.class); + PrivateMessageService privateMessageService = mock(PrivateMessageService.class); + Config config = new Config(); + config.server = "mini-pvp"; + + ChatTransportHandler handler = new ChatTransportHandler(network, sessionService, privateMessageService, config); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + listener(listeners, ChatPrivateV1.class) + .get(new ChatPrivateV1("uuid-from", 7, "Sender", "uuid-to", 42, "hello", "mini-pvp")); + + verify(sessionService, never()).get(anyString()); + verifyNoInteractions(privateMessageService); + } + private static void captureListeners(NetworkService network, Map, Cons> listeners) { doAnswer(invocation -> { listeners.put(invocation.getArgument(0), invocation.getArgument(1)); diff --git a/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java b/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java index 41566f2..b0a63a6 100644 --- a/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java @@ -8,10 +8,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.PrivateMessageRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.model.PrivateMessage; @@ -78,7 +78,7 @@ void send_savesAndUpdatesReplyState_forValidPid() { assertThat(sender.lastPrivateMessageAt).isGreaterThan(0L); verify(privateMessageRepository).save(any(PrivateMessage.class)); verify(sender.locale()).send(eq("private-message-sent"), anyMap()); - verify(networkService).post(any(TransportEvents.PrivateMessageEvent.class)); + verify(networkService).post(any(ChatPrivateV1.class)); } @Test diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index fc98e7d..9bb8628 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -5,6 +5,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -121,6 +122,7 @@ void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(ChatMessageV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ChatGlobalV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ChatDiscordIngressCommandV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ChatPrivateV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index e1202b9..3c30143 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -5,6 +5,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -47,6 +48,7 @@ void routeReadOnlyEvents() { MuteData muteData = punishment(new MuteData(), "u", "n"); var messageRoute = router.route(new ChatMessageV1("a", "b", "mini-pvp"), "mini-pvp"); + var privateRoute = router.route(new ChatPrivateV1("uuid-from", 7, "Sender", "uuid-to", 42, "hello", "survival"), "mini-pvp"); var joinRoute = router.route(new TransportEvents.PlayerJoinLeaveEvent("p", "mini-pvp", true), "mini-pvp"); var heartbeatRoute = router.route(new ServerHeartbeatV1("mini-pvp", 1, 5, 30, "1.0.0", "127.0.0.1", 6567), "mini-pvp"); var banRoute = router.route( @@ -107,6 +109,9 @@ void routeReadOnlyEvents() { assertThat(messageRoute.streamKey()).isEqualTo("xcore:evt:chat:message"); assertThat(messageRoute.eventType()).isEqualTo("chat.message"); + assertThat(privateRoute.streamKey()).isEqualTo("xcore:evt:chat:private"); + assertThat(privateRoute.eventType()).isEqualTo("chat.private"); + assertThat(joinRoute.streamKey()).isEqualTo("xcore:evt:player:joinleave"); assertThat(joinRoute.eventType()).isEqualTo("player.join_leave"); @@ -221,6 +226,9 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(ChatMessageV1.class, "mini-pvp")) .containsExactly("xcore:evt:chat:message"); + assertThat(router.subscribeStreamsFor(ChatPrivateV1.class, "mini-pvp")) + .containsExactly("xcore:evt:chat:private"); + assertThat(router.subscribeStreamsFor(ChatGlobalV1.class, "mini-pvp")) .containsExactly("xcore:evt:chat:global"); @@ -281,6 +289,7 @@ void subscribeStreamsForTypes() { void classificationAndResponseMapping() { assertThat(router.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(router.isReadOnlyType(ChatDiscordIngressCommandV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ChatPrivateV1.class)).isTrue(); assertThat(router.isReadOnlyType(DiscordLinkCodeCreatedV1.class)).isTrue(); assertThat(router.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(router.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 778bc14..1d08e00 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -5,6 +5,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -153,6 +154,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec eventRoute = RedisTransportTopology.routeFor(ChatGlobalV1.class); RedisTransportTopology.RouteSpec messageRoute = RedisTransportTopology.routeFor(ChatMessageV1.class); RedisTransportTopology.RouteSpec discordIngressRoute = RedisTransportTopology.routeFor(ChatDiscordIngressCommandV1.class); + RedisTransportTopology.RouteSpec privateRoute = RedisTransportTopology.routeFor(ChatPrivateV1.class); RedisTransportTopology.RouteSpec heartbeatRoute = RedisTransportTopology.routeFor(ServerHeartbeatV1.class); RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); @@ -169,6 +171,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; RedisTransportTopology.RouteSpec stableMessageRoute = messageRoute; RedisTransportTopology.RouteSpec stableDiscordIngressRoute = discordIngressRoute; + RedisTransportTopology.RouteSpec stablePrivateRoute = privateRoute; RedisTransportTopology.RouteSpec stableHeartbeatRoute = heartbeatRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; @@ -206,6 +209,14 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableDiscordIngressRoute.readOnly()).isTrue(); assertThat(stableDiscordIngressRoute.rpcRequest()).isFalse(); + assertThat(stablePrivateRoute).isNotNull(); + assertThat(stablePrivateRoute.streamPattern()).isEqualTo("xcore:evt:chat:private"); + assertThat(stablePrivateRoute.eventType()).isEqualTo("chat.private"); + assertThat(stablePrivateRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stablePrivateRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stablePrivateRoute.readOnly()).isTrue(); + assertThat(stablePrivateRoute.rpcRequest()).isFalse(); + assertThat(stableHeartbeatRoute).isNotNull(); assertThat(stableHeartbeatRoute.streamPattern()).isEqualTo("xcore:evt:server:heartbeat"); assertThat(stableHeartbeatRoute.eventType()).isEqualTo("server.heartbeat"); From 313c658d500de57a367987f6e5391a83237cd371 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:15:14 +0300 Subject: [PATCH 14/26] feat(network): finish phase5 transport DTO cutover Move server action and player join/leave traffic onto generated protocol DTOs and remove the raw fallback route so Redis transport only emits canonical metadata. This completes the remaining Phase 5 tail work and adds regression coverage for unsupported payload rejection. --- docs/adr/ADR-redis-to-protocol-first.md | 119 +++++++++++ .../xcore-protocol-message-model.md | 169 ++++++++++++++++ .../xcore-protocol-repository-blueprint.md | 185 ++++++++++++++++++ .../xcore-protocol-target-architecture.md | 112 +++++++++++ .../xcore-protocol-agent-playbook.md | 141 +++++++++++++ .../xcore-protocol-migration-plan.md | 159 +++++++++++++++ .../xcore/plugin/event/TransportEvents.java | 4 - .../xcore/plugin/event/TransportService.java | 3 +- .../event/handler/ConnectionHandler.java | 6 +- .../event/handler/GameLifecycleHandler.java | 4 +- .../gamemode/hexed/MiniHexedService.java | 4 +- .../service/network/RedisRouteRegistry.java | 23 +-- .../service/network/RedisStreamRouter.java | 16 +- .../network/RedisTransportTopology.java | 6 +- .../java/org/xcore/plugin/vote/VoteKick.java | 5 +- .../event/handler/ConnectionHandlerTest.java | 4 +- .../RedisNetworkBackendIntegrationTest.java | 22 +++ .../network/RedisRouteRegistryTest.java | 11 ++ .../network/RedisStreamRouterTest.java | 25 ++- .../network/RedisTransportContractsTest.java | 22 +++ 20 files changed, 993 insertions(+), 47 deletions(-) create mode 100644 docs/adr/ADR-redis-to-protocol-first.md create mode 100644 docs/architecture/xcore-protocol-message-model.md create mode 100644 docs/architecture/xcore-protocol-repository-blueprint.md create mode 100644 docs/architecture/xcore-protocol-target-architecture.md create mode 100644 docs/implementation/xcore-protocol-agent-playbook.md create mode 100644 docs/migrations/xcore-protocol-migration-plan.md diff --git a/docs/adr/ADR-redis-to-protocol-first.md b/docs/adr/ADR-redis-to-protocol-first.md new file mode 100644 index 0000000..410511b --- /dev/null +++ b/docs/adr/ADR-redis-to-protocol-first.md @@ -0,0 +1,119 @@ +# ADR: Move XCore transport contracts to a protocol-first model + +## Status +Proposed + +## Context +`XCore-plugin` and `XCore-discord-bot` currently share Redis-based message contracts for events, commands, and RPC-style request/response flows. Those contracts work in practice, but the protocol surface is split across multiple implementation-specific locations: + +- Java transport route metadata and envelope construction in `XCore-plugin` +- Java transport event/request/response records in `TransportEvents` +- Python pydantic contract models and Redis bus logic in `XCore-discord-bot` +- Compatibility behavior encoded through aliases, legacy event names, and runtime fallback logic + +This creates several problems: + +1. The protocol exists as an accidental agreement between implementations instead of an explicit source of truth. +2. Cross-language compatibility depends on tolerant readers, duplicate field names, and historical knowledge. +3. Internal domain/storage models can leak into the wire format. +4. Route metadata and transport semantics are hard to evolve safely across repos. +5. Future consumers would have to reverse-engineer the protocol from application code. + +## Decision +Adopt a **protocol-first** model and define a future shared repository named **`xcore-protocol`** as the canonical source of truth for XCore cross-process communication. + +`xcore-protocol` will own: + +- wire-level message schemas +- envelope definitions +- route/stream/RPC metadata +- message versioning and compatibility policy +- canonical fixtures/examples +- generated Java and Python protocol DTO/model artifacts +- thin handwritten validation/runtime support around generated artifacts +- cross-language compatibility tests + +The first implementation step is **documentation-first** inside `XCore-plugin`, followed by a phased migration into the future `xcore-protocol` repository. + +## Why `xcore-protocol` +`xcore-protocol` was chosen over names like `xcore-transport` or `xcore-contracts` because it best reflects the intended boundary: + +- broader than raw schema files alone +- not permanently tied to Redis internals +- centered on the official language of communication between XCore components + +## Scope Boundaries +`xcore-protocol` is intended to contain only **cross-process / cross-service wire protocol artifacts**. + +It should include: + +- event, command, and RPC message definitions +- envelope metadata definitions +- protocol validation helpers and fixtures +- route metadata and compatibility policy +- generated Java/Python DTOs and models derived from protocol specs +- thin Java/Python support libraries for parsing/building/validation around generated artifacts + +It should not include: + +- application business logic +- Discord UX or handlers +- Mongo repositories +- Mindustry runtime integration +- reconnect loops or app-specific worker orchestration +- general shared helper dumping grounds + +## Contract Strategy +The immediate protocol redesign will: + +1. Normalize canonical field names, time formats, and versioning rules. +2. Keep semantically distinct business messages separate. +3. Extract shared payload subtypes rather than merging unrelated messages by shape. +4. Move legacy aliases and historical event-name compatibility into dedicated compatibility adapters. +5. Stop treating internal application models as the public wire contract. +6. Generate Java and Python protocol model layers from canonical specs instead of maintaining duplicate hand-written wire DTOs in consumer repos. + +## Rollout Strategy +Migration will start with the **moderation** contract family because it already crosses repository boundaries and shows the clearest compatibility pain. + +Phases: + +1. Documentation and target-state design in `XCore-plugin` +2. Bootstrap `xcore-protocol` +3. Migrate moderation contracts first +4. Migrate Discord linking/admin contracts +5. Migrate maps RPC contracts +6. Migrate chat/heartbeat/misc flows and clean legacy handling + +## Consequences + +### Positive +- One source of truth for cross-language protocol behavior +- Safer contract evolution with explicit review and compatibility checks +- Cleaner boundaries between domain models and wire models +- Better onboarding path for future consumers and future agents +- Clear governance for breaking vs additive changes +- Less DTO drift between Java and Python consumers through generated protocol artifacts + +### Costs +- Requires initial design effort and documentation discipline +- Introduces a new repository and release process +- Needs explicit ownership and change governance +- Requires migration adapters during the transition period + +## Alternatives Considered + +### 1. Keep protocol ownership in `XCore-plugin` +Rejected as the target state because it keeps Python and future consumers secondary to a Java implementation repo. + +### 2. Create a schema-only repository +Improves the current state but still leaves Java/Python wire DTOs and model layers duplicated and easier to drift. + +### 3. Move the full ecosystem into a single monorepo +Rejected because the problem boundary is the shared protocol surface, not the full application estate. A full monorepo would impose much higher coordination cost than necessary. + +## Acceptance Criteria For This Decision +- Documentation clearly defines target-state protocol ownership and boundaries. +- Documentation clearly defines generated protocol DTO/model ownership and consumer dependency direction. +- Future implementation work can proceed without re-deciding repo naming, scope, or migration direction. +- Moderation-first migration remains the agreed first rollout slice. diff --git a/docs/architecture/xcore-protocol-message-model.md b/docs/architecture/xcore-protocol-message-model.md new file mode 100644 index 0000000..7294cd8 --- /dev/null +++ b/docs/architecture/xcore-protocol-message-model.md @@ -0,0 +1,169 @@ +# XCore Protocol Message Model + +## Goal +Define the canonical message model for the future `xcore-protocol` repository, including envelope rules, payload conventions, naming, versioning, and compatibility handling. + +## Core Principle +The public wire contract must not be an accidental serialization of internal application models. Protocol messages are explicit transport DTOs with stable meaning, and Java/Python protocol DTO/model layers should be generated from the canonical protocol definitions rather than hand-maintained independently in consumer repos. + +## Message Categories + +### Event +- broadcast or fan-out notification +- producer does not wait for a response +- usually replayable for observers depending on stream retention + +### Command +- targeted instruction toward a specific server or logical target +- producer does not wait for a response +- typically non-replayable as a business action + +### RPC Request / Response +- request expects a response +- request and response are linked by correlation metadata +- timeouts and error semantics are part of the protocol contract + +## Envelope Model + +### Long-term target +The protocol should converge on a unified envelope model with explicit metadata: + +- `message_kind` +- `message_type` +- `message_version` +- `message_id` +- `correlation_id` (when needed) +- `causation_id` (recommended when derived from another message) +- `producer` +- `target` (for targeted messages) +- `created_at` +- `expires_at` (when relevant) +- `schema_ref` +- `content_type` +- `payload_json` + +### Transition note +The current Redis field layout in `XCore-plugin` and `XCore-discord-bot` can be preserved through a migration window, but the target model must be documented explicitly now. + +## Naming Rules + +### Canonical policy +- Envelope fields use **snake_case**. +- Payload fields use **camelCase**. + +### Examples +- Envelope: `message_type`, `created_at`, `correlation_id` +- Payload: `playerUuid`, `adminDiscordId`, `occurredAt` + +### Non-goal +The protocol must not treat multiple spellings of the same canonical field as equal in the schema. Legacy spellings are compatibility concerns, not canonical contract design. + +## Time Rules + +### Envelope metadata +Use epoch milliseconds for transport metadata: +- `created_at` +- `expires_at` +- `responded_at` + +### Payload business timestamps +Use ISO-8601 UTC for business timestamps unless the message family has a strong reason not to. + +### Rationale +This keeps transport metadata simple for timeouts/retention and keeps business timestamps readable and consistent across languages. + +## Message Identity And Versioning + +### Canonical identity +Every message must have: +- `messageType` +- `messageVersion` + +Recommended examples: +- `moderation.ban.created` / version `1` +- `discord.link.confirm.command` / version `1` +- `maps.list.request` / version `1` + +### Rule +Breaking changes require a new message version. Do not change meaning in place. + +## Generated Model Strategy + +### Source of truth +Canonical schemas, shared subtypes, envelope definitions, and route manifests are the authored source of truth. + +### Generated outputs +`xcore-protocol` should generate Java and Python protocol DTO/model artifacts from those canonical definitions. + +### Consumer rule +Application repos should depend on generated protocol artifacts and keep only thin mapping/adaptation layers between internal models and wire models. + +### Non-goal +Do not generate runtime worker loops, Redis connection management, or business orchestration from protocol definitions. + +## Shared Payload Subtypes +To improve consistency without merging unrelated business messages, define reusable subtypes: + +- `ActorRef` +- `PlayerRef` +- `ServerRef` +- `DiscordIdentityRef` +- `ExpirationInfo` +- `MapRef` +- `AuditContext` + +These subtypes should be reused across schemas where they model the same concept. + +## Contract Strategy + +### Normalize now +- canonical field names +- canonical time formats +- message identity/versioning +- canonical route metadata + +### Keep separate +Semantically distinct business messages should remain distinct even if they share many fields. + +Examples that should remain separate: +- ban vs mute +- command vs event around Discord linking +- maps list vs maps remove RPC + +### Use compatibility adapters +Legacy event names, duplicate spellings, and historical payload forms should move into explicit compatibility adapters. + +## Compatibility Rules + +### Canonical outbound rule +All new producers publish only the canonical schema form. + +### Tolerant inbound rule +Consumers may temporarily accept historical forms through dedicated compatibility logic, but canonical parsing must remain strict. + +### Legacy sunset rule +Compatibility shims must be documented with a deprecation window and test coverage. + +## Route Manifest Philosophy +Each message definition should include or link to route metadata describing: + +- stream/channel pattern +- message kind +- target scope +- TTL policy +- idempotency expectations +- replay expectations +- DLQ policy +- owner + +The route manifest becomes the single source of truth for subscription/publish semantics and should feed generated route/metadata bindings exposed by the protocol repository. + +## Immediate Families To Model First +- moderation +- Discord linking/admin changes +- maps RPC +- chat/heartbeat after the initial migration wave + +## Success Criteria +- A future agent can implement or generate transport DTO/model layers without deciding naming, timing, or versioning policy on the fly. +- The model is strict enough to remove accidental drift but flexible enough to support compatibility adapters during migration. diff --git a/docs/architecture/xcore-protocol-repository-blueprint.md b/docs/architecture/xcore-protocol-repository-blueprint.md new file mode 100644 index 0000000..04c986f --- /dev/null +++ b/docs/architecture/xcore-protocol-repository-blueprint.md @@ -0,0 +1,185 @@ +# XCore Protocol Repository Blueprint + +## Goal +Describe the structure, module boundaries, testing model, and release approach for the future `xcore-protocol` repository. + +## Repository Mission +`xcore-protocol` is the canonical source of truth for XCore cross-service communication artifacts. + +It contains: +- protocol specs +- fixtures +- compatibility policy +- generation inputs and tooling +- generated Java and Python protocol DTO/model support +- cross-language compatibility checks + +It does not contain application business logic. + +## Proposed Repository Structure + +```text +xcore-protocol/ + README.md + docs/ + adr/ + architecture/ + policies/ + migrations/ + spec/ + asyncapi/ + envelopes/ + messages/ + shared/ + routes/ + fixtures/ + valid/ + invalid/ + legacy/ + generators/ + java/ + python/ + java/ + core/ + validation/ + jackson/ + testkit/ + python/ + xcore_protocol/ + tests/ + compat/ + java-python/ + scripts/ +``` + +## Spec Layer + +### Responsibilities +- AsyncAPI/channel overview +- JSON Schema message and envelope definitions +- route manifest files +- shared subtypes +- generator inputs for language bindings + +### Requirements +- one canonical schema per message type/version +- no duplicate canonical field naming +- explicit message family ownership + +## Fixture Layer + +### Valid fixtures +Golden examples that every SDK must parse and preserve. + +### Invalid fixtures +Examples that must fail strict canonical validation. + +### Legacy fixtures +Historical shapes accepted only through compatibility adapters during the migration window. + +## Java Modules + +### `java/core` +- generated DTOs/models and metadata constants +- route descriptors +- schema references + +### `java/validation` +- canonical validation against protocol definitions +- human-readable validation errors + +### `java/jackson` +- serialization/deserialization helpers +- protocol-specific mapper configuration + +### `java/testkit` +- fixture loaders +- golden-file assertions +- roundtrip helpers + +## Python Modules + +### `python/xcore_protocol` +- generated protocol models +- validation helpers +- envelope builders/parsers +- metadata constants +- fixture loading helpers + +### `python/tests` +- schema validation checks +- fixture compatibility checks +- roundtrip tests + +## What Stays Outside Shared SDKs +The following remain application-specific and should stay in consumer repos: + +- reconnect and worker loop orchestration +- Redis connection lifecycle management +- app-specific failure recovery and backoff policy +- presentation logic +- business orchestration + +## Generation Layer + +### Inputs +- canonical message schemas +- shared subtype schemas +- envelope definitions +- route manifests + +### Outputs +- generated Java DTO/model artifacts +- generated Python model artifacts +- metadata constants and route bindings + +### Handwritten support +Thin handwritten code may wrap generated artifacts for validation, serialization setup, and fixture/test helpers, but consumer repos should not redefine the owned wire DTO layer. + +## Compatibility Layer +The repository should support cross-language tests that prove: + +- Java can parse canonical fixtures used by Python +- Python can parse canonical fixtures used by Java +- Java-serialized canonical payloads validate in Python +- Python-serialized canonical payloads validate in Java + +## CI Requirements + +### Spec validation +- JSON Schema validity +- AsyncAPI validity +- route manifest consistency + +### Fixture validation +- valid fixtures pass +- invalid fixtures fail +- legacy fixtures only pass through explicit compatibility tests + +### SDK validation +- Java tests +- Python tests +- Java/Python roundtrip compatibility tests + +## Versioning Model + +### Repository versioning +Use semantic versioning: +- major = breaking protocol changes +- minor = additive protocol changes +- patch = non-breaking fixes/docs/test updates + +### Message versioning +Keep message versions independent from repository version. + +## Governance Expectations +- protocol owners approve message, routing, and compatibility changes +- domain owners approve business meaning within their family +- no contract change is complete without schema, fixtures, and tests + +## Adoption Model +`XCore-plugin` and `XCore-discord-bot` consume released versions of generated Java/Python protocol artifacts and use mapping layers to translate between internal models and generated protocol DTOs/models. + +## Success Criteria +- A future agent can bootstrap the repository structure without inventing module boundaries. +- Shared generated SDK/model scope is clear enough to avoid turning `xcore-protocol` into a generic shared-code dump. diff --git a/docs/architecture/xcore-protocol-target-architecture.md b/docs/architecture/xcore-protocol-target-architecture.md new file mode 100644 index 0000000..663d8c3 --- /dev/null +++ b/docs/architecture/xcore-protocol-target-architecture.md @@ -0,0 +1,112 @@ +# XCore Protocol Target Architecture + +## Goal +Define the target-state architecture for XCore cross-service communication so that protocol behavior is explicit, language-neutral, and implementation-ready. + +## Problem Summary +The current Redis contract surface is spread across application code in `XCore-plugin` and `XCore-discord-bot`. Even with recent transport cleanup in `XCore-plugin`, the protocol still behaves like an implementation detail that happened to become public. + +That causes friction in four places: + +1. **Ownership**: no single source of truth for message schemas and routing semantics. +2. **Compatibility**: consumers rely on aliases, historical event names, and tolerant parsing. +3. **Evolution**: changing payloads, names, or route semantics is risky across repos. +4. **Interoperability**: future non-Java consumers would need to reverse-engineer behavior from existing code. + +## Target State +Introduce a dedicated polyglot repository named **`xcore-protocol`**. + +`xcore-protocol` becomes the canonical source of truth for: + +- message schemas +- envelope structure +- route and stream metadata +- compatibility and deprecation policy +- canonical fixtures +- generated Java and Python protocol DTO/model artifacts +- thin handwritten validation/runtime support around generated artifacts +- cross-language compatibility tests + +Application repositories consume the protocol instead of defining it independently. + +## Architectural Boundary + +### `xcore-protocol` owns +- wire-level event/command/RPC contracts +- message metadata and route manifest +- protocol fixtures and golden examples +- generator inputs and generation configuration +- generated Java/Python protocol model layers +- protocol validation helpers and testkits around the generated surface +- compatibility rules and deprecation windows + +### `XCore-plugin` owns +- transport runtime backend +- subscription/request/response orchestration +- domain-to-generated-protocol mapping +- Mindustry integration boundaries +- application business logic + +### `XCore-discord-bot` owns +- bot behavior and handlers +- app-specific consumer loops and reconnect strategy +- generated-protocol-to-bot presentation logic +- app-specific failure handling + +## Dependency Direction + +### Current state +`XCore-plugin` and `XCore-discord-bot` each define part of the protocol surface. + +### Target state +Both depend on `xcore-protocol`: + +```text + xcore-protocol + / \ + / \ + XCore-plugin XCore-discord-bot +``` + +This reverses the current accidental dependency on implementation details. + +## Protocol-First Flow +1. A message family is defined in the protocol repository. +2. Canonical schemas, route metadata, fixtures, and examples are added. +3. Java and Python protocol DTO/model artifacts are generated from the canonical definitions. +4. Thin Java and Python support layers validate and expose the generated artifacts. +5. Application repos adopt the updated version and map their internal models to generated protocol DTOs/models. + +## Why Not A Full Ecosystem Monorepo +The protocol is the shared boundary; the applications are not the same product. A full ecosystem monorepo would combine: + +- different languages +- different operational lifecycles +- different ownership domains +- unrelated business logic + +That would add coordination cost without solving the core problem as cleanly as a dedicated protocol repo. + +## Protocol Design Objectives +- **Explicit wire contracts** instead of serializer-shaped payloads +- **Cross-language consistency** without duplicate field naming +- **Stable message identity** with explicit type/version metadata +- **Compatibility by policy** rather than ad hoc runtime tolerance +- **Transport awareness** without over-coupling the model to Redis internals +- **Generated consumption surfaces** so applications depend on protocol artifacts instead of re-declaring wire models + +## Initial Migration Slice +The first slice is the **moderation family** because it already crosses repository boundaries and exposes the clearest protocol consistency issues. + +Included first: +- moderation ban event +- moderation mute event +- moderation vote-kick event +- kick-banned command +- pardon command +- moderation audit appended event + +## Success Criteria +- A future agent can identify what belongs in `xcore-protocol` versus application repos. +- The protocol boundary is documented clearly enough to start implementation without architecture rework. +- Message, generator, and generated SDK/model ownership are explicit before code migration begins. diff --git a/docs/implementation/xcore-protocol-agent-playbook.md b/docs/implementation/xcore-protocol-agent-playbook.md new file mode 100644 index 0000000..c89ad02 --- /dev/null +++ b/docs/implementation/xcore-protocol-agent-playbook.md @@ -0,0 +1,141 @@ +# XCore Protocol Agent Playbook + +## Goal +Give a future agent a concrete, ambiguity-resistant path for implementing the protocol redesign without rediscovering major design decisions. + +## Non-Negotiable Decisions Already Made +- The future shared repository is named **`xcore-protocol`**. +- `xcore-protocol` owns the cross-service wire protocol surface. +- Application repos do not independently redefine protocol contracts. +- Canonical outbound payloads use one naming style only. +- Legacy compatibility belongs in explicit adapters, not canonical schemas. +- Migration starts with the moderation family. + +## Implementation Order + +### Step 1 — Bootstrap protocol repository structure +Create the agreed `xcore-protocol` tree with: +- docs +- spec +- fixtures +- generator configuration +- java modules +- python package +- compatibility test directories + +Do not start by implementing runtime loops. Start with the protocol boundary itself. + +### Step 2 — Define canonical moderation specs +Create canonical definitions for: +- ban created +- mute created +- vote-kick created +- kick-banned command +- pardon command +- moderation audit appended + +Also define any required shared subtypes. + +### Step 3 — Add fixtures first +For each message: +- valid canonical fixture +- invalid fixture +- legacy fixture if migration support is required + +### Step 4 — Implement generation scaffolding +In `xcore-protocol`: +- add Java/Python generation configuration +- generate protocol DTO/model artifacts from canonical definitions +- add validation around generated output + +### Step 5 — Implement Java protocol support +In the Java SDK or integration layer: +- consume generated DTO/model artifacts +- add validators and serialization support around generated artifacts +- add fixture validation tests + +In `XCore-plugin`: +- add mapping layer from internal models to generated protocol DTOs +- stop using internal domain/storage objects as direct wire payloads + +### Step 6 — Implement Python protocol support +In the Python SDK: +- consume generated models +- add validators and fixture validation tests around generated artifacts + +In `XCore-discord-bot`: +- adopt canonical outbound forms +- use compat adapters only where needed for inbound migration + +### Step 7 — Integrate route metadata +Move route/source-of-truth metadata into protocol-owned definitions. +Application repos should consume generated route metadata rather than duplicate it. + +### Step 8 — Run compatibility tests +At minimum: +- schema validation +- Java fixture validation +- Python fixture validation +- Java/Python roundtrip or golden compatibility tests + +### Step 9 — Migrate the next family only after moderation is stable +Proceed in this order: +1. moderation +2. Discord linking/admin +3. maps RPC +4. chat/heartbeat/misc + +## What Not To Decide Again +Do not reopen these decisions unless explicitly directed: +- repo name +- protocol-first direction +- moderation-first rollout +- canonical naming policy +- legacy compatibility isolation +- shared repo scope boundaries + +## What The Agent Should Clarify Only If Missing +- exact final field set for a specific message schema +- whether a specific timestamp is business-time or transport-time +- whether a specific historical payload still needs a compat window + +These are implementation details within the documented model, not reasons to revisit the architecture. + +## Suggested Acceptance Criteria By Slice + +### For each migrated message family +- canonical schema exists +- route metadata exists +- valid/invalid fixtures exist +- generated Java support exists +- generated Python support exists +- compatibility tests exist +- application repos are updated to use generated canonical protocol artifacts + +### For moderation slice completion +- no new moderation producer publishes duplicate field naming styles +- canonical moderation payloads are independent from internal persistence/domain model shape +- bot-side moderation handling can validate canonical moderation messages without alias sprawl in the main path + +## Validation Guidance +When app repos are updated: +- use targeted tests during iteration +- finish with broad validation appropriate to the repo +- for `XCore-plugin`, default final validation should align with repository guidance (`./gradlew test`, and `./gradlew test shadowJar` when transport/build surface is affected) + +## Deliverables Expected From Implementation Work +- protocol repo structure +- initial moderation specs +- generation scaffolding and generated artifacts +- fixtures and compatibility tests +- integration changes in `XCore-plugin` +- integration changes in `XCore-discord-bot` +- migration notes for the next family + +## Definition Of Done For The Planning Packet +This planning packet is considered successful if a future agent can begin implementation without asking: +- where the protocol should live +- what belongs in the shared repo +- which family to migrate first +- whether contracts should be normalized +- how compatibility should be handled diff --git a/docs/migrations/xcore-protocol-migration-plan.md b/docs/migrations/xcore-protocol-migration-plan.md new file mode 100644 index 0000000..0d3364b --- /dev/null +++ b/docs/migrations/xcore-protocol-migration-plan.md @@ -0,0 +1,159 @@ +# XCore Protocol Migration Plan + +## Goal +Provide a phased migration strategy from the current Redis contract model to the future `xcore-protocol` model. + +## Migration Principles +- Prefer additive migration over big-bang replacement. +- Keep current consumers operational during transition. +- Move compatibility concerns into explicit adapters. +- Migrate by message family, not by random file batches. +- Start with the highest-value cross-repo family first. + +## Phase 0 — Documentation And Design Freeze +Create and approve the design packet in `XCore-plugin`. + +Deliverables: +- ADR +- target architecture +- message model +- repo blueprint +- migration plan +- agent playbook + +Exit criteria: +- target-state decisions no longer need to be rediscovered during implementation + +## Phase 1 — Bootstrap `xcore-protocol` +Create the new repository with: + +- README and mission statement +- versioning and compatibility policies +- initial spec directories +- initial fixture directories +- generator scaffolding/configuration +- Java and Python package skeletons for generated artifacts and thin support + +Exit criteria: +- the protocol repository exists with agreed structure and contribution rules + +## Phase 2 — Moderation Family First + +### Included message families +- `moderation.ban.created` +- `moderation.mute.created` +- `moderation.vote-kick.created` +- `moderation.kick-banned.command` +- `moderation.pardon.command` +- `moderation.audit.appended` + +### Work items +- define canonical schemas +- define route metadata +- define shared subtypes used by moderation +- create canonical fixtures +- create legacy compatibility fixtures for existing payload forms +- generate Java and Python models for moderation contracts +- add thin handwritten validation/runtime support around generated artifacts + +### Application changes +`XCore-plugin`: +- introduce mapping to generated protocol DTOs +- stop treating internal punishment/domain objects as the wire contract + +`XCore-discord-bot`: +- adopt canonical outbound payloads +- move alias-heavy parsing into compatibility adapters where still needed + +Exit criteria: +- moderation contracts are defined and consumed through the protocol model + +## Phase 3 — Discord Linking/Admin Contracts + +### Included messages +- `discord.link.confirm.command` +- `discord.unlink.command` +- `discord.link.status-changed` +- `discord.admin-access.changed.command` + +Focus: +- canonical field naming +- timestamp consistency +- command vs event separation + +## Phase 4 — Maps RPC Contracts + +### Included messages +- `maps.list.request` +- `maps.list.response` +- `maps.remove.request` +- `maps.remove.response` + +Focus: +- explicit request/response pairing +- canonical request shape +- remove duplicate outbound field naming like `fileName` + `file_name` + +## Phase 5 — Chat / Heartbeat / Misc +Migrate: +- chat messages +- global chat +- heartbeat +- server action and join/leave generated message cutovers +- raw fallback removal and remaining legacy event-name cleanup + +Focus: +- normalize event type naming +- isolate historical forms into compatibility adapters + +## Compatibility Strategy During Migration + +### Producers +All new or upgraded producers send the canonical protocol form. + +### Consumers +Consumers may temporarily support legacy payload forms, but only via explicit compatibility readers. + +### Legacy handling +- legacy names and field aliases remain documented +- compat coverage must include fixtures and tests +- every compat rule gets an owner and sunset condition + +## Suggested Current-To-Target Mapping Themes + +### Current state patterns to remove +- duplicate canonical field spellings +- outbound duplication of multiple naming styles +- reliance on internal Java class shape for public contracts +- legacy event names handled as first-class canonical types + +### Current state patterns to keep conceptually +- broadcast event vs targeted command distinction +- request/response correlation concept +- stream naming discipline as a route metadata concern +- explicit DLQ and idempotency semantics + +## Validation Expectations Per Phase +- protocol specs validate +- fixtures validate +- generated Java SDK validates fixtures +- generated Python SDK validates fixtures +- cross-language compatibility checks pass +- application repos pass their targeted migration tests before broader validation + +## Risks +- under-specifying compatibility windows +- moving too many families at once +- accidentally turning `xcore-protocol` into a generic utility repository +- keeping legacy aliases in canonical schemas for too long + +## Risk Controls +- migrate family by family +- keep canonical schema strict +- document legacy support separately +- require schema + fixture + test updates together + +## Completion Criteria +- `XCore-plugin` and `XCore-discord-bot` both consume generated protocol artifacts for the migrated families +- canonical outbound payloads are used consistently +- historical compatibility is localized rather than spread through business logic diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 7d8c0cd..c1188d7 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -16,10 +16,6 @@ public interface ServerScopedEvent { public static abstract class Response {} public static abstract class Request {} - public record ServerActionEvent(String message, String server) implements ServerScopedEvent {} - - public record PlayerJoinLeaveEvent(String playerName, String server, Boolean join) implements ServerScopedEvent {} - public record KickBannedPlayer(String uuid, String ip) {} public record PlayerCustomNicknameChanged(String uuid, String customNickname) {} diff --git a/src/main/java/org/xcore/plugin/event/TransportService.java b/src/main/java/org/xcore/plugin/event/TransportService.java index f105129..f3d8dd0 100644 --- a/src/main/java/org/xcore/plugin/event/TransportService.java +++ b/src/main/java/org/xcore/plugin/event/TransportService.java @@ -10,6 +10,7 @@ import mindustry.game.EventType; import mindustry.gen.Groups; import mindustry.net.Administration; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.event.transport.ChatTransportHandler; @@ -55,7 +56,7 @@ public void init() { registerListeners(); Events.on(EventType.ServerLoadEvent.class, event -> { - network.post(new TransportEvents.ServerActionEvent("Server loaded", config.server)); + network.post(new ServerActionV1("Server loaded", config.server)); Timer.schedule(() -> { try { diff --git a/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java b/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java index cc0db43..2c3594a 100644 --- a/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java +++ b/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java @@ -8,6 +8,7 @@ import mindustry.game.EventType.PlayerLeave; import mindustry.gen.Call; import mindustry.gen.Player; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.database.repository.AdminDataRepository; @@ -21,7 +22,6 @@ import org.xcore.plugin.session.SessionService; import org.xcore.plugin.session.Session; import org.xcore.plugin.vote.VoteService; -import org.xcore.plugin.event.TransportEvents; import java.util.Objects; @@ -120,7 +120,7 @@ public void onPlayerJoin(PlayerJoin event) { sessionService.broadcast("player-joined", args( "nickname", player.coloredName(), "pid", data.pid)); - network.post(new TransportEvents.PlayerJoinLeaveEvent( + network.post(new PlayerJoinLeaveV1( player.plainName() + " #" + data.pid, config.server, true) @@ -142,7 +142,7 @@ public void onPlayerLeave(PlayerLeave event) { "pid", data.pid) ); - network.post(new TransportEvents.PlayerJoinLeaveEvent( + network.post(new PlayerJoinLeaveV1( player.plainName() + " #" + data.pid, config.server, false) diff --git a/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java b/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java index ca54b04..1fc50c6 100644 --- a/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java +++ b/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java @@ -14,6 +14,7 @@ import mindustry.gen.Groups; import mindustry.io.JsonIO; import mindustry.net.Packets; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.plugin.common.PluginState; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.MapDataRepository; @@ -22,7 +23,6 @@ import org.xcore.plugin.model.enums.FinishReason; import org.xcore.plugin.service.GameDataService; import org.xcore.plugin.service.NetworkService; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; @@ -95,7 +95,7 @@ public void onGameOver(GameOverEvent event) { Strings.capitalize(Strings.stripColors(state.map.name()))); } - network.post(new TransportEvents.ServerActionEvent(message, config.server)); + network.post(new ServerActionV1(message, config.server)); if (state.map != null && !state.isMenu()) { try { diff --git a/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java b/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java index 536b1ae..3a6efb9 100644 --- a/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java +++ b/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java @@ -22,7 +22,7 @@ import mindustry.net.Packets; import mindustry.net.WorldReloader; import mindustry.world.blocks.storage.CoreBlock; -import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.session.SessionService; @@ -272,7 +272,7 @@ private void endGame() { }); String rawMessage = generateMessage.get(new Localization(bundle)); - network.post(new TransportEvents.ServerActionEvent(Strings.stripColors(rawMessage), config.server)); + network.post(new ServerActionV1(Strings.stripColors(rawMessage), config.server)); Events.fire("hexed_world-reload"); Timer.schedule(this::reloadMap, 10); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 263c6f2..05f5a74 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -5,6 +5,8 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -25,7 +27,6 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; public final class RedisRouteRegistry { @@ -64,21 +65,7 @@ public RedisRouteRegistry() { } public RedisRouteDescriptor routeDescriptorFor(Object payload) { - RedisRouteDescriptor descriptor = descriptorsByType.get(payload.getClass()); - if (descriptor != null) { - return descriptor; - } - - String eventType = "event." + payload.getClass().getSimpleName().toLowerCase(Locale.ROOT); - return new RedisRouteDescriptor( - payload.getClass(), - "xcore:evt:raw", - eventType, - 60_000L, - RedisRouteKind.READ_ONLY, - RedisServerResolver.broadcast(), - null - ); + return descriptorsByType.get(payload.getClass()); } public RedisRouteDescriptor routeDescriptorFor(Class type) { @@ -140,8 +127,8 @@ public List descriptors() { private void registerDefaults() { register(readOnly(ChatMessageV1.class, "xcore:evt:chat:message", "chat.message", 60_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.ServerActionEvent.class, "xcore:evt:server:action", "server.action", 60_000L, RedisServerResolver.broadcast())); - register(readOnly(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, RedisServerResolver.broadcast())); + register(readOnly(ServerActionV1.class, "xcore:evt:server:action", "server.action", 60_000L, RedisServerResolver.broadcast())); + register(readOnly(PlayerJoinLeaveV1.class, "xcore:evt:player:joinleave", "player.join-leave", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, RedisServerResolver.broadcast())); register(readOnly(ChatDiscordIngressCommandV1.class, "xcore:cmd:discord-message:{server}", "chat.discord-ingress.command", 60_000L, PAYLOAD_SERVER_RESOLVER)); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java b/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java index adca1bd..6811a76 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java @@ -1,7 +1,6 @@ package org.xcore.plugin.service.network; import java.util.List; -import java.util.Locale; public final class RedisStreamRouter { private final RedisRouteRegistry registry; @@ -19,16 +18,15 @@ public record Route(String streamKey, String eventType, long ttlMillis) { public Route route(Object event, String defaultServer) { RedisRouteDescriptor descriptor = registry.routeDescriptorFor(event); - if (descriptor != null) { - return new Route( - registry.resolveStreamKey(descriptor, event, defaultServer), - descriptor.eventType(), - descriptor.ttlMillis() - ); + if (descriptor == null) { + throw new UnsupportedOperationException("Redis route does not support payload type: " + event.getClass().getName()); } - var eventType = "event." + event.getClass().getSimpleName().toLowerCase(Locale.ROOT); - return new Route("xcore:evt:raw", eventType, 60000L); + return new Route( + registry.resolveStreamKey(descriptor, event, defaultServer), + descriptor.eventType(), + descriptor.ttlMillis() + ); } public List subscribeStreamsFor(Class type, String defaultServer) { diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index f6c6e02..c23b5de 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -5,6 +5,8 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -59,8 +61,8 @@ public record RouteSpec( public static final List ROUTES = List.of( route(ChatMessageV1.class, "xcore:evt:chat:message", "chat.message", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.ServerActionEvent.class, "xcore:evt:server:action", "server.action", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), - route(TransportEvents.PlayerJoinLeaveEvent.class, "xcore:evt:player:joinleave", "player.join_leave", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(ServerActionV1.class, "xcore:evt:server:action", "server.action", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), + route(PlayerJoinLeaveV1.class, "xcore:evt:player:joinleave", "player.join-leave", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ChatGlobalV1.class, "xcore:evt:chat:global", "chat.global", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ServerHeartbeatV1.class, "xcore:evt:server:heartbeat", "server.heartbeat", 60_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ChatDiscordIngressCommandV1.class, "xcore:cmd:discord-message:{server}", "chat.discord-ingress.command", 60_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, true), diff --git a/src/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index ec54ef3..825ed99 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -10,6 +10,7 @@ import mindustry.gen.Groups; import mindustry.gen.Player; import mindustry.net.Packets; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.plugin.common.VersionComparator; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.config.Config; @@ -111,7 +112,7 @@ public void vote(Player player, int sign) { } if (network != null) { - network.post(new TransportEvents.ServerActionEvent(stripColors(message), config.server)); + network.post(new ServerActionV1(stripColors(message), config.server)); } } @@ -210,7 +211,7 @@ public void success() { if (network != null) { network.post(buildVoteKickEvent()); - network.post(new TransportEvents.ServerActionEvent( + network.post(new ServerActionV1( systemLocal.format("votekick-success", bundleArgs), config.server)); } onKick.get(target); diff --git a/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java b/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java index 2603c06..62d983f 100644 --- a/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java @@ -10,6 +10,7 @@ import mindustry.gen.Player; import mindustry.net.Administration; import mindustry.net.NetConnection; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,7 +19,6 @@ import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.database.repository.AdminDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.NetworkService; @@ -124,7 +124,7 @@ void onPlayerJoin_persistsNicknameWithChangedIp_andRevokesUnconfirmedAdmin() { verify(sessionService).updateConnectionData(session, "2.2.2.2", "[#00000000][red]Renamed[]"); verify(localization).send(eq("error-ip-changed"), anyMap()); verify(playerDisplayService).refresh(session); - verify(networkService).post(any(TransportEvents.PlayerJoinLeaveEvent.class)); + verify(networkService).post(any(PlayerJoinLeaveV1.class)); verify(sessionService, never()).persistPlayer(session); } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 099faaf..faf7091 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -61,6 +61,28 @@ void tearDown() { } } + @Test + @DisplayName("send rejects unsupported payloads without publishing any fallback stream") + void sendRejectsUnsupportedPayloadsWithoutPublishingAnyFallbackStream() { + Config config = baseConfig("alpha"); + requesterBackend = new RedisNetworkBackend(config); + requesterBackend.connect(); + + requesterBackend.send(new Object()); + + assertThat(requesterBackend.metricsSnapshot().getOrDefault("publish_failures", 0L)).isEqualTo(1L); + assertThat(requesterBackend.metricsSnapshot().getOrDefault("published_events", 0L)).isZero(); + + try (RedisClient client = RedisClient.create(config.redisUrl); + StatefulRedisConnection connection = client.connect()) { + List> messages = connection.sync().xread( + XReadArgs.StreamOffset.from("xcore:evt:raw", "0-0") + ); + + assertThat(messages).isEmpty(); + } + } + @Test @DisplayName("send publishes envelope to mapped stream") void sendPublishesEnvelopeToMappedStream() { diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 9bb8628..ccda005 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -6,6 +6,8 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -31,6 +33,13 @@ class RedisRouteRegistryTest { private final RedisRouteRegistry registry = new RedisRouteRegistry(); + @Test + @DisplayName("unknown payload types have no registry descriptor") + void unknownPayloadTypesHaveNoRegistryDescriptor() { + assertThat(registry.routeDescriptorFor(new Object())).isNull(); + assertThat(registry.routeDescriptorFor(Object.class)).isNull(); + } + @Test @DisplayName("payload server resolver uses typed server contract") void payloadServerResolverUsesTypedContract() { @@ -123,6 +132,8 @@ void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(ChatGlobalV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ChatDiscordIngressCommandV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ChatPrivateV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(PlayerJoinLeaveV1.class)).isTrue(); + assertThat(registry.isReadOnlyType(ServerActionV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 3c30143..26b2e79 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -6,6 +6,8 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -36,11 +38,20 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class RedisStreamRouterTest { private final RedisStreamRouter router = new RedisStreamRouter(); + @Test + @DisplayName("route rejects unsupported payload types without synthesizing transport metadata") + void routeRejectsUnsupportedPayloadTypes() { + assertThatThrownBy(() -> router.route(new Object(), "mini-pvp")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining(Object.class.getName()); + } + @Test @DisplayName("route maps read-only events to expected stream and event type") void routeReadOnlyEvents() { @@ -49,7 +60,8 @@ void routeReadOnlyEvents() { var messageRoute = router.route(new ChatMessageV1("a", "b", "mini-pvp"), "mini-pvp"); var privateRoute = router.route(new ChatPrivateV1("uuid-from", 7, "Sender", "uuid-to", 42, "hello", "survival"), "mini-pvp"); - var joinRoute = router.route(new TransportEvents.PlayerJoinLeaveEvent("p", "mini-pvp", true), "mini-pvp"); + var serverActionRoute = router.route(new ServerActionV1("Server loaded", "mini-pvp"), "mini-pvp"); + var joinRoute = router.route(new PlayerJoinLeaveV1("p", "mini-pvp", true), "mini-pvp"); var heartbeatRoute = router.route(new ServerHeartbeatV1("mini-pvp", 1, 5, 30, "1.0.0", "127.0.0.1", 6567), "mini-pvp"); var banRoute = router.route( org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreated( @@ -112,8 +124,11 @@ void routeReadOnlyEvents() { assertThat(privateRoute.streamKey()).isEqualTo("xcore:evt:chat:private"); assertThat(privateRoute.eventType()).isEqualTo("chat.private"); + assertThat(serverActionRoute.streamKey()).isEqualTo("xcore:evt:server:action"); + assertThat(serverActionRoute.eventType()).isEqualTo("server.action"); + assertThat(joinRoute.streamKey()).isEqualTo("xcore:evt:player:joinleave"); - assertThat(joinRoute.eventType()).isEqualTo("player.join_leave"); + assertThat(joinRoute.eventType()).isEqualTo("player.join-leave"); assertThat(heartbeatRoute.streamKey()).isEqualTo("xcore:evt:server:heartbeat"); assertThat(heartbeatRoute.eventType()).isEqualTo("server.heartbeat"); @@ -235,6 +250,12 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(ChatDiscordIngressCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:discord-message:mini-pvp"); + assertThat(router.subscribeStreamsFor(PlayerJoinLeaveV1.class, "mini-pvp")) + .containsExactly("xcore:evt:player:joinleave"); + + assertThat(router.subscribeStreamsFor(ServerActionV1.class, "mini-pvp")) + .containsExactly("xcore:evt:server:action"); + assertThat(router.subscribeStreamsFor(ServerHeartbeatV1.class, "mini-pvp")) .containsExactly("xcore:evt:server:heartbeat"); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 1d08e00..eae7c4f 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -6,6 +6,8 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -155,6 +157,8 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec messageRoute = RedisTransportTopology.routeFor(ChatMessageV1.class); RedisTransportTopology.RouteSpec discordIngressRoute = RedisTransportTopology.routeFor(ChatDiscordIngressCommandV1.class); RedisTransportTopology.RouteSpec privateRoute = RedisTransportTopology.routeFor(ChatPrivateV1.class); + RedisTransportTopology.RouteSpec joinLeaveRoute = RedisTransportTopology.routeFor(PlayerJoinLeaveV1.class); + RedisTransportTopology.RouteSpec serverActionRoute = RedisTransportTopology.routeFor(ServerActionV1.class); RedisTransportTopology.RouteSpec heartbeatRoute = RedisTransportTopology.routeFor(ServerHeartbeatV1.class); RedisTransportTopology.RouteSpec moderationRoute = RedisTransportTopology.routeFor(ModerationBanCreatedV1.class); RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); @@ -172,6 +176,8 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec stableMessageRoute = messageRoute; RedisTransportTopology.RouteSpec stableDiscordIngressRoute = discordIngressRoute; RedisTransportTopology.RouteSpec stablePrivateRoute = privateRoute; + RedisTransportTopology.RouteSpec stableJoinLeaveRoute = joinLeaveRoute; + RedisTransportTopology.RouteSpec stableServerActionRoute = serverActionRoute; RedisTransportTopology.RouteSpec stableHeartbeatRoute = heartbeatRoute; RedisTransportTopology.RouteSpec stableModerationRoute = moderationRoute; RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; @@ -217,6 +223,22 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stablePrivateRoute.readOnly()).isTrue(); assertThat(stablePrivateRoute.rpcRequest()).isFalse(); + assertThat(stableJoinLeaveRoute).isNotNull(); + assertThat(stableJoinLeaveRoute.streamPattern()).isEqualTo("xcore:evt:player:joinleave"); + assertThat(stableJoinLeaveRoute.eventType()).isEqualTo("player.join-leave"); + assertThat(stableJoinLeaveRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stableJoinLeaveRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stableJoinLeaveRoute.readOnly()).isTrue(); + assertThat(stableJoinLeaveRoute.rpcRequest()).isFalse(); + + assertThat(stableServerActionRoute).isNotNull(); + assertThat(stableServerActionRoute.streamPattern()).isEqualTo("xcore:evt:server:action"); + assertThat(stableServerActionRoute.eventType()).isEqualTo("server.action"); + assertThat(stableServerActionRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.EVENT); + assertThat(stableServerActionRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.BROADCAST); + assertThat(stableServerActionRoute.readOnly()).isTrue(); + assertThat(stableServerActionRoute.rpcRequest()).isFalse(); + assertThat(stableHeartbeatRoute).isNotNull(); assertThat(stableHeartbeatRoute.streamPattern()).isEqualTo("xcore:evt:server:heartbeat"); assertThat(stableHeartbeatRoute.eventType()).isEqualTo("server.heartbeat"); From b85120fa9323205d25a13f12b93908d2c10f9971 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:16:07 +0300 Subject: [PATCH 15/26] feat(network): migrate maps load command DTO Move map load transport onto the generated protocol command so the plugin uses the canonical maps.load.command contract across routing and tests. --- .../org/xcore/plugin/event/TransportEvents.java | 4 ---- .../event/transport/MapTransportHandler.java | 11 ++++++----- .../service/network/RedisRouteRegistry.java | 6 +++++- .../service/network/RedisTransportTopology.java | 3 ++- .../RedisNetworkBackendIntegrationTest.java | 15 ++++++++++----- .../service/network/RedisRouteRegistryTest.java | 17 +++++++++++++++++ .../service/network/RedisStreamRouterTest.java | 9 ++++++++- .../network/RedisTransportContractsTest.java | 11 +++++++++++ 8 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index c1188d7..fa6a1f4 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -68,10 +68,6 @@ public record ModerationAuditAppendedEvent( public static class ReloadPlayerDataCache {} - public record LoadMapsV2(FileURL[] urls, String server) implements ServerScopedEvent {} - - public record FileURL(String url, String filename) {} - public record ExecuteCommand(String command, String[] expectServers, boolean isExclusion) { public ExecuteCommand(String command, String[] expectServers) { this(command, expectServers, false); diff --git a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java index edb16fb..c1841e5 100644 --- a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java @@ -6,11 +6,12 @@ import jakarta.inject.Singleton; import mindustry.maps.Map; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; +import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.MapEntryV1; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.MapDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.MapData; import org.xcore.plugin.service.MapService; import org.xcore.plugin.service.NetworkService; @@ -77,19 +78,19 @@ public void registerListeners() { if (map != null) info("Removed map @", map.plainName()); }); - network.subscribe(TransportEvents.LoadMapsV2.class, e -> { + network.subscribe(MapsLoadCommandV1.class, e -> { if (!config.server.equals(e.server())) return; AtomicInteger counter = new AtomicInteger(); - for (TransportEvents.FileURL file : e.urls()) { + for (MapFileSourceV1 file : e.files()) { Http.get(file.url()) .error(Log::err) .submit(result -> { customMapDirectory.child(file.filename()).writeBytes(result.getResult()); - if (counter.incrementAndGet() == e.urls().length) { + if (counter.incrementAndGet() == e.files().size()) { maps.reload(); - info("Loaded @ maps.", e.urls().length); + info("Loaded @ maps.", e.files().size()); } }); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 05f5a74..c7753ff 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -15,6 +15,7 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; @@ -149,7 +150,7 @@ private void registerDefaults() { register(readOnly(DiscordLinkStatusChangedV1.class, "xcore:evt:discord:link-status", "discord.link.status-changed", 120_000L, RedisServerResolver.broadcast())); register(mutating(DiscordAdminAccessChangedCommandV1.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin-access.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, RedisServerResolver.defaultServer())); - register(mutating(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, PAYLOAD_SERVER_RESOLVER)); + register(mutating(MapsLoadCommandV1.class, "xcore:cmd:maps-load:{server}", "maps.load.command", 300_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, RedisServerResolver.broadcast())); register(mutating(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, MODERATION_SERVER_RESOLVER)); register(rpc(MapsListRequestV1.class, "xcore:rpc:req:{server}", "maps.list.request", 10_000L, PAYLOAD_SERVER_RESOLVER, MapsListResponseV1.class)); @@ -204,6 +205,9 @@ private static String mapsServer(Object payload) { if (payload instanceof MapsListRequestV1 request) { return request.server(); } + if (payload instanceof MapsLoadCommandV1 command) { + return command.server(); + } if (payload instanceof MapsRemoveRequestV1 request) { return request.server(); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index c23b5de..de91a12 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -15,6 +15,7 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; @@ -83,7 +84,7 @@ public record RouteSpec( route(DiscordLinkStatusChangedV1.class, "xcore:evt:discord:link-status", "discord.link.status-changed", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(DiscordAdminAccessChangedCommandV1.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin-access.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), - route(TransportEvents.LoadMapsV2.class, "xcore:cmd:maps-load:{server}", "maps.load", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(MapsLoadCommandV1.class, "xcore:cmd:maps-load:{server}", "maps.load.command", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), route(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), rpcRoute(MapsListRequestV1.class, "xcore:rpc:req:{server}", "maps.list.request", 10_000L, ServerScope.PAYLOAD_SERVER, MapsListResponseV1.class), diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index faf7091..4660a9c 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -17,12 +17,14 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; import org.xcore.protocol.generated.shared.MapEntryV1; +import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.plugin.config.Config; @@ -558,8 +560,8 @@ void mutatingDuplicateMessageExecutesOnce() throws InterruptedException { AtomicInteger executions = new AtomicInteger(0); CountDownLatch latch = new CountDownLatch(1); - Subscription subscription = requesterBackend.subscribe( - TransportEvents.LoadMapsV2.class, + Subscription subscription = requesterBackend.subscribe( + MapsLoadCommandV1.class, event -> { executions.incrementAndGet(); latch.countDown(); @@ -572,14 +574,17 @@ void mutatingDuplicateMessageExecutesOnce() throws InterruptedException { long expires = now + 120_000; Map fields = Map.ofEntries( Map.entry("schema_version", "1"), - Map.entry("event_type", "maps.load"), + Map.entry("event_type", "maps.load.command"), Map.entry("event_id", "evt-1"), - Map.entry("idempotency_key", "maps.load:test-key"), + Map.entry("idempotency_key", "maps.load.command:test-key"), Map.entry("producer", "discord-bot"), Map.entry("created_at", String.valueOf(now)), Map.entry("expires_at", String.valueOf(expires)), Map.entry("server", "alpha"), - Map.entry("payload_json", "{\"urls\":[],\"server\":\"alpha\"}") + Map.entry("payload_json", new Gson().toJson(new MapsLoadCommandV1( + "alpha", + List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav")) + ))) ); connection.sync().xadd("xcore:cmd:maps-load:alpha", fields); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index ccda005..e911727 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -15,6 +15,7 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; @@ -24,6 +25,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.plugin.event.TransportEvents; @@ -125,6 +127,20 @@ void rpcRouteDescriptorCarriesResponseType() { assertThat(registry.rpcTypeForRequestClass(MapsRemoveRequestV1.class)).isEqualTo("maps.remove.request"); } + @Test + @DisplayName("maps load command uses typed payload server contract") + void mapsLoadCommandUsesTypedPayloadServerContract() { + RedisRouteDescriptor descriptor = registry.routeDescriptorFor(MapsLoadCommandV1.class); + + String stream = registry.resolveStreamKey( + descriptor, + new MapsLoadCommandV1("survival", java.util.List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav"))), + "mini-pvp" + ); + + assertThat(stream).isEqualTo("xcore:cmd:maps-load:survival"); + } + @Test @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { @@ -138,6 +154,7 @@ void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); + assertThat(registry.isMutatingType(MapsLoadCommandV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 26b2e79..882e538 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -16,6 +16,7 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; @@ -27,6 +28,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.protocol.generated.shared.ActorRefV1; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.ModerationTargetRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import org.xcore.plugin.event.TransportEvents; @@ -153,7 +155,7 @@ void routeReadOnlyEvents() { @DisplayName("route maps server-targeted events using event payload server") void routeServerTargetedEvents() { var discordRoute = router.route(new ChatDiscordIngressCommandV1("bot", "hello", "mini-hexed"), "mini-pvp"); - var mapsRoute = router.route(new TransportEvents.LoadMapsV2(new TransportEvents.FileURL[0], "event"), "mini-pvp"); + var mapsRoute = router.route(new MapsLoadCommandV1("event", List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav"))), "mini-pvp"); var badgeRoute = router.route(new TransportEvents.PlayerBadgeInventoryChanged("uuid-7", "translator", java.util.Set.of("translator")), "mini-pvp"); var badgeColorModeRoute = router.route(new TransportEvents.PlayerBadgeSymbolColorModeChanged("uuid-7", "player-color"), "mini-pvp"); var passwordRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); @@ -204,6 +206,7 @@ void routeServerTargetedEvents() { assertThat(discordRoute.streamKey()).isEqualTo("xcore:cmd:discord-message:mini-hexed"); assertThat(discordRoute.eventType()).isEqualTo("chat.discord-ingress.command"); assertThat(mapsRoute.streamKey()).isEqualTo("xcore:cmd:maps-load:event"); + assertThat(mapsRoute.eventType()).isEqualTo("maps.load.command"); assertThat(badgeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-inventory:mini-pvp"); assertThat(badgeRoute.eventType()).isEqualTo("player.badge_inventory"); assertThat(badgeColorModeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:mini-pvp"); @@ -265,6 +268,9 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(MapsRemoveRequestV1.class, "mini-pvp")) .containsExactly("xcore:rpc:req:mini-pvp"); + assertThat(router.subscribeStreamsFor(MapsLoadCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:maps-load:mini-pvp"); + assertThat(router.subscribeStreamsFor(TransportEvents.PlayerPasswordReset.class, "mini-pvp")) .containsExactly("xcore:cmd:player-password-reset:mini-pvp"); @@ -323,6 +329,7 @@ void classificationAndResponseMapping() { assertThat(router.isMutatingType(TransportEvents.PlayerPasswordReset.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerBadgeSymbolColorModeChanged.class)).isTrue(); + assertThat(router.isMutatingType(MapsLoadCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordLinkConfirmCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index eae7c4f..2f31a55 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -14,6 +14,7 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; @@ -165,6 +166,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec discordLinkCodeRoute = RedisTransportTopology.routeFor(DiscordLinkCodeCreatedV1.class); RedisTransportTopology.RouteSpec discordStatusRoute = RedisTransportTopology.routeFor(DiscordLinkStatusChangedV1.class); RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); + RedisTransportTopology.RouteSpec mapsLoadCommandRoute = RedisTransportTopology.routeFor(MapsLoadCommandV1.class); RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(MapsListRequestV1.class); @@ -184,6 +186,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec stableDiscordLinkCodeRoute = discordLinkCodeRoute; RedisTransportTopology.RouteSpec stableDiscordStatusRoute = discordStatusRoute; RedisTransportTopology.RouteSpec stableCommandRoute = commandRoute; + RedisTransportTopology.RouteSpec stableMapsLoadCommandRoute = mapsLoadCommandRoute; RedisTransportTopology.RouteSpec stableDiscordAdminCommandRoute = discordAdminCommandRoute; RedisTransportTopology.RouteSpec stableBroadcastCommandRoute = broadcastCommandRoute; RedisTransportTopology.RouteSpec stableRpcRoute = rpcRoute; @@ -283,6 +286,14 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableCommandRoute.readOnly()).isFalse(); assertThat(stableCommandRoute.rpcRequest()).isFalse(); + assertThat(stableMapsLoadCommandRoute).isNotNull(); + assertThat(stableMapsLoadCommandRoute.streamPattern()).isEqualTo("xcore:cmd:maps-load:{server}"); + assertThat(stableMapsLoadCommandRoute.eventType()).isEqualTo("maps.load.command"); + assertThat(stableMapsLoadCommandRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); + assertThat(stableMapsLoadCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableMapsLoadCommandRoute.readOnly()).isFalse(); + assertThat(stableMapsLoadCommandRoute.rpcRequest()).isFalse(); + assertThat(stableDiscordAdminCommandRoute).isNotNull(); assertThat(stableDiscordAdminCommandRoute.streamPattern()).isEqualTo("xcore:cmd:discord-admin-access:{server}"); assertThat(stableDiscordAdminCommandRoute.eventType()).isEqualTo("discord.admin-access.changed.command"); From a9799a9f8e2c6bf2df72cececfd3868086efdd7f Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:39:07 +0300 Subject: [PATCH 16/26] refactor(network): drop dead legacy maps transport types Remove obsolete maps request and response scaffolding now that the plugin uses generated protocol DTOs for the live maps transport path. --- .../xcore/plugin/event/TransportEvents.java | 55 ------------------- 1 file changed, 55 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index fa6a1f4..5f4087e 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -1,7 +1,5 @@ package org.xcore.plugin.event; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; import java.time.Instant; import java.util.List; import java.util.Set; @@ -13,9 +11,6 @@ public interface ServerScopedEvent { String server(); } - public static abstract class Response {} - public static abstract class Request {} - public record KickBannedPlayer(String uuid, String ip) {} public record PlayerCustomNicknameChanged(String uuid, String customNickname) {} @@ -76,54 +71,4 @@ public ExecuteCommand(String command, String[] expectServers) { public record PardonPlayer(String uuid) {} - @NoArgsConstructor - @AllArgsConstructor - public static class MapsListRequest extends Request implements ServerScopedEvent { - public String server; - - @Override - public String server() { - return server; - } - } - - @NoArgsConstructor - @AllArgsConstructor - public static class MapsListResponse extends Response { - public MapEntry[] maps; - } - - @NoArgsConstructor - @AllArgsConstructor - public static class MapEntry { - public String name; - public String fileName; - public String author; - public Integer width; - public Integer height; - public Long fileSizeBytes; - public Integer like; - public Integer dislike; - public Integer reputation; - public Double popularity; - public Double interest; - public String gameMode; - } - - @NoArgsConstructor - @AllArgsConstructor - public static class MapRemoveRequest extends Request implements ServerScopedEvent { - public String server, fileName; - - @Override - public String server() { - return server; - } - } - - @NoArgsConstructor - @AllArgsConstructor - public static class MapRemoveResponse extends Response { - public String result; - } } From b4323489144c735888ab38271eaf512215a87e7c Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Fri, 1 May 2026 19:50:35 +0300 Subject: [PATCH 17/26] feat(network): migrate player session sync DTOs Switch the custom nickname, active badge, and badge symbol color mode transport flow to generated protocol commands so plugin routing and handlers use the canonical server-targeted contract. This also updates the protocol dependency to the snapshot containing the new chat-family player-session commands and removes the replaced legacy transport records. --- gradle/libs.versions.toml | 2 +- .../controller/client/BadgeController.java | 12 ++-- .../xcore/plugin/event/TransportEvents.java | 6 -- .../transport/ModerationTransportHandler.java | 15 +++-- .../service/network/RedisRouteRegistry.java | 26 ++++++++- .../network/RedisTransportTopology.java | 9 ++- .../org/xcore/plugin/ui/menu/PlayerMenu.java | 9 ++- .../ModerationTransportHandlerTest.java | 58 +++++++++++++++++++ .../network/RedisRouteRegistryTest.java | 30 ++++++++++ .../network/RedisStreamRouterTest.java | 31 +++++++--- .../network/RedisTransportContractsTest.java | 45 ++++++++++++++ 11 files changed, 210 insertions(+), 33 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab7a6bf..20dbabc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] mindustry = "157" -xcore-protocol = "0.3.0" +xcore-protocol = "0.3.0-SNAPSHOT" # Plugins toxopid = "4.1.2" diff --git a/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java b/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java index 200ee50..647c610 100644 --- a/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java +++ b/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java @@ -6,13 +6,14 @@ import org.incendo.cloud.annotations.Command; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudClientController; -import org.xcore.plugin.event.TransportEvents; +import org.xcore.plugin.config.Config; import org.xcore.plugin.player.Badge; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.ui.menu.PlayerMenu; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; import static com.ospx.flubundle.Bundle.args; @@ -23,12 +24,15 @@ public class BadgeController implements CloudClientController { private final PlayerMenu playerMenu; private final PlayerDisplayService playerDisplayService; private final NetworkService network; + private final Config config; @Inject - public BadgeController(SessionService sessionService, + public BadgeController(Config config, + SessionService sessionService, PlayerMenu playerMenu, PlayerDisplayService playerDisplayService, NetworkService network) { + this.config = config; this.sessionService = sessionService; this.playerMenu = playerMenu; this.playerDisplayService = playerDisplayService; @@ -49,7 +53,7 @@ public void clear(XCoreSender sender) { sessionService.setActiveBadge(session, ""); playerDisplayService.refresh(session); - network.post(new TransportEvents.PlayerActiveBadgeChanged(session.data.uuid, session.data.activeBadge)); + network.post(new PlayerActiveBadgeChangedCommandV1(session.data.uuid, session.data.activeBadge, config.server)); session.locale().send("badge-clear-success", args()); } @@ -76,7 +80,7 @@ public void set(XCoreSender sender, @Argument("id") String id) { sessionService.setActiveBadge(session, badge.id()); playerDisplayService.refresh(session); - network.post(new TransportEvents.PlayerActiveBadgeChanged(session.data.uuid, session.data.activeBadge)); + network.post(new PlayerActiveBadgeChangedCommandV1(session.data.uuid, session.data.activeBadge, config.server)); session.locale().send("badge-set-success", args("badge", session.locale().t(badge.nameKey()))); } } diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 5f4087e..e865406 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -13,12 +13,6 @@ public interface ServerScopedEvent { public record KickBannedPlayer(String uuid, String ip) {} - public record PlayerCustomNicknameChanged(String uuid, String customNickname) {} - - public record PlayerActiveBadgeChanged(String uuid, String activeBadge) {} - - public record PlayerBadgeSymbolColorModeChanged(String uuid, String badgeSymbolColorMode) {} - public record PlayerBadgeInventoryChanged(String uuid, String activeBadge, Set unlockedBadges) {} public record PlayerPasswordReset(String uuid) {} diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index d8f2b5b..e23081a 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -15,6 +15,9 @@ import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; @@ -84,22 +87,22 @@ public void registerListeners() { } }); - network.subscribe(TransportEvents.PlayerCustomNicknameChanged.class, e -> updatePlayerSession( - e.uuid(), + network.subscribe(PlayerCustomNicknameChangedCommandV1.class, e -> updatePlayerSession( + e.playerUuid(), data -> data.customNickname = e.customNickname(), false, "custom nickname" )); - network.subscribe(TransportEvents.PlayerActiveBadgeChanged.class, e -> updatePlayerSession( - e.uuid(), + network.subscribe(PlayerActiveBadgeChangedCommandV1.class, e -> updatePlayerSession( + e.playerUuid(), data -> data.activeBadge = e.activeBadge(), true, "active badge" )); - network.subscribe(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, e -> updatePlayerSession( - e.uuid(), + network.subscribe(PlayerBadgeSymbolColorModeChangedCommandV1.class, e -> updatePlayerSession( + e.playerUuid(), data -> data.badgeSymbolColorMode = e.badgeSymbolColorMode(), true, "badge symbol color mode" diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index c7753ff..321269d 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -5,6 +5,9 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -45,6 +48,10 @@ public final class RedisRouteRegistry { if (discordServer != null && !discordServer.isBlank()) { return discordServer; } + String playerSessionServer = playerSessionServer(payload); + if (playerSessionServer != null && !playerSessionServer.isBlank()) { + return playerSessionServer; + } String mapsServer = mapsServer(payload); if (mapsServer != null && !mapsServer.isBlank()) { return mapsServer; @@ -139,9 +146,9 @@ private void registerDefaults() { register(readOnly(ModerationVoteKickCreatedV1.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, RedisServerResolver.broadcast())); register(readOnly(ModerationAuditAppendedV1.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, RedisServerResolver.broadcast())); register(mutating(ModerationKickBannedCommandV1.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, MODERATION_SERVER_RESOLVER)); - register(mutating(TransportEvents.PlayerCustomNicknameChanged.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom_nickname", 120_000L, RedisServerResolver.defaultServer())); - register(mutating(TransportEvents.PlayerActiveBadgeChanged.class, "xcore:cmd:player-active-badge:{server}", "player.active_badge", 120_000L, RedisServerResolver.defaultServer())); - register(mutating(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, RedisServerResolver.defaultServer())); + register(mutating(PlayerCustomNicknameChangedCommandV1.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom-nickname.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); + register(mutating(PlayerActiveBadgeChangedCommandV1.class, "xcore:cmd:player-active-badge:{server}", "player.active-badge.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); + register(mutating(PlayerBadgeSymbolColorModeChangedCommandV1.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge-symbol-color-mode.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(TransportEvents.PlayerBadgeInventoryChanged.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge_inventory", 120_000L, RedisServerResolver.defaultServer())); register(mutating(TransportEvents.PlayerPasswordReset.class, "xcore:cmd:player-password-reset:{server}", "player.password_reset", 120_000L, RedisServerResolver.defaultServer())); register(readOnly(DiscordLinkCodeCreatedV1.class, "xcore:evt:discord:link-code", "discord.link-code-created", 120_000L, RedisServerResolver.broadcast())); @@ -214,6 +221,19 @@ private static String mapsServer(Object payload) { return null; } + private static String playerSessionServer(Object payload) { + if (payload instanceof PlayerCustomNicknameChangedCommandV1 command) { + return command.server(); + } + if (payload instanceof PlayerActiveBadgeChangedCommandV1 command) { + return command.server(); + } + if (payload instanceof PlayerBadgeSymbolColorModeChangedCommandV1 command) { + return command.server(); + } + return null; + } + private void register(RedisRouteDescriptor descriptor) { descriptorsByType.put(descriptor.payloadType(), descriptor); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index de91a12..6dcf2a7 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -5,6 +5,9 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -73,9 +76,9 @@ public record RouteSpec( route(ModerationVoteKickCreatedV1.class, "xcore:evt:moderation:votekick", "moderation.vote-kick.created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationAuditAppendedV1.class, "xcore:evt:moderation:audit", "moderation.audit.appended", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(ModerationKickBannedCommandV1.class, "xcore:cmd:kick-banned:{server}", "moderation.kick-banned.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - route(TransportEvents.PlayerCustomNicknameChanged.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom_nickname", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), - route(TransportEvents.PlayerActiveBadgeChanged.class, "xcore:cmd:player-active-badge:{server}", "player.active_badge", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), - route(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge_symbol_color_mode", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(PlayerCustomNicknameChangedCommandV1.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom-nickname.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(PlayerActiveBadgeChangedCommandV1.class, "xcore:cmd:player-active-badge:{server}", "player.active-badge.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(PlayerBadgeSymbolColorModeChangedCommandV1.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge-symbol-color-mode.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(TransportEvents.PlayerBadgeInventoryChanged.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge_inventory", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(TransportEvents.PlayerPasswordReset.class, "xcore:cmd:player-password-reset:{server}", "player.password_reset", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), route(DiscordLinkCodeCreatedV1.class, "xcore:evt:discord:link-code", "discord.link-code-created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), diff --git a/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java b/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java index 00664d6..518fcd3 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java @@ -20,6 +20,9 @@ import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import java.nio.charset.StandardCharsets; import java.text.NumberFormat; @@ -573,7 +576,7 @@ private void updateCustomNickname(PlayerData targetData, String customNickname, updatePlayerData(targetData, data -> data.customNickname = customNickname, data -> playerDataRepository.updateCustomNickname(data.uuid, customNickname), - data -> new org.xcore.plugin.event.TransportEvents.PlayerCustomNicknameChanged(data.uuid, data.customNickname), + data -> new PlayerCustomNicknameChangedCommandV1(data.uuid, data.customNickname, config.server), refreshDisplay, sync); } @@ -660,7 +663,7 @@ private void updateActiveBadge(PlayerData targetData, String badgeId, boolean re updatePlayerData(targetData, data -> data.activeBadge = badgeId, data -> playerDataRepository.setActiveBadge(data.uuid, badgeId), - data -> new org.xcore.plugin.event.TransportEvents.PlayerActiveBadgeChanged(data.uuid, data.activeBadge), + data -> new PlayerActiveBadgeChangedCommandV1(data.uuid, data.activeBadge, config.server), refreshDisplay, sync); } @@ -669,7 +672,7 @@ private void updateBadgeSymbolColorMode(PlayerData targetData, String mode, bool updatePlayerData(targetData, data -> data.badgeSymbolColorMode = mode, data -> playerDataRepository.updateBadgeSymbolColorMode(data.uuid, mode), - data -> new org.xcore.plugin.event.TransportEvents.PlayerBadgeSymbolColorModeChanged(data.uuid, data.badgeSymbolColorMode), + data -> new PlayerBadgeSymbolColorModeChangedCommandV1(data.uuid, data.badgeSymbolColorMode, config.server), refreshDisplay, sync); } diff --git a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java index 95f3732..12a02f7 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -1,20 +1,30 @@ package org.xcore.plugin.event.transport; import arc.func.Cons; +import com.ospx.flubundle.Bundle; import mindustry.Vars; import mindustry.core.NetServer; +import mindustry.gen.Player; import mindustry.net.Administration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.event.TransportEvents; +import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.service.DiscordAdminAccessService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.service.network.RedisNetworkBackend; +import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; +import org.xcore.plugin.ui.MenuService; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; @@ -22,9 +32,11 @@ import java.util.HashMap; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -115,6 +127,52 @@ void discordAdminRevokeCommand_clearsPersistedAdminFlags() { verify(discordAdminAccessService).revokeDiscordAdminAccess("uuid-1"); } + @Test + @DisplayName("player session commands update session state and refresh display when needed") + void playerSessionCommands_updateSessionStateAndRefreshDisplay() { + NetworkService network = mock(NetworkService.class); + SessionService sessionService = mock(SessionService.class); + PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); + DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); + + Config config = new Config(); + config.server = "mini-pvp"; + + ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + PlayerData playerData = new PlayerData(); + playerData.uuid = "uuid-1"; + playerData.customNickname = "Old"; + playerData.activeBadge = ""; + playerData.badgeSymbolColorMode = "default"; + Session session = new Session( + mock(GlobalConfig.class), + mock(Bundle.class), + mock(MenuService.class), + mock(PlayerDataRepository.class), + mock(Player.class), + playerData + ); + when(sessionService.get("uuid-1")).thenReturn(session); + + handler.registerListeners(); + + listener(listeners, PlayerCustomNicknameChangedCommandV1.class) + .get(new PlayerCustomNicknameChangedCommandV1("uuid-1", "Commander", "survival")); + listener(listeners, PlayerActiveBadgeChangedCommandV1.class) + .get(new PlayerActiveBadgeChangedCommandV1("uuid-1", "translator", "survival")); + listener(listeners, PlayerBadgeSymbolColorModeChangedCommandV1.class) + .get(new PlayerBadgeSymbolColorModeChangedCommandV1("uuid-1", "player-color", "survival")); + + assertThat(session.data.customNickname).isEqualTo("Commander"); + assertThat(session.data.activeBadge).isEqualTo("translator"); + assertThat(session.data.badgeSymbolColorMode).isEqualTo("player-color"); + verify(playerDisplayService, times(2)).refresh(session); + } + private static void captureListeners(NetworkService network, Map, Cons> listeners) { doAnswer(invocation -> { listeners.put(invocation.getArgument(0), invocation.getArgument(1)); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index e911727..31c352d 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -6,6 +6,9 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -141,6 +144,30 @@ void mapsLoadCommandUsesTypedPayloadServerContract() { assertThat(stream).isEqualTo("xcore:cmd:maps-load:survival"); } + @Test + @DisplayName("player session commands use typed payload server contract") + void playerSessionCommandsUseTypedPayloadServerContract() { + String customNicknameStream = registry.resolveStreamKey( + registry.routeDescriptorFor(PlayerCustomNicknameChangedCommandV1.class), + new PlayerCustomNicknameChangedCommandV1("uuid", "Commander", "survival"), + "mini-pvp" + ); + String activeBadgeStream = registry.resolveStreamKey( + registry.routeDescriptorFor(PlayerActiveBadgeChangedCommandV1.class), + new PlayerActiveBadgeChangedCommandV1("uuid", "translator", "hexed"), + "mini-pvp" + ); + String badgeColorModeStream = registry.resolveStreamKey( + registry.routeDescriptorFor(PlayerBadgeSymbolColorModeChangedCommandV1.class), + new PlayerBadgeSymbolColorModeChangedCommandV1("uuid", "player-color", "mini-hexed"), + "mini-pvp" + ); + + assertThat(customNicknameStream).isEqualTo("xcore:cmd:player-custom-nickname:survival"); + assertThat(activeBadgeStream).isEqualTo("xcore:cmd:player-active-badge:hexed"); + assertThat(badgeColorModeStream).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:mini-hexed"); + } + @Test @DisplayName("read-only and mutating classification comes from registry descriptors") void classificationComesFromRegistry() { @@ -155,6 +182,9 @@ void classificationComesFromRegistry() { assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(MapsLoadCommandV1.class)).isTrue(); + assertThat(registry.isMutatingType(PlayerCustomNicknameChangedCommandV1.class)).isTrue(); + assertThat(registry.isMutatingType(PlayerActiveBadgeChangedCommandV1.class)).isTrue(); + assertThat(registry.isMutatingType(PlayerBadgeSymbolColorModeChangedCommandV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); assertThat(registry.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 882e538..3299ed3 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -6,6 +6,9 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -156,8 +159,10 @@ void routeReadOnlyEvents() { void routeServerTargetedEvents() { var discordRoute = router.route(new ChatDiscordIngressCommandV1("bot", "hello", "mini-hexed"), "mini-pvp"); var mapsRoute = router.route(new MapsLoadCommandV1("event", List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav"))), "mini-pvp"); + var customNicknameRoute = router.route(new PlayerCustomNicknameChangedCommandV1("uuid-7", "Commander", "survival"), "mini-pvp"); + var activeBadgeRoute = router.route(new PlayerActiveBadgeChangedCommandV1("uuid-7", "translator", "mini-hexed"), "mini-pvp"); var badgeRoute = router.route(new TransportEvents.PlayerBadgeInventoryChanged("uuid-7", "translator", java.util.Set.of("translator")), "mini-pvp"); - var badgeColorModeRoute = router.route(new TransportEvents.PlayerBadgeSymbolColorModeChanged("uuid-7", "player-color"), "mini-pvp"); + var badgeColorModeRoute = router.route(new PlayerBadgeSymbolColorModeChangedCommandV1("uuid-7", "player-color", "hexed"), "mini-pvp"); var passwordRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); var discordLinkConfirmRoute = router.route( new DiscordLinkConfirmCommandV1( @@ -207,10 +212,14 @@ void routeServerTargetedEvents() { assertThat(discordRoute.eventType()).isEqualTo("chat.discord-ingress.command"); assertThat(mapsRoute.streamKey()).isEqualTo("xcore:cmd:maps-load:event"); assertThat(mapsRoute.eventType()).isEqualTo("maps.load.command"); + assertThat(customNicknameRoute.streamKey()).isEqualTo("xcore:cmd:player-custom-nickname:survival"); + assertThat(customNicknameRoute.eventType()).isEqualTo("player.custom-nickname.changed.command"); + assertThat(activeBadgeRoute.streamKey()).isEqualTo("xcore:cmd:player-active-badge:mini-hexed"); + assertThat(activeBadgeRoute.eventType()).isEqualTo("player.active-badge.changed.command"); assertThat(badgeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-inventory:mini-pvp"); assertThat(badgeRoute.eventType()).isEqualTo("player.badge_inventory"); - assertThat(badgeColorModeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:mini-pvp"); - assertThat(badgeColorModeRoute.eventType()).isEqualTo("player.badge_symbol_color_mode"); + assertThat(badgeColorModeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:hexed"); + assertThat(badgeColorModeRoute.eventType()).isEqualTo("player.badge-symbol-color-mode.changed.command"); assertThat(passwordRoute.streamKey()).isEqualTo("xcore:cmd:player-password-reset:mini-pvp"); assertThat(passwordRoute.eventType()).isEqualTo("player.password_reset"); assertThat(discordLinkConfirmRoute.streamKey()).isEqualTo("xcore:cmd:discord-link-confirm:mini-hexed"); @@ -271,12 +280,18 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(MapsLoadCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:maps-load:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.PlayerPasswordReset.class, "mini-pvp")) - .containsExactly("xcore:cmd:player-password-reset:mini-pvp"); + assertThat(router.subscribeStreamsFor(PlayerCustomNicknameChangedCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:player-custom-nickname:mini-pvp"); + + assertThat(router.subscribeStreamsFor(PlayerActiveBadgeChangedCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:player-active-badge:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(PlayerBadgeSymbolColorModeChangedCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:player-badge-symbol-color-mode:mini-pvp"); + assertThat(router.subscribeStreamsFor(TransportEvents.PlayerPasswordReset.class, "mini-pvp")) + .containsExactly("xcore:cmd:player-password-reset:mini-pvp"); + assertThat(router.subscribeStreamsFor(DiscordLinkConfirmCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:discord-link-confirm:mini-pvp"); @@ -328,7 +343,9 @@ void classificationAndResponseMapping() { assertThat(router.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); assertThat(router.isMutatingType(TransportEvents.PlayerPasswordReset.class)).isTrue(); - assertThat(router.isMutatingType(TransportEvents.PlayerBadgeSymbolColorModeChanged.class)).isTrue(); + assertThat(router.isMutatingType(PlayerCustomNicknameChangedCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(PlayerActiveBadgeChangedCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(PlayerBadgeSymbolColorModeChangedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(MapsLoadCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordLinkConfirmCommandV1.class)).isTrue(); assertThat(router.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 2f31a55..dc7390e 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -6,6 +6,9 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -165,6 +168,9 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec muteRoute = RedisTransportTopology.routeFor(ModerationMuteCreatedV1.class); RedisTransportTopology.RouteSpec discordLinkCodeRoute = RedisTransportTopology.routeFor(DiscordLinkCodeCreatedV1.class); RedisTransportTopology.RouteSpec discordStatusRoute = RedisTransportTopology.routeFor(DiscordLinkStatusChangedV1.class); + RedisTransportTopology.RouteSpec customNicknameCommandRoute = RedisTransportTopology.routeFor(PlayerCustomNicknameChangedCommandV1.class); + RedisTransportTopology.RouteSpec activeBadgeCommandRoute = RedisTransportTopology.routeFor(PlayerActiveBadgeChangedCommandV1.class); + RedisTransportTopology.RouteSpec badgeSymbolColorModeCommandRoute = RedisTransportTopology.routeFor(PlayerBadgeSymbolColorModeChangedCommandV1.class); RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); RedisTransportTopology.RouteSpec mapsLoadCommandRoute = RedisTransportTopology.routeFor(MapsLoadCommandV1.class); RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); @@ -185,6 +191,9 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec stableMuteRoute = muteRoute; RedisTransportTopology.RouteSpec stableDiscordLinkCodeRoute = discordLinkCodeRoute; RedisTransportTopology.RouteSpec stableDiscordStatusRoute = discordStatusRoute; + RedisTransportTopology.RouteSpec stableCustomNicknameCommandRoute = customNicknameCommandRoute; + RedisTransportTopology.RouteSpec stableActiveBadgeCommandRoute = activeBadgeCommandRoute; + RedisTransportTopology.RouteSpec stableBadgeSymbolColorModeCommandRoute = badgeSymbolColorModeCommandRoute; RedisTransportTopology.RouteSpec stableCommandRoute = commandRoute; RedisTransportTopology.RouteSpec stableMapsLoadCommandRoute = mapsLoadCommandRoute; RedisTransportTopology.RouteSpec stableDiscordAdminCommandRoute = discordAdminCommandRoute; @@ -278,6 +287,30 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableDiscordStatusRoute.readOnly()).isTrue(); assertThat(stableDiscordStatusRoute.rpcRequest()).isFalse(); + assertThat(stableCustomNicknameCommandRoute).isNotNull(); + assertThat(stableCustomNicknameCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-custom-nickname:{server}"); + assertThat(stableCustomNicknameCommandRoute.eventType()).isEqualTo("player.custom-nickname.changed.command"); + assertThat(stableCustomNicknameCommandRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); + assertThat(stableCustomNicknameCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableCustomNicknameCommandRoute.readOnly()).isFalse(); + assertThat(stableCustomNicknameCommandRoute.rpcRequest()).isFalse(); + + assertThat(stableActiveBadgeCommandRoute).isNotNull(); + assertThat(stableActiveBadgeCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-active-badge:{server}"); + assertThat(stableActiveBadgeCommandRoute.eventType()).isEqualTo("player.active-badge.changed.command"); + assertThat(stableActiveBadgeCommandRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); + assertThat(stableActiveBadgeCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableActiveBadgeCommandRoute.readOnly()).isFalse(); + assertThat(stableActiveBadgeCommandRoute.rpcRequest()).isFalse(); + + assertThat(stableBadgeSymbolColorModeCommandRoute).isNotNull(); + assertThat(stableBadgeSymbolColorModeCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:{server}"); + assertThat(stableBadgeSymbolColorModeCommandRoute.eventType()).isEqualTo("player.badge-symbol-color-mode.changed.command"); + assertThat(stableBadgeSymbolColorModeCommandRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); + assertThat(stableBadgeSymbolColorModeCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); + assertThat(stableBadgeSymbolColorModeCommandRoute.readOnly()).isFalse(); + assertThat(stableBadgeSymbolColorModeCommandRoute.rpcRequest()).isFalse(); + assertThat(stableCommandRoute).isNotNull(); assertThat(stableCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-password-reset:{server}"); assertThat(stableCommandRoute.eventType()).isEqualTo("player.password_reset"); @@ -336,16 +369,28 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { @DisplayName("registry and router remain aligned with explicit transport topology") void registryAndRouterRemainAlignedWithExplicitTransportTopology() { // Arrange + RedisTransportTopology.RouteSpec playerSessionSpec = RedisTransportTopology.routeFor(PlayerCustomNicknameChangedCommandV1.class); RedisTransportTopology.RouteSpec commandSpec = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); RedisTransportTopology.RouteSpec rpcSpec = RedisTransportTopology.routeFor(MapsListRequestV1.class); + RedisRouteDescriptor playerSessionDescriptor = registry.routeDescriptorFor(PlayerCustomNicknameChangedCommandV1.class); RedisRouteDescriptor commandDescriptor = registry.routeDescriptorFor(TransportEvents.PlayerPasswordReset.class); RedisRouteDescriptor rpcDescriptor = registry.routeDescriptorFor(MapsListRequestV1.class); // Act + var playerSessionRoute = router.route(new PlayerCustomNicknameChangedCommandV1("uuid-7", "Commander", "survival"), "mini-pvp"); var commandRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); List rpcSubscriptions = router.subscribeStreamsFor(MapsListRequestV1.class, "mini-pvp"); // Assert + assertThat(playerSessionDescriptor).isNotNull(); + assertThat(playerSessionDescriptor.streamPattern()).isEqualTo(playerSessionSpec.streamPattern()); + assertThat(playerSessionDescriptor.eventType()).isEqualTo(playerSessionSpec.eventType()); + assertThat(playerSessionDescriptor.isMutating()).isTrue(); + assertThat(playerSessionDescriptor.isReadOnly()).isFalse(); + assertThat(playerSessionRoute.streamKey()).isEqualTo("xcore:cmd:player-custom-nickname:survival"); + assertThat(playerSessionRoute.eventType()).isEqualTo("player.custom-nickname.changed.command"); + assertThat(playerSessionRoute.streamKey()).doesNotStartWith("xcore:evt:"); + assertThat(commandDescriptor).isNotNull(); assertThat(commandDescriptor.streamPattern()).isEqualTo(commandSpec.streamPattern()); assertThat(commandDescriptor.eventType()).isEqualTo(commandSpec.eventType()); From 8d8cc11e43107d6136edca21f6c49eccd7cbbc52 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Fri, 1 May 2026 21:12:21 +0300 Subject: [PATCH 18/26] feat(network): migrate badge inventory and password reset command DTOs to V1 Switch producer (BadgeAdminController) and consumer (ModerationTransportHandler) from legacy TransportEvents records to ChatMessages V1 command DTOs. Update routing (RedisRouteRegistry, RedisTransportTopology) to use PAYLOAD_SERVER resolver and canonical event type names. Remove dead legacy types PlayerBadgeInventoryChanged/PlayerPasswordReset from TransportEvents. Add ModerationTransportHandler consumer tests. --- .../server/BadgeAdminController.java | 14 +++- .../xcore/plugin/event/TransportEvents.java | 5 -- .../transport/ModerationTransportHandler.java | 12 +-- .../service/network/RedisRouteRegistry.java | 12 ++- .../network/RedisTransportTopology.java | 6 +- .../ModerationTransportHandlerTest.java | 82 ++++++++++++++++++- .../network/RedisRouteRegistryTest.java | 12 +-- .../network/RedisStreamRouterTest.java | 15 ++-- .../network/RedisTransportContractsTest.java | 18 ++-- 9 files changed, 137 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java b/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java index f0a14e0..031d8df 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java @@ -7,16 +7,18 @@ import org.incendo.cloud.annotations.Command; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudServerController; +import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.PlayerDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.player.Badge; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; +import org.xcore.protocol.generated.messages.chat.ChatMessages; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import java.util.regex.Pattern; @@ -30,16 +32,19 @@ public class BadgeAdminController implements CloudServerController { private final NetworkService network; private final PlayerDisplayService playerDisplayService; private final PlayerDataRepository playerDataRepository; + private final Config config; @Inject public BadgeAdminController(SessionService sessionService, NetworkService network, PlayerDisplayService playerDisplayService, - PlayerDataRepository playerDataRepository) { + PlayerDataRepository playerDataRepository, + Config config) { this.sessionService = sessionService; this.network = network; this.playerDisplayService = playerDisplayService; this.playerDataRepository = playerDataRepository; + this.config = config; } @Command("badge grant ") @@ -115,10 +120,11 @@ private void changeBadge(XCoreSender sender, String playerRef, String badgeId, b persistBadgeState(target); } - network.post(new TransportEvents.PlayerBadgeInventoryChanged( + network.post(new ChatMessages.PlayerBadgeInventoryChangedCommandV1( target.uuid, updatedActiveBadge, - copyBadges(updatedBadges) + List.copyOf(updatedBadges), + config.server )); if (grant) { Log.info("Granted badge '@' to @ (#@).", badge.id(), target.nickname, target.pid); diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index e865406..789d593 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -2,7 +2,6 @@ import java.time.Instant; import java.util.List; -import java.util.Set; public class TransportEvents { public interface Event {} @@ -13,10 +12,6 @@ public interface ServerScopedEvent { public record KickBannedPlayer(String uuid, String ip) {} - public record PlayerBadgeInventoryChanged(String uuid, String activeBadge, Set unlockedBadges) {} - - public record PlayerPasswordReset(String uuid) {} - public record VoteKickParticipant( String name, Integer pid, diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index e23081a..a4b6dbd 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -16,8 +16,10 @@ import org.xcore.plugin.service.PlayerDisplayService; import org.xcore.plugin.session.SessionService; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; @@ -108,18 +110,18 @@ public void registerListeners() { "badge symbol color mode" )); - network.subscribe(TransportEvents.PlayerBadgeInventoryChanged.class, e -> updatePlayerSession( - e.uuid(), + network.subscribe(PlayerBadgeInventoryChangedCommandV1.class, e -> updatePlayerSession( + e.playerUuid(), data -> { data.activeBadge = e.activeBadge(); - data.unlockedBadges = e.unlockedBadges() == null ? new HashSet<>() : new HashSet<>(e.unlockedBadges()); + data.unlockedBadges = new HashSet<>(e.unlockedBadges()); }, true, "badge inventory" )); - network.subscribe(TransportEvents.PlayerPasswordReset.class, e -> updatePlayerSession( - e.uuid(), + network.subscribe(PlayerPasswordResetCommandV1.class, e -> updatePlayerSession( + e.playerUuid(), data -> data.password = "", false, "password reset" diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 321269d..50f1adc 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -6,8 +6,10 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -149,8 +151,8 @@ private void registerDefaults() { register(mutating(PlayerCustomNicknameChangedCommandV1.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom-nickname.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(PlayerActiveBadgeChangedCommandV1.class, "xcore:cmd:player-active-badge:{server}", "player.active-badge.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(PlayerBadgeSymbolColorModeChangedCommandV1.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge-symbol-color-mode.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); - register(mutating(TransportEvents.PlayerBadgeInventoryChanged.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge_inventory", 120_000L, RedisServerResolver.defaultServer())); - register(mutating(TransportEvents.PlayerPasswordReset.class, "xcore:cmd:player-password-reset:{server}", "player.password_reset", 120_000L, RedisServerResolver.defaultServer())); + register(mutating(PlayerBadgeInventoryChangedCommandV1.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge-inventory.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); + register(mutating(PlayerPasswordResetCommandV1.class, "xcore:cmd:player-password-reset:{server}", "player.password-reset.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(DiscordLinkCodeCreatedV1.class, "xcore:evt:discord:link-code", "discord.link-code-created", 120_000L, RedisServerResolver.broadcast())); register(mutating(DiscordLinkConfirmCommandV1.class, "xcore:cmd:discord-link-confirm:{server}", "discord.link.confirm.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(DiscordUnlinkCommandV1.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); @@ -231,6 +233,12 @@ private static String playerSessionServer(Object payload) { if (payload instanceof PlayerBadgeSymbolColorModeChangedCommandV1 command) { return command.server(); } + if (payload instanceof PlayerBadgeInventoryChangedCommandV1 command) { + return command.server(); + } + if (payload instanceof PlayerPasswordResetCommandV1 command) { + return command.server(); + } return null; } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 6dcf2a7..953aa15 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -6,8 +6,10 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -79,8 +81,8 @@ public record RouteSpec( route(PlayerCustomNicknameChangedCommandV1.class, "xcore:cmd:player-custom-nickname:{server}", "player.custom-nickname.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(PlayerActiveBadgeChangedCommandV1.class, "xcore:cmd:player-active-badge:{server}", "player.active-badge.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(PlayerBadgeSymbolColorModeChangedCommandV1.class, "xcore:cmd:player-badge-symbol-color-mode:{server}", "player.badge-symbol-color-mode.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - route(TransportEvents.PlayerBadgeInventoryChanged.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge_inventory", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), - route(TransportEvents.PlayerPasswordReset.class, "xcore:cmd:player-password-reset:{server}", "player.password_reset", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(PlayerBadgeInventoryChangedCommandV1.class, "xcore:cmd:player-badge-inventory:{server}", "player.badge-inventory.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), + route(PlayerPasswordResetCommandV1.class, "xcore:cmd:player-password-reset:{server}", "player.password-reset.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(DiscordLinkCodeCreatedV1.class, "xcore:evt:discord:link-code", "discord.link-code-created", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(DiscordLinkConfirmCommandV1.class, "xcore:cmd:discord-link-confirm:{server}", "discord.link.confirm.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(DiscordUnlinkCommandV1.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), diff --git a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java index 12a02f7..b7169ac 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -12,7 +12,6 @@ import org.junit.jupiter.api.Test; import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.service.DiscordAdminAccessService; @@ -23,13 +22,16 @@ import org.xcore.plugin.session.SessionService; import org.xcore.plugin.ui.MenuService; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -173,6 +175,84 @@ void playerSessionCommands_updateSessionStateAndRefreshDisplay() { verify(playerDisplayService, times(2)).refresh(session); } + @Test + @DisplayName("badge inventory command updates unlocked badges and refreshes display") + void badgeInventoryCommand_updatesUnlockedBadgesAndRefreshesDisplay() { + NetworkService network = mock(NetworkService.class); + SessionService sessionService = mock(SessionService.class); + PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); + DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); + + Config config = new Config(); + config.server = "mini-pvp"; + + ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + PlayerData playerData = new PlayerData(); + playerData.uuid = "uuid-1"; + playerData.activeBadge = "old-badge"; + playerData.unlockedBadges = new java.util.HashSet<>(); + Session session = new Session( + mock(GlobalConfig.class), + mock(Bundle.class), + mock(MenuService.class), + mock(PlayerDataRepository.class), + mock(Player.class), + playerData + ); + when(sessionService.get("uuid-1")).thenReturn(session); + + handler.registerListeners(); + + listener(listeners, PlayerBadgeInventoryChangedCommandV1.class) + .get(new PlayerBadgeInventoryChangedCommandV1("uuid-1", "translator", List.of("translator", "contributor"), "survival")); + + assertThat(session.data.activeBadge).isEqualTo("translator"); + assertThat(session.data.unlockedBadges).containsExactlyInAnyOrder("translator", "contributor"); + verify(playerDisplayService, times(1)).refresh(session); + } + + @Test + @DisplayName("password reset command clears password without refresh") + void passwordResetCommand_clearsPasswordWithoutRefresh() { + NetworkService network = mock(NetworkService.class); + SessionService sessionService = mock(SessionService.class); + PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); + DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); + + Config config = new Config(); + config.server = "mini-pvp"; + + ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + PlayerData playerData = new PlayerData(); + playerData.uuid = "uuid-1"; + playerData.password = "old-hash"; + Session session = new Session( + mock(GlobalConfig.class), + mock(Bundle.class), + mock(MenuService.class), + mock(PlayerDataRepository.class), + mock(Player.class), + playerData + ); + when(sessionService.get("uuid-1")).thenReturn(session); + + handler.registerListeners(); + + listener(listeners, PlayerPasswordResetCommandV1.class) + .get(new PlayerPasswordResetCommandV1("uuid-1", "survival")); + + assertThat(session.data.password).isEmpty(); + verify(playerDisplayService, times(0)).refresh(any()); + } + private static void captureListeners(NetworkService network, Map, Cons> listeners) { doAnswer(invocation -> { listeners.put(invocation.getArgument(0), invocation.getArgument(1)); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 31c352d..89cb829 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -7,8 +7,10 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -100,17 +102,17 @@ void unlinkCommandUsesTypedPayloadServerContract() { } @Test - @DisplayName("default server resolver keeps server-local mutating events on current server") - void defaultServerResolverUsesDefaultServer() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(TransportEvents.PlayerPasswordReset.class); + @DisplayName("player password reset command uses typed payload server contract") + void playerPasswordResetCommandUsesTypedPayloadServerContract() { + RedisRouteDescriptor descriptor = registry.routeDescriptorFor(PlayerPasswordResetCommandV1.class); String stream = registry.resolveStreamKey( descriptor, - new TransportEvents.PlayerPasswordReset("uuid-1"), + new PlayerPasswordResetCommandV1("uuid-1", "survival"), "mini-pvp" ); - assertThat(stream).isEqualTo("xcore:cmd:player-password-reset:mini-pvp"); + assertThat(stream).isEqualTo("xcore:cmd:player-password-reset:survival"); } @Test diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 3299ed3..d02db25 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -7,8 +7,10 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -34,7 +36,6 @@ import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.ModerationTargetRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.BanData; import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.Punishment; @@ -161,9 +162,9 @@ void routeServerTargetedEvents() { var mapsRoute = router.route(new MapsLoadCommandV1("event", List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav"))), "mini-pvp"); var customNicknameRoute = router.route(new PlayerCustomNicknameChangedCommandV1("uuid-7", "Commander", "survival"), "mini-pvp"); var activeBadgeRoute = router.route(new PlayerActiveBadgeChangedCommandV1("uuid-7", "translator", "mini-hexed"), "mini-pvp"); - var badgeRoute = router.route(new TransportEvents.PlayerBadgeInventoryChanged("uuid-7", "translator", java.util.Set.of("translator")), "mini-pvp"); + var badgeRoute = router.route(new PlayerBadgeInventoryChangedCommandV1("uuid-7", "translator", List.of("translator"), "mini-pvp"), "mini-pvp"); var badgeColorModeRoute = router.route(new PlayerBadgeSymbolColorModeChangedCommandV1("uuid-7", "player-color", "hexed"), "mini-pvp"); - var passwordRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); + var passwordRoute = router.route(new PlayerPasswordResetCommandV1("uuid-7", "mini-pvp"), "mini-pvp"); var discordLinkConfirmRoute = router.route( new DiscordLinkConfirmCommandV1( "ABC123", @@ -217,11 +218,11 @@ void routeServerTargetedEvents() { assertThat(activeBadgeRoute.streamKey()).isEqualTo("xcore:cmd:player-active-badge:mini-hexed"); assertThat(activeBadgeRoute.eventType()).isEqualTo("player.active-badge.changed.command"); assertThat(badgeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-inventory:mini-pvp"); - assertThat(badgeRoute.eventType()).isEqualTo("player.badge_inventory"); + assertThat(badgeRoute.eventType()).isEqualTo("player.badge-inventory.changed.command"); assertThat(badgeColorModeRoute.streamKey()).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:hexed"); assertThat(badgeColorModeRoute.eventType()).isEqualTo("player.badge-symbol-color-mode.changed.command"); assertThat(passwordRoute.streamKey()).isEqualTo("xcore:cmd:player-password-reset:mini-pvp"); - assertThat(passwordRoute.eventType()).isEqualTo("player.password_reset"); + assertThat(passwordRoute.eventType()).isEqualTo("player.password-reset.command"); assertThat(discordLinkConfirmRoute.streamKey()).isEqualTo("xcore:cmd:discord-link-confirm:mini-hexed"); assertThat(discordLinkConfirmRoute.eventType()).isEqualTo("discord.link.confirm.command"); assertThat(discordLinkStatusRoute.streamKey()).isEqualTo("xcore:evt:discord:link-status"); @@ -289,7 +290,7 @@ void subscribeStreamsForTypes() { assertThat(router.subscribeStreamsFor(PlayerBadgeSymbolColorModeChangedCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:player-badge-symbol-color-mode:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.PlayerPasswordReset.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(PlayerPasswordResetCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:player-password-reset:mini-pvp"); assertThat(router.subscribeStreamsFor(DiscordLinkConfirmCommandV1.class, "mini-pvp")) @@ -342,7 +343,7 @@ void classificationAndResponseMapping() { assertThat(router.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); - assertThat(router.isMutatingType(TransportEvents.PlayerPasswordReset.class)).isTrue(); + assertThat(router.isMutatingType(PlayerPasswordResetCommandV1.class)).isTrue(); assertThat(router.isMutatingType(PlayerCustomNicknameChangedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(PlayerActiveBadgeChangedCommandV1.class)).isTrue(); assertThat(router.isMutatingType(PlayerBadgeSymbolColorModeChangedCommandV1.class)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index dc7390e..6372f19 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -7,8 +7,10 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -171,7 +173,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec customNicknameCommandRoute = RedisTransportTopology.routeFor(PlayerCustomNicknameChangedCommandV1.class); RedisTransportTopology.RouteSpec activeBadgeCommandRoute = RedisTransportTopology.routeFor(PlayerActiveBadgeChangedCommandV1.class); RedisTransportTopology.RouteSpec badgeSymbolColorModeCommandRoute = RedisTransportTopology.routeFor(PlayerBadgeSymbolColorModeChangedCommandV1.class); - RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); + RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(PlayerPasswordResetCommandV1.class); RedisTransportTopology.RouteSpec mapsLoadCommandRoute = RedisTransportTopology.routeFor(MapsLoadCommandV1.class); RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); @@ -313,9 +315,9 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { assertThat(stableCommandRoute).isNotNull(); assertThat(stableCommandRoute.streamPattern()).isEqualTo("xcore:cmd:player-password-reset:{server}"); - assertThat(stableCommandRoute.eventType()).isEqualTo("player.password_reset"); + assertThat(stableCommandRoute.eventType()).isEqualTo("player.password-reset.command"); assertThat(stableCommandRoute.deliveryMode()).isEqualTo(RedisTransportTopology.DeliveryMode.COMMAND); - assertThat(stableCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.DEFAULT_SERVER); + assertThat(stableCommandRoute.serverScope()).isEqualTo(RedisTransportTopology.ServerScope.PAYLOAD_SERVER); assertThat(stableCommandRoute.readOnly()).isFalse(); assertThat(stableCommandRoute.rpcRequest()).isFalse(); @@ -370,15 +372,15 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { void registryAndRouterRemainAlignedWithExplicitTransportTopology() { // Arrange RedisTransportTopology.RouteSpec playerSessionSpec = RedisTransportTopology.routeFor(PlayerCustomNicknameChangedCommandV1.class); - RedisTransportTopology.RouteSpec commandSpec = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); + RedisTransportTopology.RouteSpec commandSpec = RedisTransportTopology.routeFor(PlayerPasswordResetCommandV1.class); RedisTransportTopology.RouteSpec rpcSpec = RedisTransportTopology.routeFor(MapsListRequestV1.class); RedisRouteDescriptor playerSessionDescriptor = registry.routeDescriptorFor(PlayerCustomNicknameChangedCommandV1.class); - RedisRouteDescriptor commandDescriptor = registry.routeDescriptorFor(TransportEvents.PlayerPasswordReset.class); + RedisRouteDescriptor commandDescriptor = registry.routeDescriptorFor(PlayerPasswordResetCommandV1.class); RedisRouteDescriptor rpcDescriptor = registry.routeDescriptorFor(MapsListRequestV1.class); // Act var playerSessionRoute = router.route(new PlayerCustomNicknameChangedCommandV1("uuid-7", "Commander", "survival"), "mini-pvp"); - var commandRoute = router.route(new TransportEvents.PlayerPasswordReset("uuid-7"), "mini-pvp"); + var commandRoute = router.route(new PlayerPasswordResetCommandV1("uuid-7", "survival"), "mini-pvp"); List rpcSubscriptions = router.subscribeStreamsFor(MapsListRequestV1.class, "mini-pvp"); // Assert @@ -396,8 +398,8 @@ void registryAndRouterRemainAlignedWithExplicitTransportTopology() { assertThat(commandDescriptor.eventType()).isEqualTo(commandSpec.eventType()); assertThat(commandDescriptor.isMutating()).isTrue(); assertThat(commandDescriptor.isReadOnly()).isFalse(); - assertThat(commandRoute.streamKey()).isEqualTo("xcore:cmd:player-password-reset:mini-pvp"); - assertThat(commandRoute.eventType()).isEqualTo("player.password_reset"); + assertThat(commandRoute.streamKey()).isEqualTo("xcore:cmd:player-password-reset:survival"); + assertThat(commandRoute.eventType()).isEqualTo("player.password-reset.command"); assertThat(commandRoute.streamKey()).doesNotStartWith("xcore:evt:"); assertThat(rpcDescriptor).isNotNull(); From b76d5b1b17efd247103af83c1b6dd784946352ed Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sat, 2 May 2026 11:14:23 +0300 Subject: [PATCH 19/26] feat(network): migrate final chat command DTOs to V1 --- .../controller/server/MaintainController.java | 12 +++-- .../xcore/plugin/event/TransportEvents.java | 53 ------------------- .../transport/ModerationTransportHandler.java | 16 +++--- .../service/network/RedisRouteRegistry.java | 9 +++- .../network/RedisTransportTopology.java | 7 +-- .../server/MaintainControllerTest.java | 23 ++++---- .../RedisNetworkBackendIntegrationTest.java | 16 +++--- .../network/RedisRouteRegistryTest.java | 4 +- .../network/RedisTransportContractsTest.java | 4 +- 9 files changed, 51 insertions(+), 93 deletions(-) diff --git a/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java index 7bc1d71..7b50ad4 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java @@ -20,8 +20,9 @@ import org.xcore.plugin.common.PluginState; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.PlayerDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.enums.Feature; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.plugin.service.MapIdentityAuditService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.TopMenuCacheService; @@ -29,6 +30,7 @@ import org.xcore.plugin.session.SessionService; import java.util.Arrays; +import java.util.List; import java.util.Set; import static com.ospx.flubundle.Bundle.args; @@ -46,6 +48,7 @@ public class MaintainController implements CloudServerController { private final PlayerDataRepository playerDataRepository; private final PluginState pluginState; private final SessionService sessionService; + private final Config config; private final RuntimeToggleConfigService toggleConfigService; private final MapIdentityAuditService mapIdentityAuditService; private final TopMenuCacheService topMenuCacheService; @@ -64,6 +67,7 @@ public MaintainController(NetworkService network, this.playerDataRepository = playerDataRepository; this.pluginState = pluginState; this.sessionService = sessionService; + this.config = config; this.mapIdentityAuditService = mapIdentityAuditService; this.topMenuCacheService = topMenuCacheService; this.toggleConfigService = new RuntimeToggleConfigService(config, configFile, prettyGson); @@ -160,7 +164,7 @@ public void deleteBots(XCoreSender sender) { if (deleted > 0 && topMenuCacheService != null) { topMenuCacheService.invalidateAll(); } - network.post(new TransportEvents.ReloadPlayerDataCache()); + network.post(new PlayerDataCacheReloadCommandV1(config.server)); Log.info("Deleted @ bots from database.", deleted); } @@ -220,7 +224,7 @@ public void gcmd(XCoreSender sender, if (targets.length == 0) { Log.info("Dispatching '@' to [ALL]", normalizedCommand); - network.post(new TransportEvents.ExecuteCommand(normalizedCommand, new String[0], false)); + network.post(new ServerCommandExecuteCommandV1(normalizedCommand, List.of(), false)); return; } @@ -230,7 +234,7 @@ public void gcmd(XCoreSender sender, Log.info("Dispatching '@' to @", normalizedCommand, Seq.with(targets)); } - network.post(new TransportEvents.ExecuteCommand(normalizedCommand, targets, except)); + network.post(new ServerCommandExecuteCommandV1(normalizedCommand, Arrays.asList(targets), except)); } private String[] parseTargetList(String targetsCsv) { diff --git a/src/main/java/org/xcore/plugin/event/TransportEvents.java b/src/main/java/org/xcore/plugin/event/TransportEvents.java index 789d593..2c0348a 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -1,8 +1,5 @@ package org.xcore.plugin.event; -import java.time.Instant; -import java.util.List; - public class TransportEvents { public interface Event {} @@ -10,54 +7,4 @@ public interface ServerScopedEvent { String server(); } - public record KickBannedPlayer(String uuid, String ip) {} - - public record VoteKickParticipant( - String name, - Integer pid, - String discordId - ) {} - - public record VoteKickEvent( - String targetName, - Integer targetPid, - String targetUuid, - String starterName, - Integer starterPid, - String starterDiscordId, - String reason, - List votesFor, - List votesAgainst, - String status, - String server, - long occurredAt - ) implements Event, ServerScopedEvent {} - - public record ModerationAuditAppendedEvent( - String auditId, - String action, - String targetUuid, - Integer targetPid, - String targetName, - String actorType, - String actorId, - String actorName, - String reason, - Long durationMs, - Instant expiresAt, - String relatedAuditId, - String server, - Instant occurredAt - ) implements Event, ServerScopedEvent {} - - public static class ReloadPlayerDataCache {} - - public record ExecuteCommand(String command, String[] expectServers, boolean isExclusion) { - public ExecuteCommand(String command, String[] expectServers) { - this(command, expectServers, false); - } - } - - public record PardonPlayer(String uuid) {} - } diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index a4b6dbd..4b20c05 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -1,7 +1,6 @@ package org.xcore.plugin.event.transport; import arc.util.Log; -import arc.util.Structs; import jakarta.inject.Inject; import jakarta.inject.Singleton; import mindustry.gen.Groups; @@ -9,7 +8,6 @@ import mindustry.net.Packets; import mindustry.server.ServerControl; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.DiscordAdminAccessService; import org.xcore.plugin.service.NetworkService; @@ -19,7 +17,9 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; @@ -127,16 +127,16 @@ public void registerListeners() { "password reset" )); - network.subscribe(TransportEvents.ReloadPlayerDataCache.class, _ -> { + network.subscribe(PlayerDataCacheReloadCommandV1.class, _ -> { sessionService.reloadCache(); info("Reloaded player data cache."); }); - network.subscribe(TransportEvents.ExecuteCommand.class, e -> { - if (e.expectServers() != null) { - if (e.isExclusion()) { - if (Structs.contains(e.expectServers(), config.server)) return; - } else if (e.expectServers().length > 0 && !Structs.contains(e.expectServers(), config.server)) { + network.subscribe(ServerCommandExecuteCommandV1.class, e -> { + if (!e.targetServers().isEmpty()) { + if (e.exclusion()) { + if (e.targetServers().contains(config.server)) return; + } else if (!e.targetServers().contains(config.server)) { return; } } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 50f1adc..351b525 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -9,9 +9,11 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; @@ -158,9 +160,9 @@ private void registerDefaults() { register(mutating(DiscordUnlinkCommandV1.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(readOnly(DiscordLinkStatusChangedV1.class, "xcore:evt:discord:link-status", "discord.link.status-changed", 120_000L, RedisServerResolver.broadcast())); register(mutating(DiscordAdminAccessChangedCommandV1.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin-access.changed.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); - register(mutating(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, RedisServerResolver.defaultServer())); + register(mutating(PlayerDataCacheReloadCommandV1.class, "xcore:cmd:reload-cache:{server}", "player-data-cache.reload.command", 120_000L, PAYLOAD_SERVER_RESOLVER)); register(mutating(MapsLoadCommandV1.class, "xcore:cmd:maps-load:{server}", "maps.load.command", 300_000L, PAYLOAD_SERVER_RESOLVER)); - register(mutating(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, RedisServerResolver.broadcast())); + register(mutating(ServerCommandExecuteCommandV1.class, "xcore:cmd:execute-command:broadcast", "server-command.execute.command", 120_000L, RedisServerResolver.broadcast())); register(mutating(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, MODERATION_SERVER_RESOLVER)); register(rpc(MapsListRequestV1.class, "xcore:rpc:req:{server}", "maps.list.request", 10_000L, PAYLOAD_SERVER_RESOLVER, MapsListResponseV1.class)); register(rpc(MapsRemoveRequestV1.class, "xcore:rpc:req:{server}", "maps.remove.request", 10_000L, PAYLOAD_SERVER_RESOLVER, MapsRemoveResponseV1.class)); @@ -239,6 +241,9 @@ private static String playerSessionServer(Object payload) { if (payload instanceof PlayerPasswordResetCommandV1 command) { return command.server(); } + if (payload instanceof PlayerDataCacheReloadCommandV1 command) { + return command.server(); + } return null; } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java index 953aa15..7368723 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisTransportTopology.java @@ -1,6 +1,7 @@ package org.xcore.plugin.service.network; -import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; @@ -88,9 +89,9 @@ public record RouteSpec( route(DiscordUnlinkCommandV1.class, "xcore:cmd:discord-unlink:{server}", "discord.unlink.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(DiscordLinkStatusChangedV1.class, "xcore:evt:discord:link-status", "discord.link.status-changed", 120_000L, DeliveryMode.EVENT, ServerScope.BROADCAST, true), route(DiscordAdminAccessChangedCommandV1.class, "xcore:cmd:discord-admin-access:{server}", "discord.admin-access.changed.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - route(TransportEvents.ReloadPlayerDataCache.class, "xcore:cmd:reload-cache:{server}", "cache.reload_player_data", 120_000L, DeliveryMode.COMMAND, ServerScope.DEFAULT_SERVER, false), + route(PlayerDataCacheReloadCommandV1.class, "xcore:cmd:reload-cache:{server}", "player-data-cache.reload.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), route(MapsLoadCommandV1.class, "xcore:cmd:maps-load:{server}", "maps.load.command", 300_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), - route(TransportEvents.ExecuteCommand.class, "xcore:cmd:execute-command:broadcast", "server.execute_command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), + route(ServerCommandExecuteCommandV1.class, "xcore:cmd:execute-command:broadcast", "server-command.execute.command", 120_000L, DeliveryMode.COMMAND, ServerScope.BROADCAST, false), route(ModerationPardonCommandV1.class, "xcore:cmd:pardon-player:{server}", "moderation.pardon.command", 120_000L, DeliveryMode.COMMAND, ServerScope.PAYLOAD_SERVER, false), rpcRoute(MapsListRequestV1.class, "xcore:rpc:req:{server}", "maps.list.request", 10_000L, ServerScope.PAYLOAD_SERVER, MapsListResponseV1.class), rpcRoute(MapsRemoveRequestV1.class, "xcore:rpc:req:{server}", "maps.remove.request", 10_000L, ServerScope.PAYLOAD_SERVER, MapsRemoveResponseV1.class) diff --git a/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java index e41b7d9..e6c0794 100644 --- a/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java @@ -8,7 +8,8 @@ import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.PlayerDataRepository; -import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.plugin.service.MapIdentityAuditService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.TopMenuCacheService; @@ -50,13 +51,13 @@ void gcmdParsesCommaSeparatedTargets() { controller.gcmd(sender, "say hello world", "mini-pvp,mini-hexed", false); - var captor = ArgumentCaptor.forClass(TransportEvents.ExecuteCommand.class); + var captor = ArgumentCaptor.forClass(ServerCommandExecuteCommandV1.class); verify(network).post(captor.capture()); var event = captor.getValue(); assertThat(event.command()).isEqualTo("say hello world"); - assertThat(event.expectServers()).containsExactly("mini-pvp", "mini-hexed"); - assertThat(event.isExclusion()).isFalse(); + assertThat(event.targetServers()).containsExactly("mini-pvp", "mini-hexed"); + assertThat(event.exclusion()).isFalse(); } @Test @@ -85,13 +86,13 @@ void gcmdFallsBackToAllServers() { controller.gcmd(sender, "say hello world", null, false); - var captor = ArgumentCaptor.forClass(TransportEvents.ExecuteCommand.class); + var captor = ArgumentCaptor.forClass(ServerCommandExecuteCommandV1.class); verify(network).post(captor.capture()); var event = captor.getValue(); assertThat(event.command()).isEqualTo("say hello world"); - assertThat(event.expectServers()).isEmpty(); - assertThat(event.isExclusion()).isFalse(); + assertThat(event.targetServers()).isEmpty(); + assertThat(event.exclusion()).isFalse(); } @Test @@ -120,13 +121,13 @@ void gcmdPreservesExclusionMode() { controller.gcmd(sender, "say hello world", "mini-pvp,mini-hexed", true); - var captor = ArgumentCaptor.forClass(TransportEvents.ExecuteCommand.class); + var captor = ArgumentCaptor.forClass(ServerCommandExecuteCommandV1.class); verify(network).post(captor.capture()); var event = captor.getValue(); assertThat(event.command()).isEqualTo("say hello world"); - assertThat(event.expectServers()).containsExactly("mini-pvp", "mini-hexed"); - assertThat(event.isExclusion()).isTrue(); + assertThat(event.targetServers()).containsExactly("mini-pvp", "mini-hexed"); + assertThat(event.exclusion()).isTrue(); } @Test @@ -252,6 +253,6 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { verify(repository).deleteBots(); verify(topMenuCacheService).invalidateAll(); - verify(network).post(org.mockito.ArgumentMatchers.any(TransportEvents.ReloadPlayerDataCache.class)); + verify(network).post(org.mockito.ArgumentMatchers.any(PlayerDataCacheReloadCommandV1.class)); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 4660a9c..cf999a5 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -28,7 +28,7 @@ import org.xcore.protocol.generated.shared.VoteKickParticipantV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.plugin.model.BanData; import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.Punishment; @@ -166,25 +166,25 @@ void executeCommandBroadcastDeliveredAcrossServers() throws InterruptedException CountDownLatch alphaLatch = new CountDownLatch(1); CountDownLatch betaLatch = new CountDownLatch(1); - AtomicReference alphaReceived = new AtomicReference<>(); - AtomicReference betaReceived = new AtomicReference<>(); + AtomicReference alphaReceived = new AtomicReference<>(); + AtomicReference betaReceived = new AtomicReference<>(); - Subscription alphaSubscription = serverBackend.subscribe( - TransportEvents.ExecuteCommand.class, + Subscription alphaSubscription = serverBackend.subscribe( + ServerCommandExecuteCommandV1.class, event -> { alphaReceived.set(event); alphaLatch.countDown(); } ); - Subscription betaSubscription = requesterBackend.subscribe( - TransportEvents.ExecuteCommand.class, + Subscription betaSubscription = requesterBackend.subscribe( + ServerCommandExecuteCommandV1.class, event -> { betaReceived.set(event); betaLatch.countDown(); } ); - serverBackend.send(new TransportEvents.ExecuteCommand("status", new String[0], false)); + serverBackend.send(new ServerCommandExecuteCommandV1("status", List.of(), false)); assertThat(alphaLatch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(betaLatch.await(10, TimeUnit.SECONDS)).isTrue(); diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 89cb829..b3375f9 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -13,6 +13,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; @@ -32,7 +33,6 @@ import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.PlayerRefV1; -import org.xcore.plugin.event.TransportEvents; import static org.assertj.core.api.Assertions.assertThat; @@ -193,7 +193,7 @@ void classificationComesFromRegistry() { assertThat(registry.isReadOnlyType(ModerationAuditAppendedV1.class)).isTrue(); assertThat(registry.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(TransportEvents.ExecuteCommand.class)).isTrue(); + assertThat(registry.isMutatingType(ServerCommandExecuteCommandV1.class)).isTrue(); assertThat(registry.isMutatingType(ChatGlobalV1.class)).isFalse(); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java index 6372f19..96c99eb 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -25,7 +25,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; -import org.xcore.plugin.event.TransportEvents; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import java.util.List; @@ -176,7 +176,7 @@ void topologyLocksDownRepresentativeEventCommandAndRpcRouteMetadata() { RedisTransportTopology.RouteSpec commandRoute = RedisTransportTopology.routeFor(PlayerPasswordResetCommandV1.class); RedisTransportTopology.RouteSpec mapsLoadCommandRoute = RedisTransportTopology.routeFor(MapsLoadCommandV1.class); RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.class); - RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(TransportEvents.ExecuteCommand.class); + RedisTransportTopology.RouteSpec broadcastCommandRoute = RedisTransportTopology.routeFor(ServerCommandExecuteCommandV1.class); RedisTransportTopology.RouteSpec rpcRoute = RedisTransportTopology.routeFor(MapsListRequestV1.class); RedisTransportTopology.RouteSpec removeRpcRoute = RedisTransportTopology.routeFor(MapsRemoveRequestV1.class); RedisTransportTopology.RouteSpec kickBannedRoute = RedisTransportTopology.routeFor(ModerationKickBannedCommandV1.class); From 3e66238f6ad5036ecb2ebccc2348f62686ae7206 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sat, 2 May 2026 12:48:55 +0300 Subject: [PATCH 20/26] refactor(network): align plugin with protocol enums Update plugin mappers and transport-facing tests to consume the\nlatest canonical xcore-protocol actor/source DTO shapes, enum\nmodels, and map file naming.\n\nThis keeps XCore-plugin aligned with the generated protocol\nsurface and avoids reintroducing local contract drift. --- .../event/transport/MapTransportHandler.java | 2 +- .../plugin/service/DiscordLinkService.java | 14 +++++ .../network/DiscordProtocolMapper.java | 53 +++++++++++++++++-- .../network/ModerationProtocolMapper.java | 30 ++++++----- .../DiscordLinkTransportHandlerTest.java | 4 +- .../ModerationTransportHandlerTest.java | 10 ++-- .../ModerationServiceAvajeTest.java | 3 +- .../RedisNetworkBackendIntegrationTest.java | 2 +- .../network/RedisRouteRegistryTest.java | 4 +- .../network/RedisStreamRouterTest.java | 19 ++++--- 10 files changed, 107 insertions(+), 34 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java index c1841e5..558d01c 100644 --- a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java @@ -86,7 +86,7 @@ public void registerListeners() { Http.get(file.url()) .error(Log::err) .submit(result -> { - customMapDirectory.child(file.filename()).writeBytes(result.getResult()); + customMapDirectory.child(file.fileName()).writeBytes(result.getResult()); if (counter.incrementAndGet() == e.files().size()) { maps.reload(); diff --git a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java index c2d6200..33bab74 100644 --- a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java +++ b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java @@ -6,6 +6,7 @@ import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.plugin.service.network.DiscordProtocolMapper; import org.xcore.plugin.service.network.RedisDiscordLinkCodeStore; import org.xcore.plugin.session.Session; @@ -279,6 +280,19 @@ private void publishAdminAccessChanged(String playerUuid, )); } + public DiscordUnlinkCommandV1 toUnlinkCommand(PlayerData data, String requestedBy) { + return DiscordProtocolMapper.toUnlinkCommand( + data.uuid, + data.pid, + data.nickname, + data.discordId, + data.discordUsername, + requestedBy, + config.server, + System.currentTimeMillis() + ); + } + private String nextCode() { for (int attempt = 0; attempt < 10; attempt++) { StringBuilder builder = new StringBuilder(CODE_LENGTH); diff --git a/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java index cca5699..8a09b02 100644 --- a/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java @@ -1,9 +1,12 @@ package org.xcore.plugin.service.network; import org.xcore.plugin.model.PlayerData; +import org.xcore.protocol.generated.messages.discord.DiscordLinkStatusChangedV1Action; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkCodeCreatedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; @@ -45,7 +48,7 @@ public static DiscordLinkStatusChangedV1 toLinkStatusChanged( return new DiscordLinkStatusChangedV1( toPlayerRef(playerData.uuid, playerData.pid, playerData.nickname), toDiscordIdentity(discordId, discordUsername), - requireNonBlank(action, "action"), + toLinkStatusAction(action), requireNonBlank(server, "server"), toOccurredAt(occurredAt) ); @@ -68,14 +71,33 @@ public static DiscordAdminAccessChangedCommandV1 toAdminAccessChangedCommand( toPlayerRef(playerUuid, playerPid, playerName), toDiscordIdentity(discordId, discordUsername), admin, - requireNonBlank(adminSource, "adminSource"), - requireNonBlank(requestedBy, "requestedBy"), + toSourceActor(adminSource), + toRequesterActor(requestedBy), requireNonBlank(reason, "reason"), requireNonBlank(server, "server"), toOccurredAt(occurredAt) ); } + public static org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1 toUnlinkCommand( + String playerUuid, + int playerPid, + String playerName, + String discordId, + String discordUsername, + String requestedBy, + String server, + long requestedAt + ) { + return new org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1( + toPlayerRef(playerUuid, playerPid, playerName), + toDiscordIdentity(discordId, discordUsername), + toRequesterActor(requestedBy), + requireNonBlank(server, "server"), + toOccurredAt(requestedAt) + ); + } + private static PlayerRefV1 toPlayerRef(String playerUuid, Integer playerPid, String playerName) { return new PlayerRefV1( requireNonBlank(playerUuid, "playerUuid"), @@ -92,6 +114,31 @@ private static DiscordIdentityRefV1 toDiscordIdentity(String discordId, String d ); } + private static ActorRefV1 toSourceActor(String adminSource) { + String sourceName = requireNonBlank(adminSource, "adminSource"); + return new ActorRefV1(sourceName, null, resolveSourceActorType(sourceName)); + } + + private static ActorRefV1 toRequesterActor(String requestedBy) { + return new ActorRefV1(requireNonBlank(requestedBy, "requestedBy"), null, ActorRefV1ActorType.SYSTEM); + } + + private static ActorRefV1ActorType resolveSourceActorType(String adminSource) { + return switch (adminSource) { + case "DISCORD_ROLE" -> ActorRefV1ActorType.SYSTEM; + case "NONE" -> ActorRefV1ActorType.SYSTEM; + default -> ActorRefV1ActorType.SYSTEM; + }; + } + + private static DiscordLinkStatusChangedV1Action toLinkStatusAction(String action) { + return switch (requireNonBlank(action, "action").toLowerCase()) { + case "linked" -> DiscordLinkStatusChangedV1Action.LINKED; + case "unlinked" -> DiscordLinkStatusChangedV1Action.UNLINKED; + default -> throw new IllegalArgumentException("Unsupported discord link status action: " + action); + }; + } + private static String requirePlayerName(String playerName) { String normalized = normalizeOptional(playerName); return normalized == null ? "Unknown" : normalized; diff --git a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java index c3bf766..4484455 100644 --- a/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java @@ -6,6 +6,7 @@ import org.xcore.plugin.model.BanData; import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.Punishment; +import org.xcore.protocol.generated.messages.moderation.ModerationAuditAppendedV1EntryType; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; @@ -13,6 +14,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.ExpirationInfoV1; import org.xcore.protocol.generated.shared.ModerationTargetRefV1; import org.xcore.protocol.generated.shared.PlayerCommandTargetV1; @@ -191,27 +193,27 @@ private static String resolveAuditServer(AuditRecord record, String server) { return auditServer != null ? auditServer : server; } - private static String toAuditEntryType(AuditAction action) { + private static ModerationAuditAppendedV1EntryType toAuditEntryType(AuditAction action) { if (action == null) { - return "other"; + return ModerationAuditAppendedV1EntryType.OTHER; } return switch (action) { - case BAN -> "ban"; - case MUTE -> "mute"; - case UNBAN, UNMUTE -> "pardon"; - default -> "other"; + case BAN -> ModerationAuditAppendedV1EntryType.BAN; + case MUTE -> ModerationAuditAppendedV1EntryType.MUTE; + case UNBAN, UNMUTE -> ModerationAuditAppendedV1EntryType.PARDON; + default -> ModerationAuditAppendedV1EntryType.OTHER; }; } - private static String toProtocolActorType(AuditActorType actorType) { + private static ActorRefV1ActorType toProtocolActorType(AuditActorType actorType) { if (actorType == null) { - return "system"; + return ActorRefV1ActorType.SYSTEM; } return switch (actorType) { - case DISCORD_USER -> "discord"; - case PLAYER_ADMIN -> "player_admin"; - case SERVER_CONSOLE -> "server_console"; - case SYSTEM -> "system"; + case DISCORD_USER -> ActorRefV1ActorType.DISCORD; + case PLAYER_ADMIN -> ActorRefV1ActorType.PLAYER; + case SERVER_CONSOLE -> ActorRefV1ActorType.SERVER; + case SYSTEM -> ActorRefV1ActorType.SYSTEM; }; } @@ -220,8 +222,8 @@ private static String resolveActorName(String actorName) { return normalized == null ? "Unknown" : normalized; } - private static String resolveActorType(String actorDiscordId) { - return normalizeOptional(actorDiscordId) == null ? "unknown" : "discord"; + private static ActorRefV1ActorType resolveActorType(String actorDiscordId) { + return normalizeOptional(actorDiscordId) == null ? ActorRefV1ActorType.UNKNOWN : ActorRefV1ActorType.DISCORD; } private static String resolveReason(String reason) { diff --git a/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java index d52fcc1..db2edbe 100644 --- a/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java @@ -13,6 +13,8 @@ import org.xcore.plugin.session.SessionService; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; @@ -92,7 +94,7 @@ void discordUnlinkCommand_updatesOfflinePlayerDataWithoutOnlineSession() { .get(new DiscordUnlinkCommandV1( new PlayerRefV1("uuid-7", 7, "Target", null), new DiscordIdentityRefV1("123", "discord"), - "discord", + new ActorRefV1("discord", null, ActorRefV1ActorType.SYSTEM), "mini-other", "2026-04-28T00:00:01Z" )); diff --git a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java index b7169ac..edfcdfc 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -27,6 +27,8 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.PlayerRefV1; @@ -84,8 +86,8 @@ void discordAdminAccessCommand_appliesPersistedAdminFlags() { new PlayerRefV1("uuid-1", 7, "Player", null), new DiscordIdentityRefV1("123", "discord-user"), true, - DiscordAdminAccessService.SOURCE_DISCORD_ROLE, - "tester", + new ActorRefV1(DiscordAdminAccessService.SOURCE_DISCORD_ROLE, null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), "sync", "mini-pvp", "2026-04-28T00:00:10Z" @@ -119,8 +121,8 @@ void discordAdminRevokeCommand_clearsPersistedAdminFlags() { new PlayerRefV1("uuid-1", 7, "Player", null), new DiscordIdentityRefV1("123", "discord-user"), false, - DiscordAdminAccessService.SOURCE_DISCORD_ROLE, - "tester", + new ActorRefV1(DiscordAdminAccessService.SOURCE_DISCORD_ROLE, null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), "sync", "mini-pvp", "2026-04-28T00:00:11Z" diff --git a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java index ce78989..5cd114f 100644 --- a/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java +++ b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java @@ -16,6 +16,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.database.repository.MuteDataRepository; import org.xcore.plugin.database.repository.PlayerDataRepository; @@ -149,7 +150,7 @@ void tempBanSuccess() { && canonical.actor() != null && "admin".equals(canonical.actor().actorName()) && "12345".equals(canonical.actor().actorDiscordId()) - && "discord".equals(canonical.actor().actorType()) + && ActorRefV1ActorType.DISCORD == canonical.actor().actorType() && "Not Specified".equals(canonical.reason()) && canonical.expiration() != null && !canonical.expiration().permanent() diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index cf999a5..dd69b61 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -351,7 +351,7 @@ void sendSerializesVoteKickEvent() { .containsEntry("reason", "griefing") .containsEntry("server", "alpha") .containsEntry("occurredAt", "2026-04-26T00:00:00Z") - .containsKeys("target", "starter", "votesFor", "votesAgainst"); + .containsKeys("target", "actor", "votesFor", "votesAgainst"); } } diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index b3375f9..117eaa0 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -30,6 +30,8 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.PlayerRefV1; @@ -91,7 +93,7 @@ void unlinkCommandUsesTypedPayloadServerContract() { new DiscordUnlinkCommandV1( new PlayerRefV1("uuid", 1, "Player", null), new DiscordIdentityRefV1("discord", "user"), - "moderator", + new ActorRefV1("moderator", null, ActorRefV1ActorType.SYSTEM), "survival", "2026-04-28T00:00:00Z" ), diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index d02db25..6252dbb 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -19,12 +19,14 @@ import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordLinkStatusChangedV1Action; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; +import org.xcore.protocol.generated.messages.moderation.ModerationAuditAppendedV1EntryType; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; @@ -32,6 +34,7 @@ import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages; import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.ModerationTargetRefV1; @@ -103,9 +106,9 @@ void routeReadOnlyEvents() { ); var auditRoute = router.route( new ModerationAuditAppendedV1( - "ban", + ModerationAuditAppendedV1EntryType.BAN, new ModerationTargetRefV1("uuid-target", 42, "target", null), - new ActorRefV1("Admin", "admin-1", "discord"), + new ActorRefV1("Admin", "admin-1", ActorRefV1ActorType.DISCORD), "reason", "mini-pvp", Instant.parse("2026-04-26T00:00:03Z").toString(), @@ -179,7 +182,7 @@ void routeServerTargetedEvents() { new DiscordLinkStatusChangedV1( new PlayerRefV1("uuid-7", 7, "Nick", null), new DiscordIdentityRefV1("123", "discord-user"), - "linked", + DiscordLinkStatusChangedV1Action.LINKED, "mini-pvp", Instant.parse("2026-04-26T00:00:10Z").toString() ), @@ -190,8 +193,8 @@ void routeServerTargetedEvents() { new PlayerRefV1("uuid-7", 7, "Nick", null), new DiscordIdentityRefV1("123", "discord-user"), true, - "DISCORD_ROLE", - "tester", + new ActorRefV1("DISCORD_ROLE", null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), "sync", "mini-pvp", Instant.parse("2026-04-26T00:00:11Z").toString() @@ -202,7 +205,7 @@ void routeServerTargetedEvents() { new DiscordUnlinkCommandV1( new PlayerRefV1("uuid-7", 7, "Nick", null), new DiscordIdentityRefV1("123", "discord-user"), - "tester", + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), "mini-hexed", Instant.parse("2026-04-26T00:00:13Z").toString() ), @@ -237,8 +240,8 @@ void routeServerTargetedEvents() { new PlayerRefV1("uuid-8", 8, "Other", null), new DiscordIdentityRefV1("456", "other-user"), false, - "NONE", - "tester", + new ActorRefV1("NONE", null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), "sync", "survival", Instant.parse("2026-04-26T00:00:12Z").toString() From d27ca8c2a7e0261688a9cf5027a7c992cf624320 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sat, 2 May 2026 13:05:17 +0300 Subject: [PATCH 21/26] build(ci): refresh Gradle dependencies in workflows Force CI build, snapshot publish, and release publish jobs to refresh Gradle dependencies before running tests. This reduces stale xcore-protocol snapshot cache issues in GitHub Actions for the migration branch. --- .github/workflows/build.yml | 4 ++-- .github/workflows/release-publish.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b64a91c..cd4ff9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,11 +43,11 @@ jobs: - name: Build and test if: github.event_name == 'pull_request' || env.XCORE_USERNAME == '' || env.XCORE_PASSWORD == '' - run: ./gradlew test shadowJar + run: ./gradlew --refresh-dependencies test shadowJar - name: Build, test and publish snapshot if: github.event_name != 'pull_request' && env.XCORE_USERNAME != '' && env.XCORE_PASSWORD != '' - run: ./gradlew test publishMavenPublicationToXcoreRepositorySnapshotsRepository -PxcorePublishVersion="$XCORE_PUBLISH_VERSION" + run: ./gradlew --refresh-dependencies test publishMavenPublicationToXcoreRepositorySnapshotsRepository -PxcorePublishVersion="$XCORE_PUBLISH_VERSION" env: ORG_GRADLE_PROJECT_xcoreRepositorySnapshotsUsername: ${{ secrets.XCORE_USERNAME }} ORG_GRADLE_PROJECT_xcoreRepositorySnapshotsPassword: ${{ secrets.XCORE_PASSWORD }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 469a6a5..3fbf16e 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -35,7 +35,7 @@ jobs: echo "XCORE_PUBLISH_VERSION=${VERSION}" >> "$GITHUB_ENV" - name: Build, test and publish release - run: ./gradlew test publishMavenPublicationToXcoreRepositoryReleasesRepository -PxcorePublishVersion="$XCORE_PUBLISH_VERSION" + run: ./gradlew --refresh-dependencies test publishMavenPublicationToXcoreRepositoryReleasesRepository -PxcorePublishVersion="$XCORE_PUBLISH_VERSION" env: ORG_GRADLE_PROJECT_xcoreRepositoryReleasesUsername: ${{ secrets.XCORE_USERNAME }} ORG_GRADLE_PROJECT_xcoreRepositoryReleasesPassword: ${{ secrets.XCORE_PASSWORD }} From 8f59525f6b3e1e69395a234b092bb98035194f26 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sat, 2 May 2026 13:53:12 +0300 Subject: [PATCH 22/26] test(network): stabilize Redis moderation integration assertions Flush Redis between integration tests to avoid shared stream state leaking across cases. Align moderation payload assertions with the runtime Gson payload shape so CI validates the actual transport contract. --- .../RedisNetworkBackendIntegrationTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index dd69b61..55e60c2 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -61,6 +61,7 @@ void tearDown() { if (requesterBackend != null) { requesterBackend.disconnect(); } + flushRedis(); } @Test @@ -226,8 +227,6 @@ void sendSerializesBanDataInstant() { Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); assertThat(last.get("event_type")).isEqualTo("moderation.ban.created"); assertThat(payload) - .containsEntry("messageType", ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE) - .containsEntry("messageVersion", 1.0) .containsEntry("reason", "rule") .containsEntry("server", "alpha") .containsEntry("occurredAt", "2026-04-26T00:00:00Z") @@ -265,8 +264,6 @@ void sendSerializesCanonicalModerationMuteCreatedEvent() { Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); assertThat(last.get("event_type")).isEqualTo("moderation.mute.created"); assertThat(payload) - .containsEntry("messageType", ModerationMessages.ModerationMuteCreatedV1.MESSAGE_TYPE) - .containsEntry("messageVersion", 1.0) .containsEntry("reason", "rule") .containsEntry("server", "alpha") .containsEntry("occurredAt", "2026-04-26T00:00:00Z") @@ -346,8 +343,6 @@ void sendSerializesVoteKickEvent() { Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); assertThat(last.get("event_type")).isEqualTo("moderation.vote-kick.created"); assertThat(payload) - .containsEntry("messageType", ModerationMessages.ModerationVoteKickCreatedV1.MESSAGE_TYPE) - .containsEntry("messageVersion", 1.0) .containsEntry("reason", "griefing") .containsEntry("server", "alpha") .containsEntry("occurredAt", "2026-04-26T00:00:00Z") @@ -718,6 +713,14 @@ private Config baseConfig(String server) { return config; } + private void flushRedis() { + String redisUrl = "redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379); + try (RedisClient client = RedisClient.create(redisUrl); + StatefulRedisConnection connection = client.connect()) { + connection.sync().flushall(); + } + } + private static T punishment(T value, String uuid, String name) { value.uuid = uuid; value.name = name; From a3830a2a84ee5f831008db3271903791e7e6efbf Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sat, 2 May 2026 14:41:58 +0300 Subject: [PATCH 23/26] fix(network): preserve heartbeat discord channel width Publish heartbeat events with the full Discord snowflake instead of narrowing the configured channel ID to int. Update the router test to cover the widened protocol contract that now resolves from the refreshed protocol snapshot. --- src/main/java/org/xcore/plugin/event/TransportService.java | 2 +- .../org/xcore/plugin/service/network/RedisStreamRouterTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/xcore/plugin/event/TransportService.java b/src/main/java/org/xcore/plugin/event/TransportService.java index f3d8dd0..836aeca 100644 --- a/src/main/java/org/xcore/plugin/event/TransportService.java +++ b/src/main/java/org/xcore/plugin/event/TransportService.java @@ -62,7 +62,7 @@ public void init() { try { network.post(new ServerHeartbeatV1( config.server, - Math.toIntExact(config.discordChannelId), + config.discordChannelId, Groups.player.size(), config.getNoAdminPlayerLimit(), Version.buildString(), diff --git a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java index 6252dbb..7e2f9d1 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisStreamRouterTest.java @@ -71,7 +71,7 @@ void routeReadOnlyEvents() { var privateRoute = router.route(new ChatPrivateV1("uuid-from", 7, "Sender", "uuid-to", 42, "hello", "survival"), "mini-pvp"); var serverActionRoute = router.route(new ServerActionV1("Server loaded", "mini-pvp"), "mini-pvp"); var joinRoute = router.route(new PlayerJoinLeaveV1("p", "mini-pvp", true), "mini-pvp"); - var heartbeatRoute = router.route(new ServerHeartbeatV1("mini-pvp", 1, 5, 30, "1.0.0", "127.0.0.1", 6567), "mini-pvp"); + var heartbeatRoute = router.route(new ServerHeartbeatV1("mini-pvp", 1L, 5, 30, "1.0.0", "127.0.0.1", 6567), "mini-pvp"); var banRoute = router.route( org.xcore.plugin.service.network.ModerationProtocolMapper.toBanCreated( banData, From 75b6579b360ea55bee1e5f63cad07c693b89c846 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sun, 3 May 2026 14:11:17 +0300 Subject: [PATCH 24/26] feat(protocol): migrate to xcore-protocol 0.4.0 with canonical serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: Redis payload_json now emitted via ProtocolPayload.toPayload() which injects messageType/messageVersion and canonical nested ref shapes. - Bump xcore-protocol-java: 0.3.0-SNAPSHOT → 0.4.0 - RedisNetworkBackend.payloadJson() uses toPayload() for ProtocolPayload - RPC request/response serialization uses same path - Integration tests assert messageType/messageVersion in payload_json - Clean up unused TransportEvents imports --- build.gradle.kts | 2 ++ gradle/libs.versions.toml | 2 +- .../transport/DiscordLinkTransportHandler.java | 1 - .../xcore/plugin/service/DiscordLinkService.java | 1 - .../plugin/service/network/RedisNetworkBackend.java | 8 ++++++-- src/main/java/org/xcore/plugin/vote/VoteKick.java | 1 - .../network/RedisNetworkBackendIntegrationTest.java | 13 ++++++++++++- 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7833c81..62a99ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,6 +50,8 @@ val xcoreReleasesRepositoryUrl = providers.gradleProperty("xcoreMavenReleasesUrl .orElse("https://maven.x-core.org/releases") repositories { + maven { url = uri("https://maven.x-core.org/snapshots") } + maven { url = uri("https://maven.x-core.org/releases") } mavenCentral() anukeXpdustry() maven(url = "https://oss.sonatype.org/content/repositories/snapshots") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20dbabc..e46130f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] mindustry = "157" -xcore-protocol = "0.3.0-SNAPSHOT" +xcore-protocol = "0.4.0" # Plugins toxopid = "4.1.2" diff --git a/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java index 870d433..f280139 100644 --- a/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java @@ -3,7 +3,6 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.service.DiscordLinkService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.session.SessionService; diff --git a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java index 33bab74..ae640b9 100644 --- a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java +++ b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java @@ -4,7 +4,6 @@ import jakarta.inject.Singleton; import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.PlayerDataRepository; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.PlayerData; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import org.xcore.plugin.service.network.DiscordProtocolMapper; diff --git a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java index 80fedc1..f4fe0b2 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -15,6 +15,7 @@ import jakarta.inject.Named; import jakarta.inject.Singleton; import org.xcore.plugin.config.Config; +import org.xcore.protocol.generated.runtime.ProtocolPayload; import java.time.Instant; import java.util.ArrayList; @@ -232,7 +233,7 @@ public RequestSubscription request(REQ request, Cons listen return requestHandle; } - String requestJson = gson.toJson(request); + String requestJson = payloadJson(request); RedisCommands commands = connectionManager.commands(); try { streamSupport.xaddWithTrim(commands, route.streamKey(), @@ -260,7 +261,7 @@ public void respond(Object request, Object response) { try { RedisCommands commands = connectionManager.commands(); streamSupport.xaddWithTrim(commands, context.replyTo(), - envelopeFactory.rpcResponseFields(context, gson.toJson(response), System.currentTimeMillis())); + envelopeFactory.rpcResponseFields(context, payloadJson(response), System.currentTimeMillis())); } catch (RuntimeException e) { rpcResponses.decrementAndGet(); throw e; @@ -299,6 +300,9 @@ public boolean supportsRespond(Object request) { } private String payloadJson(Object event) { + if (event instanceof ProtocolPayload protocolPayload) { + return gson.toJson(protocolPayload.toPayload()); + } return gson.toJson(event); } diff --git a/src/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index 825ed99..3d46990 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -12,7 +12,6 @@ import mindustry.net.Packets; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.plugin.common.VersionComparator; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.localization.Localization; diff --git a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 55e60c2..dd61412 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java @@ -106,7 +106,14 @@ void sendPublishesEnvelopeToMappedStream() { assertThat(messages).isNotEmpty(); var last = messages.get(messages.size() - 1).getBody(); assertThat(last.get("event_type")).isEqualTo("chat.message"); - assertThat(last.get("payload_json")).contains("hello"); + @SuppressWarnings("unchecked") + Map chatPayload = new Gson().fromJson(last.get("payload_json"), Map.class); + assertThat(chatPayload) + .containsEntry("messageType", "chat.message") + .containsEntry("messageVersion", 1.0) + .containsEntry("authorName", "tester") + .containsEntry("message", "hello") + .containsEntry("server", "alpha"); } } @@ -227,6 +234,8 @@ void sendSerializesBanDataInstant() { Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); assertThat(last.get("event_type")).isEqualTo("moderation.ban.created"); assertThat(payload) + .containsEntry("messageType", "moderation.ban.created") + .containsEntry("messageVersion", 1.0) .containsEntry("reason", "rule") .containsEntry("server", "alpha") .containsEntry("occurredAt", "2026-04-26T00:00:00Z") @@ -264,6 +273,8 @@ void sendSerializesCanonicalModerationMuteCreatedEvent() { Map payload = new Gson().fromJson(last.get("payload_json"), Map.class); assertThat(last.get("event_type")).isEqualTo("moderation.mute.created"); assertThat(payload) + .containsEntry("messageType", "moderation.mute.created") + .containsEntry("messageVersion", 1.0) .containsEntry("reason", "rule") .containsEntry("server", "alpha") .containsEntry("occurredAt", "2026-04-26T00:00:00Z") From 1e0a8ae684898bf389dd7be7a5a510a7f7e9a640 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sun, 3 May 2026 15:55:40 +0300 Subject: [PATCH 25/26] refactor(protocol): align plugin transport with canonical actor semantics --- .../plugin/service/DiscordLinkService.java | 11 +- .../network/DiscordProtocolMapper.java | 88 +++++++- .../service/network/RedisRouteRegistry.java | 5 + .../network/DiscordProtocolMapperTest.java | 168 ++++++++++++++ .../network/RedisRouteRegistryTest.java | 213 ++++++------------ 5 files changed, 334 insertions(+), 151 deletions(-) create mode 100644 src/test/java/org/xcore/plugin/service/network/DiscordProtocolMapperTest.java diff --git a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java index ae640b9..3e233d3 100644 --- a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java +++ b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java @@ -264,6 +264,9 @@ private void publishAdminAccessChanged(String playerUuid, String adminSource, String requestedBy, String reason) { + var source = DiscordProtocolMapper.toSourceActor(adminSource); + // DiscordLinkService only has requester name strings here, so actor metadata falls back to SYSTEM. + var actor = DiscordProtocolMapper.toRequesterActor(requestedBy); networkService.post(DiscordProtocolMapper.toAdminAccessChangedCommand( playerUuid, playerPid, @@ -271,8 +274,8 @@ private void publishAdminAccessChanged(String playerUuid, discordId, discordUsername, admin, - adminSource, - requestedBy, + source, + actor, reason, config.server, System.currentTimeMillis() @@ -280,13 +283,15 @@ private void publishAdminAccessChanged(String playerUuid, } public DiscordUnlinkCommandV1 toUnlinkCommand(PlayerData data, String requestedBy) { + // DiscordLinkService only has requester name strings here, so actor metadata falls back to SYSTEM. + var actor = DiscordProtocolMapper.toRequesterActor(requestedBy); return DiscordProtocolMapper.toUnlinkCommand( data.uuid, data.pid, data.nickname, data.discordId, data.discordUsername, - requestedBy, + actor, config.server, System.currentTimeMillis() ); diff --git a/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java index 8a09b02..7084daf 100644 --- a/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java +++ b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java @@ -79,6 +79,39 @@ public static DiscordAdminAccessChangedCommandV1 toAdminAccessChangedCommand( ); } + /** + * Overload accepting canonical {@link ActorRefV1} objects for source and actor. + * Use this when the caller already has structured actor metadata (Discord ID, display name, actor type). + *

+ * When actor metadata is not available, prefer the simpler overloads that use + * {@link #toRequesterActor(String)} and {@link #toSourceActor(String)} with their + * built-in system-actor fallback. + */ + public static DiscordAdminAccessChangedCommandV1 toAdminAccessChangedCommand( + String playerUuid, + int playerPid, + String playerName, + String discordId, + String discordUsername, + boolean admin, + ActorRefV1 source, + ActorRefV1 actor, + String reason, + String server, + long occurredAt + ) { + return new DiscordAdminAccessChangedCommandV1( + toPlayerRef(playerUuid, playerPid, playerName), + toDiscordIdentity(discordId, discordUsername), + admin, + source, + actor, + requireNonBlank(reason, "reason"), + requireNonBlank(server, "server"), + toOccurredAt(occurredAt) + ); + } + public static org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1 toUnlinkCommand( String playerUuid, int playerPid, @@ -98,6 +131,31 @@ public static org.xcore.protocol.generated.messages.discord.DiscordMessages.Disc ); } + /** + * Overload accepting a canonical {@link ActorRefV1} for the actor. + * Use this when the caller can supply the Discord display name, Discord ID, + * and verified actor type. Falls back to {@link #toRequesterActor(String)} + * when only a name string is available. + */ + public static org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1 toUnlinkCommand( + String playerUuid, + int playerPid, + String playerName, + String discordId, + String discordUsername, + ActorRefV1 actor, + String server, + long requestedAt + ) { + return new org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1( + toPlayerRef(playerUuid, playerPid, playerName), + toDiscordIdentity(discordId, discordUsername), + actor, + requireNonBlank(server, "server"), + toOccurredAt(requestedAt) + ); + } + private static PlayerRefV1 toPlayerRef(String playerUuid, Integer playerPid, String playerName) { return new PlayerRefV1( requireNonBlank(playerUuid, "playerUuid"), @@ -114,15 +172,41 @@ private static DiscordIdentityRefV1 toDiscordIdentity(String discordId, String d ); } - private static ActorRefV1 toSourceActor(String adminSource) { + public static ActorRefV1 toSourceActor(String adminSource) { String sourceName = requireNonBlank(adminSource, "adminSource"); return new ActorRefV1(sourceName, null, resolveSourceActorType(sourceName)); } - private static ActorRefV1 toRequesterActor(String requestedBy) { + /** + * Creates a system-level source actor for provenance tracking. + * Source actors represent system mechanisms (DISCORD_ROLE, NONE, COMMAND) + * and always use {@link ActorRefV1ActorType#SYSTEM}. + */ + private static ActorRefV1 toSourceActor(String sourceName, ActorRefV1ActorType sourceType) { + return new ActorRefV1( + requireNonBlank(sourceName, "sourceName"), + null, + Objects.requireNonNull(sourceType, "sourceType") + ); + } + + public static ActorRefV1 toRequesterActor(String requestedBy) { return new ActorRefV1(requireNonBlank(requestedBy, "requestedBy"), null, ActorRefV1ActorType.SYSTEM); } + /** + * Creates an actor ref with the actor's Discord identity. + * When the caller knows the Discord user's display name and ID, + * this preserves that metadata for audit/history purposes. + */ + private static ActorRefV1 toRequesterActor(String name, String discordId) { + String actorName = normalizeOptional(name) == null ? "Unknown" : normalizeOptional(name); + ActorRefV1ActorType actorType = normalizeOptional(discordId) != null + ? ActorRefV1ActorType.DISCORD + : ActorRefV1ActorType.SYSTEM; + return new ActorRefV1(actorName, normalizeOptional(discordId), actorType); + } + private static ActorRefV1ActorType resolveSourceActorType(String adminSource) { return switch (adminSource) { case "DISCORD_ROLE" -> ActorRefV1ActorType.SYSTEM; diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index 351b525..f028404 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -60,6 +60,11 @@ public final class RedisRouteRegistry { if (mapsServer != null && !mapsServer.isBlank()) { return mapsServer; } + // Legacy migration fallback — TransportEvents.ServerScopedEvent is retained + // for backward compatibility during protocol migration rollout. + // This branch will be removed once all payloads use generated protocol DTOs + // and TransportEvents.ServerScopedEvent is no longer needed. + // See: xcore-protocol migration plan for removal timeline. if (payload instanceof TransportEvents.ServerScopedEvent serverScopedEvent) { String server = serverScopedEvent.server(); if (server != null && !server.isBlank()) { diff --git a/src/test/java/org/xcore/plugin/service/network/DiscordProtocolMapperTest.java b/src/test/java/org/xcore/plugin/service/network/DiscordProtocolMapperTest.java new file mode 100644 index 0000000..8732c6d --- /dev/null +++ b/src/test/java/org/xcore/plugin/service/network/DiscordProtocolMapperTest.java @@ -0,0 +1,168 @@ +package org.xcore.plugin.service.network; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; +import org.xcore.protocol.generated.shared.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; + +import java.lang.reflect.Method; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiscordProtocolMapperTest { + + @Test + @DisplayName("admin access source actor type resolves to system") + void testAdminAccessSourceActorTypeIsSystem() { + ActorRefV1 discordRoleSource = invokeMapper("toSourceActor", new Class[]{String.class}, "DISCORD_ROLE"); + ActorRefV1 noneSource = invokeMapper("toSourceActor", new Class[]{String.class}, "NONE"); + + assertThat(discordRoleSource.actorType()).isEqualTo(ActorRefV1ActorType.SYSTEM); + assertThat(discordRoleSource.actorType().toString()).isEqualTo("system"); + assertThat(noneSource.actorType()).isEqualTo(ActorRefV1ActorType.SYSTEM); + assertThat(noneSource.actorType().toString()).isEqualTo("system"); + } + + @Test + @DisplayName("requester actor falls back to system when only name is available") + void testAdminAccessActorSystemFallback() { + ActorRefV1 actor = invokeMapper("toRequesterActor", new Class[]{String.class}, "plugin/unlink"); + + assertThat(actor.actorType()).isEqualTo(ActorRefV1ActorType.SYSTEM); + assertThat(actor.actorType().toString()).isEqualTo("system"); + assertThat(actor.actorDiscordId()).isNull(); + assertThat(actor.actorName()).isEqualTo("plugin/unlink"); + } + + @Test + @DisplayName("requester actor uses discord type when discord id is available") + void testAdminAccessActorWithDiscordId() { + ActorRefV1 actor = invokeMapper("toRequesterActor", new Class[]{String.class, String.class}, "boss", "12345"); + + assertThat(actor.actorType()).isEqualTo(ActorRefV1ActorType.DISCORD); + assertThat(actor.actorType().toString()).isEqualTo("discord"); + assertThat(actor.actorDiscordId()).isEqualTo("12345"); + assertThat(actor.actorName()).isEqualTo("boss"); + } + + @Test + @DisplayName("unlink command payload uses canonical actor and target field names") + void testUnlinkCommandCanonicalFields() { + DiscordUnlinkCommandV1 command = DiscordProtocolMapper.toUnlinkCommand( + "uuid-7", + 7, + "Target", + "12345", + "discord-user", + "requestor", + "survival", + 1_714_102_400_000L + ); + + Map payload = command.toPayload(); + + // Top-level keys: nested objects, no legacy flat keys + assertThat(payload) + .containsKeys("player", "discord", "actor", "server", "requestedAt") + .doesNotContainKeys("uuid", "name", "requestedBy", "requestedByDiscordId", "requestedByType"); + + @SuppressWarnings("unchecked") + var player = (Map) payload.get("player"); + assertThat(player).containsEntry("playerUuid", "uuid-7"); + assertThat(player).containsEntry("playerName", "Target"); + + @SuppressWarnings("unchecked") + var discord = (Map) payload.get("discord"); + assertThat(discord).containsEntry("discordId", "12345"); + assertThat(discord).containsEntry("discordUsername", "discord-user"); + + @SuppressWarnings("unchecked") + var actorPayload = (Map) payload.get("actor"); + assertThat(actorPayload).containsEntry("actorName", "requestor"); + + assertThat(payload).containsEntry("server", "survival"); + assertThat(payload.get("requestedAt")).isNotNull(); + } + + @Test + @DisplayName("unlink command actor overload preserves canonical actor fields") + void testUnlinkCommandActorOverload() { + ActorRefV1 actor = new ActorRefV1("DisplayName", "555", ActorRefV1ActorType.DISCORD); + + DiscordUnlinkCommandV1 command = DiscordProtocolMapper.toUnlinkCommand( + "uuid-7", + 7, + "Target", + "12345", + "discord-user", + actor, + "survival", + 1_714_102_400_000L + ); + + assertThat(command.actor()).isEqualTo(actor); + assertThat(command.actor().actorName()).isEqualTo("DisplayName"); + assertThat(command.actor().actorDiscordId()).isEqualTo("555"); + assertThat(command.actor().actorType()).isEqualTo(ActorRefV1ActorType.DISCORD); + } + + @Test + @DisplayName("admin access command actor overload preserves source and actor refs") + void testAdminAccessCommandActorOverload() { + ActorRefV1 source = new ActorRefV1("DISCORD_ROLE", null, ActorRefV1ActorType.SYSTEM); + ActorRefV1 actor = new ActorRefV1("Boss", "555", ActorRefV1ActorType.DISCORD); + + DiscordAdminAccessChangedCommandV1 command = DiscordProtocolMapper.toAdminAccessChangedCommand( + "uuid-7", + 7, + "Target", + "12345", + "discord-user", + true, + source, + actor, + "sync", + "survival", + 1_714_102_400_000L + ); + Map payload = command.toPayload(); + + assertThat(command.source()).isEqualTo(source); + assertThat(command.source().actorName()).isEqualTo("DISCORD_ROLE"); + assertThat(command.source().actorDiscordId()).isNull(); + assertThat(command.source().actorType()).isEqualTo(ActorRefV1ActorType.SYSTEM); + assertThat(command.actor()).isEqualTo(actor); + assertThat(command.actor().actorName()).isEqualTo("Boss"); + assertThat(command.actor().actorDiscordId()).isEqualTo("555"); + assertThat(command.actor().actorType()).isEqualTo(ActorRefV1ActorType.DISCORD); + // Top-level keys: nested objects, no legacy flat keys + assertThat(payload) + .containsKeys("player", "discord", "source", "actor", "server", "occurredAt") + .doesNotContainKeys("uuid", "name", "requestedBy", "adminSource"); + + @SuppressWarnings("unchecked") + var sourcePayload = (Map) payload.get("source"); + assertThat(sourcePayload).containsEntry("actorName", "DISCORD_ROLE"); + assertThat(sourcePayload.get("actorType").toString()).isEqualTo("system"); + + @SuppressWarnings("unchecked") + var actorPayload2 = (Map) payload.get("actor"); + assertThat(actorPayload2).containsEntry("actorName", "Boss"); + assertThat(actorPayload2).containsEntry("actorDiscordId", "555"); + assertThat(actorPayload2.get("actorType").toString()).isEqualTo("discord"); + } + + @SuppressWarnings("unchecked") + private static T invokeMapper(String methodName, Class[] parameterTypes, Object... args) { + try { + Method method = DiscordProtocolMapper.class.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return (T) method.invoke(null, args); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Failed to invoke mapper method: " + methodName, exception); + } + } +} diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 117eaa0..9c143a1 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,200 +2,121 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.xcore.plugin.event.TransportEvents; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeSymbolColorModeChangedCommandV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerCustomNicknameChangedCommandV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerPasswordResetCommandV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; -import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; -import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordAdminAccessChangedCommandV1; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; -import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkStatusChangedV1; -import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; -import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; -import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListResponseV1; import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; -import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; -import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveResponseV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationAuditAppendedV1; import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationKickBannedCommandV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationMuteCreatedV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; -import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; -import org.xcore.protocol.generated.shared.ActorRefV1; -import org.xcore.protocol.generated.shared.ActorRefV1ActorType; import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.PlayerRefV1; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class RedisRouteRegistryTest { private final RedisRouteRegistry registry = new RedisRouteRegistry(); + private final RedisStreamRouter router = new RedisStreamRouter(registry); @Test - @DisplayName("unknown payload types have no registry descriptor") - void unknownPayloadTypesHaveNoRegistryDescriptor() { - assertThat(registry.routeDescriptorFor(new Object())).isNull(); - assertThat(registry.routeDescriptorFor(Object.class)).isNull(); + @DisplayName("chat message resolves to chat stream descriptor") + void testChatMessageRoutesToChatStream() { + var descriptor = registry.routeDescriptorFor(ChatMessageV1.class); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:evt:chat:message"); + assertThat(descriptor.isReadOnly()).isTrue(); + assertThat(descriptor.isMutating()).isFalse(); } @Test - @DisplayName("payload server resolver uses typed server contract") - void payloadServerResolverUsesTypedContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(DiscordLinkConfirmCommandV1.class); - - String stream = registry.resolveStreamKey( - descriptor, - new DiscordLinkConfirmCommandV1( - "code", - new PlayerRefV1("uuid", 1, "Player", null), - new DiscordIdentityRefV1("discord", "user"), - "survival", - "2026-04-28T00:00:00Z" - ), - "mini-pvp" - ); + @DisplayName("server heartbeat resolves to chat stream descriptor") + void testServerHeartbeatRoutesToChatStream() { + var descriptor = registry.routeDescriptorFor(ServerHeartbeatV1.class); - assertThat(stream).isEqualTo("xcore:cmd:discord-link-confirm:survival"); + assertThat(descriptor).isNotNull(); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:evt:server:heartbeat"); + assertThat(descriptor.isReadOnly()).isTrue(); + assertThat(descriptor.isMutating()).isFalse(); } @Test - @DisplayName("discord ingress command uses typed payload server contract") - void discordIngressCommandUsesTypedPayloadServerContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(ChatDiscordIngressCommandV1.class); - - String stream = registry.resolveStreamKey( - descriptor, - new ChatDiscordIngressCommandV1("bot", "hello", "survival"), - "mini-pvp" - ); + @DisplayName("moderation ban resolves to moderation stream descriptor") + void testModerationBanRoutesToModerationStream() { + var descriptor = registry.routeDescriptorFor(ModerationBanCreatedV1.class); - assertThat(stream).isEqualTo("xcore:cmd:discord-message:survival"); + assertThat(descriptor).isNotNull(); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:evt:moderation:ban"); + assertThat(descriptor.isReadOnly()).isTrue(); + assertThat(descriptor.isMutating()).isFalse(); } @Test - @DisplayName("unlink command uses typed payload server contract") - void unlinkCommandUsesTypedPayloadServerContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(DiscordUnlinkCommandV1.class); - - String stream = registry.resolveStreamKey( - descriptor, - new DiscordUnlinkCommandV1( - new PlayerRefV1("uuid", 1, "Player", null), - new DiscordIdentityRefV1("discord", "user"), - new ActorRefV1("moderator", null, ActorRefV1ActorType.SYSTEM), - "survival", - "2026-04-28T00:00:00Z" - ), - "mini-pvp" + @DisplayName("discord link confirm resolves to discord stream descriptor") + void testDiscordLinkConfirmRoutesToDiscordStream() { + var payload = new DiscordLinkConfirmCommandV1( + "code", + new PlayerRefV1("uuid", 1, "Player", null), + new DiscordIdentityRefV1("discord", "user"), + "survival", + "2026-04-28T00:00:00Z" ); + var descriptor = registry.routeDescriptorFor(payload); - assertThat(stream).isEqualTo("xcore:cmd:discord-unlink:survival"); + assertThat(descriptor).isNotNull(); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:cmd:discord-link-confirm:{server}"); + assertThat(registry.resolveStreamKey(descriptor, payload, "mini-pvp")) + .isEqualTo("xcore:cmd:discord-link-confirm:survival"); } @Test - @DisplayName("player password reset command uses typed payload server contract") - void playerPasswordResetCommandUsesTypedPayloadServerContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(PlayerPasswordResetCommandV1.class); - - String stream = registry.resolveStreamKey( - descriptor, - new PlayerPasswordResetCommandV1("uuid-1", "survival"), - "mini-pvp" + @DisplayName("maps load command resolves to maps stream descriptor") + void testMapsLoadCommandRoutesToMapsStream() { + var payload = new MapsLoadCommandV1( + "survival", + java.util.List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav")) ); + var descriptor = registry.routeDescriptorFor(payload); - assertThat(stream).isEqualTo("xcore:cmd:player-password-reset:survival"); + assertThat(descriptor).isNotNull(); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:cmd:maps-load:{server}"); + assertThat(registry.resolveStreamKey(descriptor, payload, "mini-pvp")) + .isEqualTo("xcore:cmd:maps-load:survival"); } @Test - @DisplayName("rpc route descriptor carries response type metadata") - void rpcRouteDescriptorCarriesResponseType() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(MapsListRequestV1.class); - RedisRouteDescriptor removeDescriptor = registry.routeDescriptorFor(MapsRemoveRequestV1.class); + @DisplayName("legacy server scoped event fallback resolves server") + void testLegacyServerScopedEventFallsBackToServer() { + var descriptor = registry.routeDescriptorFor(MapsLoadCommandV1.class); + var payload = new TestServerScopedEvent("legacy-survival"); assertThat(descriptor).isNotNull(); - assertThat(descriptor.isRpcRequest()).isTrue(); - assertThat(descriptor.responseType()).isEqualTo(MapsListResponseV1.class); - assertThat(registry.rpcTypeForRequestClass(MapsListRequestV1.class)).isEqualTo("maps.list.request"); - - assertThat(removeDescriptor).isNotNull(); - assertThat(removeDescriptor.isRpcRequest()).isTrue(); - assertThat(removeDescriptor.responseType()).isEqualTo(MapsRemoveResponseV1.class); - assertThat(registry.rpcTypeForRequestClass(MapsRemoveRequestV1.class)).isEqualTo("maps.remove.request"); + assertThat(registry.resolveStreamKey(descriptor, payload, "mini-pvp")) + .isEqualTo("xcore:cmd:maps-load:legacy-survival"); } @Test - @DisplayName("maps load command uses typed payload server contract") - void mapsLoadCommandUsesTypedPayloadServerContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(MapsLoadCommandV1.class); - - String stream = registry.resolveStreamKey( - descriptor, - new MapsLoadCommandV1("survival", java.util.List.of(new MapFileSourceV1("https://example/maps/a.msav", "a.msav"))), - "mini-pvp" - ); - - assertThat(stream).isEqualTo("xcore:cmd:maps-load:survival"); + @DisplayName("unsupported payloads throw when routing") + void testUnregisteredPayloadThrows() { + assertThat(registry.routeDescriptorFor(new Object())).isNull(); + assertThatThrownBy(() -> router.route(new Object(), "mini-pvp")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining(Object.class.getName()); } @Test - @DisplayName("player session commands use typed payload server contract") - void playerSessionCommandsUseTypedPayloadServerContract() { - String customNicknameStream = registry.resolveStreamKey( - registry.routeDescriptorFor(PlayerCustomNicknameChangedCommandV1.class), - new PlayerCustomNicknameChangedCommandV1("uuid", "Commander", "survival"), - "mini-pvp" - ); - String activeBadgeStream = registry.resolveStreamKey( - registry.routeDescriptorFor(PlayerActiveBadgeChangedCommandV1.class), - new PlayerActiveBadgeChangedCommandV1("uuid", "translator", "hexed"), - "mini-pvp" - ); - String badgeColorModeStream = registry.resolveStreamKey( - registry.routeDescriptorFor(PlayerBadgeSymbolColorModeChangedCommandV1.class), - new PlayerBadgeSymbolColorModeChangedCommandV1("uuid", "player-color", "mini-hexed"), - "mini-pvp" - ); + @DisplayName("typed payload server routing remains registered") + void typedPayloadServerRoutingRemainsRegistered() { + var descriptor = registry.routeDescriptorFor(ChatDiscordIngressCommandV1.class); - assertThat(customNicknameStream).isEqualTo("xcore:cmd:player-custom-nickname:survival"); - assertThat(activeBadgeStream).isEqualTo("xcore:cmd:player-active-badge:hexed"); - assertThat(badgeColorModeStream).isEqualTo("xcore:cmd:player-badge-symbol-color-mode:mini-hexed"); + assertThat(descriptor).isNotNull(); + assertThat(registry.resolveStreamKey(descriptor, new ChatDiscordIngressCommandV1("bot", "hello", "survival"), "mini-pvp")) + .isEqualTo("xcore:cmd:discord-message:survival"); } - @Test - @DisplayName("read-only and mutating classification comes from registry descriptors") - void classificationComesFromRegistry() { - assertThat(registry.isReadOnlyType(ChatMessageV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ChatGlobalV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ChatDiscordIngressCommandV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ChatPrivateV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(PlayerJoinLeaveV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ServerActionV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ServerHeartbeatV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(DiscordLinkStatusChangedV1.class)).isTrue(); - assertThat(registry.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(MapsLoadCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(PlayerCustomNicknameChangedCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(PlayerActiveBadgeChangedCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(PlayerBadgeSymbolColorModeChangedCommandV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ModerationBanCreatedV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); - assertThat(registry.isReadOnlyType(ModerationAuditAppendedV1.class)).isTrue(); - assertThat(registry.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(ModerationPardonCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(ServerCommandExecuteCommandV1.class)).isTrue(); - assertThat(registry.isMutatingType(ChatGlobalV1.class)).isFalse(); + private record TestServerScopedEvent(String server) implements TransportEvents.ServerScopedEvent { } } From 36be5f975811c890fc477231b158e2f344d1d29c Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sun, 3 May 2026 17:08:59 +0300 Subject: [PATCH 26/26] refactor(protocol): remove legacy transport fallbacks, finalize canonical-only migration --- .../xcore-protocol-agent-playbook.md | 157 ++++---------- .../xcore-protocol-migration-plan.md | 201 ++++++------------ .../service/network/RedisRouteRegistry.java | 12 -- .../network/RedisRouteRegistryTest.java | 15 -- 4 files changed, 108 insertions(+), 277 deletions(-) diff --git a/docs/implementation/xcore-protocol-agent-playbook.md b/docs/implementation/xcore-protocol-agent-playbook.md index c89ad02..afc56de 100644 --- a/docs/implementation/xcore-protocol-agent-playbook.md +++ b/docs/implementation/xcore-protocol-agent-playbook.md @@ -1,141 +1,62 @@ # XCore Protocol Agent Playbook -## Goal -Give a future agent a concrete, ambiguity-resistant path for implementing the protocol redesign without rediscovering major design decisions. +## Status: Executed (see `XCore-plugin#5`, `XCore-discord-bot#1`, `xcore-protocol` main) -## Non-Negotiable Decisions Already Made -- The future shared repository is named **`xcore-protocol`**. +This playbook was written as implementation guidance for the protocol-first migration. All steps below have been completed. The document is retained as a historical record of implementation order and design decisions. + +## Non-Negotiable Decisions Preserved +- The shared repository is **`xcore-protocol`**. - `xcore-protocol` owns the cross-service wire protocol surface. - Application repos do not independently redefine protocol contracts. -- Canonical outbound payloads use one naming style only. -- Legacy compatibility belongs in explicit adapters, not canonical schemas. -- Migration starts with the moderation family. - -## Implementation Order - -### Step 1 — Bootstrap protocol repository structure -Create the agreed `xcore-protocol` tree with: -- docs -- spec -- fixtures -- generator configuration -- java modules -- python package -- compatibility test directories - -Do not start by implementing runtime loops. Start with the protocol boundary itself. +- Canonical outbound payloads use one naming style only (camelCase). +- Legacy compatibility is NOT retained — first deployment uses canonical-only payloads. +- Migration was executed family by family, starting with moderation. -### Step 2 — Define canonical moderation specs -Create canonical definitions for: -- ban created -- mute created -- vote-kick created -- kick-banned command -- pardon command -- moderation audit appended +## Implementation Order (Executed) -Also define any required shared subtypes. +### Step 1 — Bootstrap protocol repository structure ✓ +Created `xcore-protocol` tree: docs, spec, fixtures, generator config, java modules, python package, compatibility test directories. -### Step 3 — Add fixtures first -For each message: -- valid canonical fixture -- invalid fixture -- legacy fixture if migration support is required +### Step 2 — Define canonical moderation specs ✓ +Created specs for ban/mute/votekick/kick-banned/pardon/audit plus shared subtypes (PlayerRefV1, ActorRefV1, etc.). -### Step 4 — Implement generation scaffolding -In `xcore-protocol`: -- add Java/Python generation configuration -- generate protocol DTO/model artifacts from canonical definitions -- add validation around generated output +### Step 3 — Add fixtures ✓ +Canonical fixtures for all message families including actor semantics fixtures. -### Step 5 — Implement Java protocol support -In the Java SDK or integration layer: -- consume generated DTO/model artifacts -- add validators and serialization support around generated artifacts -- add fixture validation tests +### Step 4 — Implement generation scaffolding ✓ +Python-based codegen producing Java records and Python frozen dataclasses from canonical JSON Schema specs. -In `XCore-plugin`: -- add mapping layer from internal models to generated protocol DTOs -- stop using internal domain/storage objects as direct wire payloads +### Step 5 — Implement Java protocol support ✓ +- Plugin consumes generated `org.xcore.protocol.generated.*` DTOs. +- `DiscordProtocolMapper` and `ModerationProtocolMapper` produce canonical payloads. +- `RedisRouteRegistry` routes by generated types. +- `RedisNetworkBackend` serializes via `ProtocolPayload.toPayload()`. -### Step 6 — Implement Python protocol support -In the Python SDK: -- consume generated models -- add validators and fixture validation tests around generated artifacts +### Step 6 — Implement Python protocol support ✓ +- Bot consumes generated `xcore_protocol.generated.*` models. +- `contracts.py` uses strict `from_payload()` parsing. +- `protocol_outbound.py` builds canonical outbound payloads. -In `XCore-discord-bot`: -- adopt canonical outbound forms -- use compat adapters only where needed for inbound migration +### Step 7 — Integrate route metadata ✓ +Route registry maps generated types to stream patterns, event types, and RPC metadata. -### Step 7 — Integrate route metadata -Move route/source-of-truth metadata into protocol-owned definitions. -Application repos should consume generated route metadata rather than duplicate it. - -### Step 8 — Run compatibility tests -At minimum: -- schema validation +### Step 8 — Compatibility tests ✓ +- Schema validation - Java fixture validation - Python fixture validation -- Java/Python roundtrip or golden compatibility tests - -### Step 9 — Migrate the next family only after moderation is stable -Proceed in this order: -1. moderation -2. Discord linking/admin -3. maps RPC -4. chat/heartbeat/misc - -## What Not To Decide Again -Do not reopen these decisions unless explicitly directed: -- repo name -- protocol-first direction -- moderation-first rollout -- canonical naming policy -- legacy compatibility isolation -- shared repo scope boundaries +- Cross-language roundtrip compatibility tests +- Plugin and bot integration tests green -## What The Agent Should Clarify Only If Missing -- exact final field set for a specific message schema -- whether a specific timestamp is business-time or transport-time -- whether a specific historical payload still needs a compat window +### Step 9 — All families migrated ✓ +1. moderation ✓ +2. Discord linking/admin ✓ +3. maps RPC ✓ +4. chat/heartbeat/misc ✓ -These are implementation details within the documented model, not reasons to revisit the architecture. - -## Suggested Acceptance Criteria By Slice - -### For each migrated message family -- canonical schema exists -- route metadata exists -- valid/invalid fixtures exist -- generated Java support exists -- generated Python support exists -- compatibility tests exist -- application repos are updated to use generated canonical protocol artifacts - -### For moderation slice completion -- no new moderation producer publishes duplicate field naming styles -- canonical moderation payloads are independent from internal persistence/domain model shape -- bot-side moderation handling can validate canonical moderation messages without alias sprawl in the main path - -## Validation Guidance -When app repos are updated: -- use targeted tests during iteration -- finish with broad validation appropriate to the repo -- for `XCore-plugin`, default final validation should align with repository guidance (`./gradlew test`, and `./gradlew test shadowJar` when transport/build surface is affected) - -## Deliverables Expected From Implementation Work +## Deliverables Delivered - protocol repo structure -- initial moderation specs +- canonical specs for all families - generation scaffolding and generated artifacts - fixtures and compatibility tests - integration changes in `XCore-plugin` - integration changes in `XCore-discord-bot` -- migration notes for the next family - -## Definition Of Done For The Planning Packet -This planning packet is considered successful if a future agent can begin implementation without asking: -- where the protocol should live -- what belongs in the shared repo -- which family to migrate first -- whether contracts should be normalized -- how compatibility should be handled diff --git a/docs/migrations/xcore-protocol-migration-plan.md b/docs/migrations/xcore-protocol-migration-plan.md index 0d3364b..e764f8e 100644 --- a/docs/migrations/xcore-protocol-migration-plan.md +++ b/docs/migrations/xcore-protocol-migration-plan.md @@ -1,45 +1,39 @@ -# XCore Protocol Migration Plan +# XCore Protocol Migration Record -## Goal -Provide a phased migration strategy from the current Redis contract model to the future `xcore-protocol` model. +## Status: Implemented -## Migration Principles -- Prefer additive migration over big-bang replacement. -- Keep current consumers operational during transition. -- Move compatibility concerns into explicit adapters. -- Migrate by message family, not by random file batches. -- Start with the highest-value cross-repo family first. +The migration from ad-hoc Redis contract model to canonical `xcore-protocol` model is complete across all planned message families. This document records what was done, not what remains to be done. -## Phase 0 — Documentation And Design Freeze -Create and approve the design packet in `XCore-plugin`. +## Relationship to PRs -Deliverables: -- ADR -- target architecture -- message model -- repo blueprint -- migration plan -- agent playbook +- `XCore-plugin#5` — plugin-side protocol adoption +- `XCore-discord-bot#1` — bot-side protocol adoption +- `xcore-protocol` main branch — canonical schemas, fixtures, generated Java/Python artifacts -Exit criteria: -- target-state decisions no longer need to be rediscovered during implementation +## Completed Phases -## Phase 1 — Bootstrap `xcore-protocol` -Create the new repository with: +### Phase 0 — Documentation And Design Freeze +Created the design packet in `XCore-plugin`: +- ADR (`docs/adr/ADR-redis-to-protocol-first.md`) +- target architecture (`docs/architecture/xcore-protocol-target-architecture.md`) +- message model (`docs/architecture/xcore-protocol-message-model.md`) +- repo blueprint (`docs/architecture/xcore-protocol-repository-blueprint.md`) +- migration plan (this document) +- agent playbook (`docs/implementation/xcore-protocol-agent-playbook.md`) +### Phase 1 — Bootstrap `xcore-protocol` +Created the `xcore-protocol` repository with: - README and mission statement - versioning and compatibility policies -- initial spec directories -- initial fixture directories -- generator scaffolding/configuration -- Java and Python package skeletons for generated artifacts and thin support - -Exit criteria: -- the protocol repository exists with agreed structure and contribution rules - -## Phase 2 — Moderation Family First - -### Included message families +- spec directories for all message families +- fixture directories +- generator and codegen pipeline +- Java module with generated protocol DTOs + runtime support (`ProtocolPayload`) +- Python package with generated models + validation helpers +- cross-language compatibility tests + +### Phase 2 — Moderation Family +Implemented: - `moderation.ban.created` - `moderation.mute.created` - `moderation.vote-kick.created` @@ -47,113 +41,56 @@ Exit criteria: - `moderation.pardon.command` - `moderation.audit.appended` -### Work items -- define canonical schemas -- define route metadata -- define shared subtypes used by moderation -- create canonical fixtures -- create legacy compatibility fixtures for existing payload forms -- generate Java and Python models for moderation contracts -- add thin handwritten validation/runtime support around generated artifacts - -### Application changes -`XCore-plugin`: -- introduce mapping to generated protocol DTOs -- stop treating internal punishment/domain objects as the wire contract - -`XCore-discord-bot`: -- adopt canonical outbound payloads -- move alias-heavy parsing into compatibility adapters where still needed +Plugin and bot both publish/consume canonical moderation DTOs via generated `org.xcore.protocol.generated.messages.moderation.*`. -Exit criteria: -- moderation contracts are defined and consumed through the protocol model - -## Phase 3 — Discord Linking/Admin Contracts - -### Included messages +### Phase 3 — Discord Linking/Admin Contracts +Implemented: - `discord.link.confirm.command` - `discord.unlink.command` - `discord.link.status-changed` - `discord.admin-access.changed.command` +- `discord.link-code-created` -Focus: -- canonical field naming -- timestamp consistency -- command vs event separation - -## Phase 4 — Maps RPC Contracts - -### Included messages +### Phase 4 — Maps RPC Contracts +Implemented: - `maps.list.request` - `maps.list.response` - `maps.remove.request` - `maps.remove.response` -Focus: -- explicit request/response pairing -- canonical request shape -- remove duplicate outbound field naming like `fileName` + `file_name` - -## Phase 5 — Chat / Heartbeat / Misc -Migrate: -- chat messages -- global chat -- heartbeat -- server action and join/leave generated message cutovers -- raw fallback removal and remaining legacy event-name cleanup - -Focus: -- normalize event type naming -- isolate historical forms into compatibility adapters - -## Compatibility Strategy During Migration - -### Producers -All new or upgraded producers send the canonical protocol form. - -### Consumers -Consumers may temporarily support legacy payload forms, but only via explicit compatibility readers. - -### Legacy handling -- legacy names and field aliases remain documented -- compat coverage must include fixtures and tests -- every compat rule gets an owner and sunset condition - -## Suggested Current-To-Target Mapping Themes - -### Current state patterns to remove -- duplicate canonical field spellings -- outbound duplication of multiple naming styles -- reliance on internal Java class shape for public contracts -- legacy event names handled as first-class canonical types - -### Current state patterns to keep conceptually -- broadcast event vs targeted command distinction -- request/response correlation concept -- stream naming discipline as a route metadata concern -- explicit DLQ and idempotency semantics - -## Validation Expectations Per Phase -- protocol specs validate -- fixtures validate -- generated Java SDK validates fixtures -- generated Python SDK validates fixtures -- cross-language compatibility checks pass -- application repos pass their targeted migration tests before broader validation - -## Risks -- under-specifying compatibility windows -- moving too many families at once -- accidentally turning `xcore-protocol` into a generic utility repository -- keeping legacy aliases in canonical schemas for too long - -## Risk Controls -- migrate family by family -- keep canonical schema strict -- document legacy support separately -- require schema + fixture + test updates together - -## Completion Criteria -- `XCore-plugin` and `XCore-discord-bot` both consume generated protocol artifacts for the migrated families -- canonical outbound payloads are used consistently -- historical compatibility is localized rather than spread through business logic +### Phase 5 — Chat / Heartbeat / Misc +Implemented: +- chat messages (`ChatMessageV1`) +- global chat (`ChatGlobalV1`) +- server heartbeat (`ServerHeartbeatV1`) +- player join/leave (`PlayerJoinLeaveV1`) +- server actions (`ServerActionV1`) +- player state change commands (nickname, badge, cache reload, password reset, etc.) +- server command execution (`ServerCommandExecuteCommandV1`) + +Legacy fallback paths (raw transport, snake_case aliases, `TransportEvents.ServerScopedEvent`) have been removed. + +## Transport Model (Current State) + +- Plugin publishes and consumes only canonical generated protocol DTOs. +- `RedisRouteRegistry` registers generated protocol classes only. +- `RedisNetworkBackend` serializes protocol payloads via `ProtocolPayload.toPayload()`. +- `RedisStreamRouter` routes strictly by generated type. +- Bot uses strict `from_payload()` parsing with no legacy alias normalization. + +## Validation Surface + +- protocol schema validation +- canonical fixture validation (Java + Python) +- cross-language roundtrip compatibility tests +- plugin integration tests (`./gradlew test`) +- bot integration tests (`uv run pytest tests/`) + +## Design Decisions Preserved + +- `xcore-protocol` owns the canonical wire contract surface. +- Application repos consume generated artifacts, not self-defined DTOs. +- Canonical payloads use one field naming style (camelCase). +- `actor` = concrete initiator, `source` = provenance/authority. +- Migration was additive by family, not big-bang. +- No backward-compatibility legacy paths retained — first deployment uses canonical-only schema. diff --git a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java index f028404..33702a7 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,6 +1,5 @@ package org.xcore.plugin.service.network; -import org.xcore.plugin.event.TransportEvents; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; @@ -60,17 +59,6 @@ public final class RedisRouteRegistry { if (mapsServer != null && !mapsServer.isBlank()) { return mapsServer; } - // Legacy migration fallback — TransportEvents.ServerScopedEvent is retained - // for backward compatibility during protocol migration rollout. - // This branch will be removed once all payloads use generated protocol DTOs - // and TransportEvents.ServerScopedEvent is no longer needed. - // See: xcore-protocol migration plan for removal timeline. - if (payload instanceof TransportEvents.ServerScopedEvent serverScopedEvent) { - String server = serverScopedEvent.server(); - if (server != null && !server.isBlank()) { - return server; - } - } return defaultServer; }; diff --git a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java index 9c143a1..fe60165 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.event.TransportEvents; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; @@ -87,17 +86,6 @@ void testMapsLoadCommandRoutesToMapsStream() { .isEqualTo("xcore:cmd:maps-load:survival"); } - @Test - @DisplayName("legacy server scoped event fallback resolves server") - void testLegacyServerScopedEventFallsBackToServer() { - var descriptor = registry.routeDescriptorFor(MapsLoadCommandV1.class); - var payload = new TestServerScopedEvent("legacy-survival"); - - assertThat(descriptor).isNotNull(); - assertThat(registry.resolveStreamKey(descriptor, payload, "mini-pvp")) - .isEqualTo("xcore:cmd:maps-load:legacy-survival"); - } - @Test @DisplayName("unsupported payloads throw when routing") void testUnregisteredPayloadThrows() { @@ -116,7 +104,4 @@ void typedPayloadServerRoutingRemainsRegistered() { assertThat(registry.resolveStreamKey(descriptor, new ChatDiscordIngressCommandV1("bot", "hello", "survival"), "mini-pvp")) .isEqualTo("xcore:cmd:discord-message:survival"); } - - private record TestServerScopedEvent(String server) implements TransportEvents.ServerScopedEvent { - } }