diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b64a91c5..cd4ff9a0 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 469a6a53..3fbf16ed 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 }} diff --git a/build.gradle.kts b/build.gradle.kts index 4461f462..62a99ce1 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") @@ -62,6 +64,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/docs/adr/ADR-redis-to-protocol-first.md b/docs/adr/ADR-redis-to-protocol-first.md new file mode 100644 index 00000000..410511ba --- /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 00000000..7294cd87 --- /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 00000000..04c986fd --- /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 00000000..663d8c34 --- /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 00000000..afc56de6 --- /dev/null +++ b/docs/implementation/xcore-protocol-agent-playbook.md @@ -0,0 +1,62 @@ +# XCore Protocol Agent Playbook + +## Status: Executed (see `XCore-plugin#5`, `XCore-discord-bot#1`, `xcore-protocol` main) + +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 (camelCase). +- Legacy compatibility is NOT retained — first deployment uses canonical-only payloads. +- Migration was executed family by family, starting with moderation. + +## Implementation Order (Executed) + +### Step 1 — Bootstrap protocol repository structure ✓ +Created `xcore-protocol` tree: docs, spec, fixtures, generator config, java modules, python package, compatibility test directories. + +### Step 2 — Define canonical moderation specs ✓ +Created specs for ban/mute/votekick/kick-banned/pardon/audit plus shared subtypes (PlayerRefV1, ActorRefV1, etc.). + +### Step 3 — Add fixtures ✓ +Canonical fixtures for all message families including actor semantics fixtures. + +### Step 4 — Implement generation scaffolding ✓ +Python-based codegen producing Java records and Python frozen dataclasses from canonical JSON Schema specs. + +### 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 ✓ +- Bot consumes generated `xcore_protocol.generated.*` models. +- `contracts.py` uses strict `from_payload()` parsing. +- `protocol_outbound.py` builds canonical outbound payloads. + +### Step 7 — Integrate route metadata ✓ +Route registry maps generated types to stream patterns, event types, and RPC metadata. + +### Step 8 — Compatibility tests ✓ +- Schema validation +- Java fixture validation +- Python fixture validation +- Cross-language roundtrip compatibility tests +- Plugin and bot integration tests green + +### Step 9 — All families migrated ✓ +1. moderation ✓ +2. Discord linking/admin ✓ +3. maps RPC ✓ +4. chat/heartbeat/misc ✓ + +## Deliverables Delivered +- protocol repo structure +- 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` diff --git a/docs/migrations/xcore-protocol-migration-plan.md b/docs/migrations/xcore-protocol-migration-plan.md new file mode 100644 index 00000000..e764f8eb --- /dev/null +++ b/docs/migrations/xcore-protocol-migration-plan.md @@ -0,0 +1,96 @@ +# XCore Protocol Migration Record + +## Status: Implemented + +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. + +## Relationship to PRs + +- `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 + +## Completed Phases + +### 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 +- 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` +- `moderation.kick-banned.command` +- `moderation.pardon.command` +- `moderation.audit.appended` + +Plugin and bot both publish/consume canonical moderation DTOs via generated `org.xcore.protocol.generated.messages.moderation.*`. + +### 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` + +### Phase 4 — Maps RPC Contracts +Implemented: +- `maps.list.request` +- `maps.list.response` +- `maps.remove.request` +- `maps.remove.response` + +### 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2674d7e2..e46130f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] mindustry = "157" +xcore-protocol = "0.4.0" # 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" } 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 200ee50c..647c610b 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/command/controller/client/SocialController.java b/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java index 4e5ddb76..cfe2b3f3 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/command/controller/server/BadgeAdminController.java b/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java index f0a14e06..031d8dfb 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/command/controller/server/MaintainController.java b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java index 7bc1d710..7b50ad42 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 0dfae1ca..2c0348a2 100644 --- a/src/main/java/org/xcore/plugin/event/TransportEvents.java +++ b/src/main/java/org/xcore/plugin/event/TransportEvents.java @@ -1,12 +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; - public class TransportEvents { public interface Event {} @@ -14,204 +7,4 @@ public interface ServerScopedEvent { String server(); } - 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( - String fromUuid, - int fromPid, - String fromName, - String toUuid, - int toPid, - String message, - 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) {} - - 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) {} - - public record DiscordLinkCodeCreatedEvent( - String code, - String playerUuid, - int playerPid, - String playerNickname, - String server, - long createdAt, - long expiresAt - ) implements ServerScopedEvent {} - - public record DiscordLinkConfirmEvent( - String code, - String playerUuid, - int playerPid, - String discordId, - String discordUsername, - String server, - long confirmedAt - ) implements ServerScopedEvent {} - - public record DiscordUnlinkEvent( - String playerUuid, - int playerPid, - String discordId, - String requestedBy, - String server, - long requestedAt - ) implements ServerScopedEvent {} - - public record DiscordLinkStatusChangedEvent( - String playerUuid, - int playerPid, - String playerNickname, - String discordId, - String discordUsername, - String action, - String server, - long occurredAt - ) implements ServerScopedEvent {} - - public record DiscordAdminAccessChanged( - String playerUuid, - int playerPid, - String discordId, - String discordUsername, - boolean admin, - String adminSource, - String requestedBy, - String reason, - String server, - long occurredAt - ) implements ServerScopedEvent {} - - 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 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); - } - } - - 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; - } } diff --git a/src/main/java/org/xcore/plugin/event/TransportService.java b/src/main/java/org/xcore/plugin/event/TransportService.java index e2c797e3..836aecaf 100644 --- a/src/main/java/org/xcore/plugin/event/TransportService.java +++ b/src/main/java/org/xcore/plugin/event/TransportService.java @@ -10,6 +10,8 @@ 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; import org.xcore.plugin.event.transport.DiscordLinkTransportHandler; @@ -54,11 +56,11 @@ 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 { - network.post(new TransportEvents.ServerHeartbeatEvent( + network.post(new ServerHeartbeatV1( config.server, config.discordChannelId, Groups.player.size(), 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 cc0db43b..2c3594a8 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 ca54b041..1fc50c6c 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/event/net/chat/ChatMessageHandler.java b/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java index c57a3620..35f3ac2d 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 1076bc84..850660cd 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java @@ -4,8 +4,10 @@ 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.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; @@ -34,7 +36,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(), @@ -43,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; } @@ -55,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/event/transport/DiscordLinkTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java index b72ef565..f280139f 100644 --- a/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java @@ -3,10 +3,11 @@ 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; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; import static com.ospx.flubundle.Bundle.args; @@ -30,29 +31,35 @@ public DiscordLinkTransportHandler(NetworkService network, } public void registerListeners() { - network.subscribe(TransportEvents.DiscordLinkConfirmEvent.class, e -> { + 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/MapTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java index ad52ba55..558d01c9 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,19 @@ 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.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; +import org.xcore.plugin.service.network.MapsProtocolMapper; +import java.util.ArrayList; import java.util.concurrent.atomic.AtomicInteger; import static mindustry.Vars.customMapDirectory; @@ -39,71 +45,52 @@ 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 -> { - 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()); }); - 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()); + 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/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index 8e4f21df..4b20c055 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,13 +8,21 @@ 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.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.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.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; import java.util.HashSet; import java.util.function.Consumer; @@ -28,7 +35,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,37 +42,45 @@ 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.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(ModerationKickBannedCommandV1.class, e -> Groups.player.each( + p -> { + var target = e.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 -> { + 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()); } }); - network.subscribe(TransportEvents.PardonPlayer.class, e -> { - Administration.PlayerInfo info = netServer.admins.getInfoOptional(e.uuid()); + network.subscribe(ModerationPardonCommandV1.class, e -> { + Administration.PlayerInfo info = netServer.admins.getInfoOptional(e.target().playerUuid()); if (info != null) { info.lastKicked = 0; @@ -75,54 +89,54 @@ 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" )); - 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" )); - 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/gamemode/hexed/MiniHexedService.java b/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java index 536b1aef..3a6efb98 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/DiscordLinkService.java b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java index 7e3f51eb..3e233d39 100644 --- a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java +++ b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java @@ -4,8 +4,9 @@ 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; 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,26 +257,46 @@ 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( + 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, + playerName, discordId, discordUsername, admin, - adminSource, - requestedBy, + source, + actor, reason, config.server, System.currentTimeMillis() )); } + 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, + actor, + 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/NetworkService.java b/src/main/java/org/xcore/plugin/service/NetworkService.java index 6f4f4a3e..5c7bf747 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/PrivateMessageService.java b/src/main/java/org/xcore/plugin/service/PrivateMessageService.java index 969b4640..a784ec14 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/moderation/ModerationService.java b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java index cf0b551c..f3b6a484 100644 --- a/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java +++ b/src/main/java/org/xcore/plugin/service/moderation/ModerationService.java @@ -2,10 +2,10 @@ 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; -import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.model.AuditAction; import org.xcore.plugin.model.AuditActor; import org.xcore.plugin.model.AuditActorType; @@ -20,8 +20,10 @@ 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; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationPardonCommandV1; import java.time.Duration; import java.time.Instant; @@ -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.toKickBannedCommand( + 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(toPardonCommand(target.uuid, target.pid, target.nickname, null, 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.toMuteCreated(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(toPardonCommand(target.uuid, target.pid, target.nickname, null, audit)); return ModerationResult.success("Player '" + target.nickname + "' unmuted successfully", target); } @@ -277,9 +291,18 @@ public ModerationResult tempBanByUuidOrIp(String uuid, String ip, Strin null ); - network.post(ban); + if (hasUuid(uuid)) { + postBanEvents(ban, audit); + } postAuditEvent(audit); - network.post(new TransportEvents.KickBannedPlayer(uuid, ip)); + network.post(ModerationProtocolMapper.toKickBannedCommand( + uuid, + null, + ban.name, + ip, + config.server, + commandOccurredAt(audit) + )); return ModerationResult.success("Player '" + ban.name + "' banned until " + expire, ban); } @@ -311,6 +334,7 @@ public ModerationResult tempUnban(String uuid, String ip, String adminName ); postAuditEvent(audit); + network.post(toPardonCommand(uuid, null, UNKNOWN_PLAYER_NAME, ip, audit)); return ModerationResult.success("Unbanned: UUID=" + uuid + " / IP=" + ip, null); } @@ -373,10 +397,33 @@ private AuditRecord appendAudit(AuditAction action, private void postAuditEvent(AuditRecord audit) { if (audit != null) { - network.post(toAuditEvent(audit)); + network.post(ModerationProtocolMapper.toAuditAppended(audit, config.server)); } } + 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, String ip, AuditRecord audit) { + return ModerationProtocolMapper.toPardonCommand( + uuid, + pid, + playerName, + ip, + 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,29 +473,14 @@ 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; } + 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/DiscordProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java new file mode 100644 index 00000000..7084daf7 --- /dev/null +++ b/src/main/java/org/xcore/plugin/service/network/DiscordProtocolMapper.java @@ -0,0 +1,250 @@ +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; + +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), + toLinkStatusAction(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, + toSourceActor(adminSource), + toRequesterActor(requestedBy), + requireNonBlank(reason, "reason"), + requireNonBlank(server, "server"), + toOccurredAt(occurredAt) + ); + } + + /** + * 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, + 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) + ); + } + + /** + * 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"), + normalizeOptionalPid(playerPid), + requirePlayerName(playerName), + null + ); + } + + private static DiscordIdentityRefV1 toDiscordIdentity(String discordId, String discordUsername) { + return new DiscordIdentityRefV1( + requireNonBlank(discordId, "discordId"), + normalizeOptional(discordUsername) + ); + } + + public static ActorRefV1 toSourceActor(String adminSource) { + String sourceName = requireNonBlank(adminSource, "adminSource"); + return new ActorRefV1(sourceName, null, resolveSourceActorType(sourceName)); + } + + /** + * 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; + 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; + } + + 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/MapsProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java new file mode 100644 index 00000000..375a27f6 --- /dev/null +++ b/src/main/java/org/xcore/plugin/service/network/MapsProtocolMapper.java @@ -0,0 +1,52 @@ +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; + +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 MapsRemoveResponseV1 toMapsRemoveResponse(String server, String result) { + return new MapsRemoveResponseV1(server, result); + } + + 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/ModerationProtocolMapper.java b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java new file mode 100644 index 00000000..44844553 --- /dev/null +++ b/src/main/java/org/xcore/plugin/service/network/ModerationProtocolMapper.java @@ -0,0 +1,258 @@ +package org.xcore.plugin.service.network; + +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.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; +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.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; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class ModerationProtocolMapper { + private ModerationProtocolMapper() { + } + + 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.adminName), normalizeOptional(ban.adminDiscordId), resolveActorType(ban.adminDiscordId)), + resolveReason(ban.reason), + toExpirationInfo(ban), + normalizeOptional(server), + occurredAt.toString() + ); + } + + 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 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 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( + normalizeOptional(playerUuid), + normalizeOptionalPid(playerPid), + normalizeOptional(playerName), + normalizeOptional(ip) + ), + requireNonBlank(server, "server"), + toOccurredAt(requestedAt) + ); + } + + public static ModerationPardonCommandV1 toPardonCommand( + String playerUuid, + Integer playerPid, + String playerName, + String ip, + String server, + Instant requestedAt + ) { + return new ModerationPardonCommandV1( + new PlayerCommandTargetV1( + normalizeOptional(playerUuid), + normalizeOptionalPid(playerPid), + normalizeOptional(playerName), + normalizeOptional(ip) + ), + requireNonBlank(server, "server"), + toOccurredAt(requestedAt) + ); + } + + public static ModerationAuditAppendedV1 toAuditAppended(AuditRecord record, String server) { + Objects.requireNonNull(record, "record must not be null"); + + return new ModerationAuditAppendedV1( + toAuditEntryType(record.action), + new ModerationTargetRefV1( + normalizeOptional(record.target == null ? null : record.target.uuid), + record.target == null ? null : normalizeOptionalPid(record.target.pid), + 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), + 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) + ); + } + + 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(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 ModerationAuditAppendedV1EntryType toAuditEntryType(AuditAction action) { + if (action == null) { + return ModerationAuditAppendedV1EntryType.OTHER; + } + return switch (action) { + case BAN -> ModerationAuditAppendedV1EntryType.BAN; + case MUTE -> ModerationAuditAppendedV1EntryType.MUTE; + case UNBAN, UNMUTE -> ModerationAuditAppendedV1EntryType.PARDON; + default -> ModerationAuditAppendedV1EntryType.OTHER; + }; + } + + private static ActorRefV1ActorType toProtocolActorType(AuditActorType actorType) { + if (actorType == null) { + return ActorRefV1ActorType.SYSTEM; + } + return switch (actorType) { + case DISCORD_USER -> ActorRefV1ActorType.DISCORD; + case PLAYER_ADMIN -> ActorRefV1ActorType.PLAYER; + case SERVER_CONSOLE -> ActorRefV1ActorType.SERVER; + case SYSTEM -> ActorRefV1ActorType.SYSTEM; + }; + } + + private static String resolveActorName(String actorName) { + String normalized = normalizeOptional(actorName); + return normalized == null ? "Unknown" : normalized; + } + + private static ActorRefV1ActorType resolveActorType(String actorDiscordId) { + return normalizeOptional(actorDiscordId) == null ? ActorRefV1ActorType.UNKNOWN : ActorRefV1ActorType.DISCORD; + } + + private static String resolveReason(String 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) { + 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 48ada020..f4fe0b2b 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -15,10 +15,9 @@ 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 org.xcore.protocol.generated.runtime.ProtocolPayload; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -29,7 +28,6 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import java.time.Instant; @Singleton @@ -147,7 +145,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); RedisCommands commands = connectionManager.commands(); streamSupport.xaddWithTrim(commands, route.streamKey(), envelopeFactory.eventFields(route, payloadJson, now)); publishedEvents.incrementAndGet(); @@ -193,7 +191,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 +199,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) { @@ -235,7 +233,7 @@ public RequestSubscription request(Request request, C return requestHandle; } - String requestJson = gson.toJson(request); + String requestJson = payloadJson(request); RedisCommands commands = connectionManager.commands(); try { streamSupport.xaddWithTrim(commands, route.streamKey(), @@ -249,7 +247,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()); @@ -263,7 +261,7 @@ public void respond(Request request, T 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; @@ -274,9 +272,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; } @@ -300,10 +295,17 @@ public T withCommands(java.util.function.Function request) { + public boolean supportsRespond(Object request) { return rpcTracker.contains(request); } + private String payloadJson(Object event) { + if (event instanceof ProtocolPayload protocolPayload) { + return gson.toJson(protocolPayload.toPayload()); + } + return gson.toJson(event); + } + private boolean ensureConnected() { return connectionManager.ensureConnected(); } @@ -485,12 +487,12 @@ private boolean dispatchStreamMessage(RedisCommands consumer } try { - T event = gson.fromJson(payloadJson, type); - if (event instanceof Request request && router.isRpcRequestType(type)) { + T event = decodeEvent(payloadJson, 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); @@ -505,14 +507,19 @@ private boolean dispatchStreamMessage(RedisCommands consumer } } - private void awaitRpcResponse(String replyTo, - String correlationId, - Class responseType, - Cons listener, - Runnable timeout, - long timeoutMs, - CountDownLatch listenerReady, - RedisRequestHandle requestHandle) { + @SuppressWarnings("unchecked") + 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) { 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 115eda72..bdf84d90 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 01c703f6..33702a79 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisRouteRegistry.java @@ -1,22 +1,63 @@ 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.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.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; +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 java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; 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) -> { - if (payload instanceof TransportEvents.ServerScopedEvent serverScopedEvent) { - String server = serverScopedEvent.server(); - if (server != null && !server.isBlank()) { - return server; - } + String moderationServer = moderationServer(payload); + if (moderationServer != null && !moderationServer.isBlank()) { + return moderationServer; + } + String discordServer = discordServer(payload); + 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; } return defaultServer; }; @@ -29,21 +70,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) { @@ -73,7 +100,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(); } @@ -104,33 +131,113 @@ public List descriptors() { } private void registerDefaults() { - register(readOnly(TransportEvents.MessageEvent.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(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(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(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(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(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)); + register(readOnly(ChatMessageV1.class, "xcore:evt:chat:message", "chat.message", 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)); + 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())); + 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(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(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)); + 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(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(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)); + } + + 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 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(); + } + if (payload instanceof ChatDiscordIngressCommandV1 command) { + return command.server(); + } + return null; + } + + 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(); + } + 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(); + } + if (payload instanceof PlayerBadgeInventoryChangedCommandV1 command) { + return command.server(); + } + if (payload instanceof PlayerPasswordResetCommandV1 command) { + return command.server(); + } + if (payload instanceof PlayerDataCacheReloadCommandV1 command) { + return command.server(); + } + return null; } private void register(RedisRouteDescriptor descriptor) { @@ -158,7 +265,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 eb1c4065..e53f966b 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 96b2f5e0..6811a765 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisStreamRouter.java @@ -1,9 +1,6 @@ package org.xcore.plugin.service.network; -import org.xcore.plugin.event.TransportEvents; - import java.util.List; -import java.util.Locale; public final class RedisStreamRouter { private final RedisRouteRegistry registry; @@ -21,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) { @@ -49,7 +45,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 00a29163..73687233 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,35 @@ 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.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; +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; +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.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 java.util.List; import java.util.Map; @@ -35,38 +62,39 @@ public record RouteSpec( ServerScope serverScope, boolean readOnly, boolean rpcRequest, - Class responseType + Class responseType ) { } 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(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(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.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(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(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), - 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) + route(ChatMessageV1.class, "xcore:evt:chat:message", "chat.message", 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), + 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), + 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(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(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), + 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(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(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) ); public static final Map, RouteSpec> ROUTES_BY_TYPE = ROUTES.stream() @@ -98,7 +126,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/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java b/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java index 00664d64..518fcd30 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/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index 76ff675d..3d46990a 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -10,16 +10,20 @@ 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; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.localization.Localization; 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.messages.moderation.ModerationMessages.ModerationVoteKickCreatedV1; +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; @@ -107,16 +111,16 @@ 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)); } } - private TransportEvents.VoteKickEvent buildVoteKickEvent(String status) { + private ModerationVoteKickCreatedV1 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 +137,23 @@ private TransportEvents.VoteKickEvent buildVoteKickEvent(String status) { } }); - return new TransportEvents.VoteKickEvent( - safePlayerName(targetData, target), - safePid(targetData), + return ModerationProtocolMapper.toVoteKickCreated( 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,8 +209,8 @@ public void success() { target.kick(Packets.KickReason.vote, (long) globalConfig.voteKickBanDurationMinutes * 60 * 1000); if (network != null) { - network.post(buildVoteKickEvent("success")); - network.post(new TransportEvents.ServerActionEvent( + network.post(buildVoteKickEvent()); + network.post(new ServerActionV1( systemLocal.format("votekick-success", bundleArgs), config.server)); } onKick.get(target); 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 e41b7d99..e6c07940 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/event/NetEventServiceTest.java b/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java index 5f7403f4..2c903427 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/handler/ConnectionHandlerTest.java b/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java index 2603c069..62d983f4 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/event/transport/ChatTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java index 4f3e76dd..79c88781 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java @@ -3,8 +3,12 @@ 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.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.plugin.config.Config; -import org.xcore.plugin.event.TransportEvents; +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; @@ -14,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 { @@ -37,8 +45,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"), @@ -67,12 +75,63 @@ 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); } + @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/event/transport/DiscordLinkTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java index 3790f9b9..db2edbe7 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,12 @@ 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.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; +import org.xcore.protocol.generated.shared.DiscordIdentityRefV1; +import org.xcore.protocol.generated.shared.PlayerRefV1; import java.util.HashMap; import java.util.Map; @@ -25,8 +31,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 +57,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 +90,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"), + new ActorRefV1("discord", null, ActorRefV1ActorType.SYSTEM), + "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 419ed6f1..edfcdfc7 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -1,28 +1,46 @@ 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.event.TransportEvents; +import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.database.repository.PlayerDataRepository; 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; +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.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.ActorRefV1; +import org.xcore.protocol.generated.shared.ActorRefV1ActorType; +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; 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; @@ -44,18 +62,17 @@ 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); - 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); @@ -64,28 +81,33 @@ 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, + new ActorRefV1(DiscordAdminAccessService.SOURCE_DISCORD_ROLE, null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), + "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); - 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); @@ -94,15 +116,145 @@ 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, + new ActorRefV1(DiscordAdminAccessService.SOURCE_DISCORD_ROLE, null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), + "sync", + "mini-pvp", + "2026-04-28T00:00:11Z" )); 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); + } + + @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/DiscordLinkServiceTest.java b/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java index fbdd843d..fe523df5 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/PrivateMessageServiceTest.java b/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java index 41566f28..b0a63a65 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/moderation/ModerationServiceAvajeTest.java b/src/test/java/org/xcore/plugin/service/moderation/ModerationServiceAvajeTest.java index 19c9453e..5cd114f8 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,23 @@ 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.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.shared.ActorRefV1ActorType; 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.config.Config; +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 +62,7 @@ class ModerationServiceAvajeTest { private FindService find; private TimeService time; private AuditService auditService; + private Config config; private Administration admins; @BeforeEach @@ -85,9 +98,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 +140,34 @@ 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 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()) + && ActorRefV1ActorType.DISCORD == 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 ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE) + && "test-server".equals(auditEvent.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 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()) @@ -147,6 +175,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() { @@ -169,9 +221,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 ModerationBanCreatedV1)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationKickBannedCommandV1)); } @Test @@ -192,9 +244,43 @@ void tempUnbanSuccess() { var result = moderationService.tempUnban("uuid-2", null, "console", null); assertThat(result.isSuccess()).isTrue(); + verify(network).post(argThat(event -> + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); + verify(network).post(argThat(event -> + 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); } + @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() { @@ -204,7 +290,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 ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationPardonCommandV1)); } @Test @@ -285,8 +372,20 @@ 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 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 ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); } @Test @@ -317,8 +416,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 ModerationMuteCreatedV1)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); } @Test @@ -334,6 +433,14 @@ void unmuteByIdSuccess() { var result = moderationService.unmuteById(8, "admin", "123"); assertThat(result.isSuccess()).isTrue(); + verify(network).post(argThat(event -> + event instanceof ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); + verify(network).post(argThat(event -> + 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"); } @@ -348,7 +455,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 ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationPardonCommandV1)); } @Test @@ -385,9 +493,23 @@ 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 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 ModerationAuditAppendedV1 auditEvent + && ModerationMessages.ModerationAuditAppendedV1.MESSAGE_TYPE.equals(ModerationAuditAppendedV1.MESSAGE_TYPE))); + order.verify(network).post(argThat(event -> + 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 @@ -419,9 +541,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 ModerationBanCreatedV1)); + verify(network, never()).post(argThat(event -> event instanceof ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationKickBannedCommandV1)); } @Test @@ -452,7 +574,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 ModerationAuditAppendedV1)); + verify(network).post(argThat(event -> event instanceof ModerationPardonCommandV1)); } @Test @@ -500,6 +623,30 @@ void findPlayerData_whenFindReturnsNull_returnsNull() { verify(find).playerData("unknown"); } + 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(uuid) + .nameSnapshot(nameSnapshot) + .ipSnapshot(ipSnapshot) + .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 +655,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 +669,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/DiscordProtocolMapperTest.java b/src/test/java/org/xcore/plugin/service/network/DiscordProtocolMapperTest.java new file mode 100644 index 00000000..8732c6d6 --- /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/RedisNetworkBackendIntegrationTest.java b/src/test/java/org/xcore/plugin/service/network/RedisNetworkBackendIntegrationTest.java index 26e20853..dd61412b 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; @@ -9,12 +10,27 @@ 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; +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; -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; import java.time.Instant; @@ -45,6 +61,29 @@ void tearDown() { if (requesterBackend != null) { requesterBackend.disconnect(); } + flushRedis(); + } + + @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 @@ -54,7 +93,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); @@ -67,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"); } } @@ -84,25 +130,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(); @@ -128,25 +174,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(); @@ -160,7 +206,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); @@ -168,7 +214,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.toBanCreated( + banData, + "alpha", + Instant.parse("2026-04-26T00:00:00Z") + )); assertThat(requesterBackend.metricsSnapshot().getOrDefault("publish_failures", 0L)).isEqualTo(0L); @@ -180,31 +230,114 @@ void sendSerializesBanDataInstant() { assertThat(messages).isNotEmpty(); var last = messages.get(messages.size() - 1).getBody(); - assertThat(last.get("event_type")).isEqualTo("moderation.ban"); - assertThat(last.get("payload_json")).contains("expireDate"); + @SuppressWarnings("unchecked") + 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") + .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.toMuteCreated( + 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 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") + .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( + 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.toBanCreated( + banData, + "alpha", + Instant.parse("2026-04-26T00:00:00Z") + )); + + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(received.get()).isNotNull(); + assertThat(ModerationBanCreatedV1.MESSAGE_TYPE).isEqualTo(ModerationMessages.ModerationBanCreatedV1.MESSAGE_TYPE); + assertThat(ModerationMessages.ModerationBanCreatedV1.MESSAGE_VERSION).isEqualTo(1); + assertThat(received.get().target().playerUuid()).isEqualTo("u-1"); + assertThat(received.get().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.toVoteKickCreated( "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); @@ -217,11 +350,14 @@ 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("reason", "griefing") + .containsEntry("server", "alpha") + .containsEntry("occurredAt", "2026-04-26T00:00:00Z") + .containsKeys("target", "actor", "votesFor", "votesAgainst"); } } @@ -234,14 +370,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(); @@ -252,7 +388,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"); @@ -260,21 +396,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( + ModerationKickBannedCommandV1.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.toKickBannedCommand( + "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().target().playerUuid()).isEqualTo("uuid-a"); + assertThat(received.get().target().ip()).isEqualTo("1.2.3.4"); + assertThat(received.get().server()).isEqualTo("alpha"); subscription.unsubscribe(); } @@ -291,8 +436,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( @@ -303,9 +448,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); @@ -314,10 +459,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); @@ -337,8 +482,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, @@ -349,15 +494,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); @@ -366,7 +511,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(); @@ -382,15 +527,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"), @@ -421,8 +566,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(); @@ -435,14 +580,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); @@ -467,12 +615,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); @@ -515,7 +663,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); @@ -536,7 +684,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 @@ -576,6 +724,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; @@ -585,32 +741,23 @@ 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) { - 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 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) { - 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 TransportEvents.MapEntry mapEntry( + private static MapEntryV1 mapEntry( String name, String fileName, String author, @@ -621,7 +768,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, @@ -635,19 +782,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 560bcce3..fe601651 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisRouteRegistryTest.java @@ -2,60 +2,106 @@ 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 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; +import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordLinkConfirmCommandV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; +import org.xcore.protocol.generated.messages.moderation.ModerationMessages.ModerationBanCreatedV1; +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("payload server resolver uses typed server contract") - void payloadServerResolverUsesTypedContract() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(TransportEvents.DiscordLinkConfirmEvent.class); - - String stream = registry.resolveStreamKey( - descriptor, - new TransportEvents.DiscordLinkConfirmEvent("code", "uuid", 1, "discord", "user", "survival", 123L), - "mini-pvp" - ); + @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("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("default server resolver keeps server-local mutating events on current server") - void defaultServerResolverUsesDefaultServer() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(TransportEvents.PlayerPasswordReset.class); - - String stream = registry.resolveStreamKey( - descriptor, - new TransportEvents.PlayerPasswordReset("uuid-1"), - "mini-pvp" + @DisplayName("moderation ban resolves to moderation stream descriptor") + void testModerationBanRoutesToModerationStream() { + var descriptor = registry.routeDescriptorFor(ModerationBanCreatedV1.class); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:evt:moderation:ban"); + assertThat(descriptor.isReadOnly()).isTrue(); + assertThat(descriptor.isMutating()).isFalse(); + } + + @Test + @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:player-password-reset:mini-pvp"); + 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("rpc route descriptor carries response type metadata") - void rpcRouteDescriptorCarriesResponseType() { - RedisRouteDescriptor descriptor = registry.routeDescriptorFor(TransportEvents.MapsListRequest.class); + @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(descriptor).isNotNull(); - assertThat(descriptor.isRpcRequest()).isTrue(); - assertThat(descriptor.responseType()).isEqualTo(TransportEvents.MapsListResponse.class); - assertThat(registry.rpcTypeForRequestClass(TransportEvents.MapsListRequest.class)).isEqualTo("maps.list"); + assertThat(descriptor.streamPattern()).isEqualTo("xcore:cmd:maps-load:{server}"); + assertThat(registry.resolveStreamKey(descriptor, payload, "mini-pvp")) + .isEqualTo("xcore:cmd:maps-load:survival"); } @Test - @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.isMutatingType(TransportEvents.ExecuteCommand.class)).isTrue(); - assertThat(registry.isMutatingType(TransportEvents.GlobalChatEvent.class)).isFalse(); + @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("typed payload server routing remains registered") + void typedPayloadServerRoutingRemainsRegistered() { + var descriptor = registry.routeDescriptorFor(ChatDiscordIngressCommandV1.class); + + assertThat(descriptor).isNotNull(); + assertThat(registry.resolveStreamKey(descriptor, new ChatDiscordIngressCommandV1("bot", "hello", "survival"), "mini-pvp")) + .isEqualTo("xcore:cmd:discord-message:survival"); } } 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 09b98214..7e2f9d10 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,43 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.event.TransportEvents; -import org.xcore.plugin.event.TransportEvents.VoteKickEvent; -import org.xcore.plugin.event.TransportEvents.VoteKickParticipant; +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.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; +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; +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.ActorRefV1ActorType; +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.model.BanData; import org.xcore.plugin.model.MuteData; import org.xcore.plugin.model.Punishment; @@ -13,42 +47,82 @@ 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() { 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 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 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 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", 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, + "mini-pvp", + Instant.parse("2026-04-26T00:00:00Z") + ), + "mini-pvp" + ); + var muteRoute = router.route( + org.xcore.plugin.service.network.ModerationProtocolMapper.toMuteCreated( + 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.toVoteKickCreated( "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 ModerationAuditAppendedV1( + ModerationAuditAppendedV1EntryType.BAN, + new ModerationTargetRefV1("uuid-target", 42, "target", null), + new ActorRefV1("Admin", "admin-1", ActorRefV1ActorType.DISCORD), + "reason", + "mini-pvp", + Instant.parse("2026-04-26T00:00:03Z").toString(), + java.util.Map.of("durationMs", 60000L) + ), + "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" ); @@ -56,121 +130,245 @@ 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(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"); 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"); + + assertThat(discordLinkCodeRoute.streamKey()).isEqualTo("xcore:evt:discord:link-code"); + assertThat(discordLinkCodeRoute.eventType()).isEqualTo("discord.link-code-created"); } @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 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"); - 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 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 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 PlayerPasswordResetCommandV1("uuid-7", "mini-pvp"), "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"), + DiscordLinkStatusChangedV1Action.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, + new ActorRefV1("DISCORD_ROLE", null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), + "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"), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), + "mini-hexed", + Instant.parse("2026-04-26T00:00:13Z").toString() + ), + "mini-pvp" + ); 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(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(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"); + 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, + new ActorRefV1("NONE", null, ActorRefV1ActorType.SYSTEM), + new ActorRefV1("tester", null, ActorRefV1ActorType.SYSTEM), + "sync", + "survival", + Instant.parse("2026-04-26T00:00:12Z").toString() + ), + "mini-pvp" + ); assertThat(discordAdminAccessOtherServerRoute.streamKey()).isEqualTo("xcore:cmd:discord-admin-access:survival"); } @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(ChatPrivateV1.class, "mini-pvp")) + .containsExactly("xcore:evt:chat:private"); + + assertThat(router.subscribeStreamsFor(ChatGlobalV1.class, "mini-pvp")) .containsExactly("xcore:evt:chat:global"); - assertThat(router.subscribeStreamsFor(TransportEvents.MapsListRequest.class, "mini-pvp")) + 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"); + + 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")) - .containsExactly("xcore:cmd:player-password-reset:mini-pvp"); + assertThat(router.subscribeStreamsFor(MapsLoadCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:maps-load:mini-pvp"); + + assertThat(router.subscribeStreamsFor(PlayerCustomNicknameChangedCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:player-custom-nickname:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.PlayerBadgeSymbolColorModeChanged.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(PlayerActiveBadgeChangedCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:player-active-badge:mini-pvp"); + + assertThat(router.subscribeStreamsFor(PlayerBadgeSymbolColorModeChangedCommandV1.class, "mini-pvp")) .containsExactly("xcore:cmd:player-badge-symbol-color-mode:mini-pvp"); - assertThat(router.subscribeStreamsFor(TransportEvents.DiscordLinkConfirmEvent.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")) .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(BanData.class, "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"); - assertThat(router.subscribeStreamsFor(MuteData.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationMuteCreatedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:mute"); - assertThat(router.subscribeStreamsFor(VoteKickEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationVoteKickCreatedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:votekick"); - assertThat(router.subscribeStreamsFor(TransportEvents.ModerationAuditAppendedEvent.class, "mini-pvp")) + assertThat(router.subscribeStreamsFor(ModerationAuditAppendedV1.class, "mini-pvp")) .containsExactly("xcore:evt:moderation:audit"); + + assertThat(router.subscribeStreamsFor(ModerationKickBannedCommandV1.class, "mini-pvp")) + .containsExactly("xcore:cmd:kick-banned:mini-pvp"); + + assertThat(router.subscribeStreamsFor(ModerationPardonCommandV1.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.DiscordAdminAccessChanged.class)).isFalse(); - - 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(TransportEvents.MessageEvent.class)).isFalse(); - - assertThat(router.isRpcRequestType(TransportEvents.MapsListRequest.class)).isTrue(); - assertThat(router.isRpcRequestType(TransportEvents.MessageEvent.class)).isFalse(); - - assertThat(router.responseTypeForRequest(TransportEvents.MapsListRequest.class)) - .isEqualTo(TransportEvents.MapsListResponse.class); - assertThat(router.responseTypeForRequest(TransportEvents.MapRemoveRequest.class)) - .isEqualTo(TransportEvents.MapRemoveResponse.class); - - assertThat(router.rpcTypeForRequestClass(TransportEvents.MapsListRequest.class)) - .isEqualTo("maps.list"); - assertThat(router.rpcTypeForRequestClass(TransportEvents.MapRemoveRequest.class)) - .isEqualTo("maps.remove"); + 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(); + assertThat(router.isReadOnlyType(ModerationMuteCreatedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ModerationVoteKickCreatedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ModerationAuditAppendedV1.class)).isTrue(); + assertThat(router.isReadOnlyType(DiscordAdminAccessChangedCommandV1.class)).isFalse(); + assertThat(router.isMutatingType(ModerationKickBannedCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(ModerationPardonCommandV1.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(); + assertThat(router.isMutatingType(MapsLoadCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(DiscordLinkConfirmCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(DiscordUnlinkCommandV1.class)).isTrue(); + assertThat(router.isMutatingType(DiscordAdminAccessChangedCommandV1.class)).isTrue(); + assertThat(router.isReadOnlyType(ChatGlobalV1.class)).isTrue(); + assertThat(router.isMutatingType(ChatMessageV1.class)).isFalse(); + + assertThat(router.isRpcRequestType(MapsListRequestV1.class)).isTrue(); + assertThat(router.isRpcRequestType(ChatMessageV1.class)).isFalse(); + + assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) + .isEqualTo(MapsListResponseV1.class); + assertThat(router.responseTypeForRequest(MapsRemoveRequestV1.class)) + .isEqualTo(MapsRemoveResponseV1.class); + + assertThat(router.rpcTypeForRequestClass(MapsListRequestV1.class)) + .isEqualTo("maps.list.request"); + 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 ed83b336..96c99ebc 100644 --- a/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java +++ b/src/test/java/org/xcore/plugin/service/network/RedisTransportContractsTest.java @@ -2,8 +2,30 @@ 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 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.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; +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.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; import java.util.List; @@ -137,18 +159,50 @@ 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 moderationRoute = RedisTransportTopology.routeFor(BanData.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 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 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); + 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(PlayerPasswordResetCommandV1.class); + RedisTransportTopology.RouteSpec mapsLoadCommandRoute = RedisTransportTopology.routeFor(MapsLoadCommandV1.class); + RedisTransportTopology.RouteSpec discordAdminCommandRoute = RedisTransportTopology.routeFor(DiscordAdminAccessChangedCommandV1.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); // Act RedisTransportTopology.RouteSpec stableEventRoute = eventRoute; + 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; + 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; RedisTransportTopology.RouteSpec stableBroadcastCommandRoute = broadcastCommandRoute; RedisTransportTopology.RouteSpec stableRpcRoute = rpcRoute; + RedisTransportTopology.RouteSpec stableRemoveRpcRoute = removeRpcRoute; + RedisTransportTopology.RouteSpec stableKickBannedRoute = kickBannedRoute; // Assert assertThat(stableEventRoute).isNotNull(); @@ -159,56 +213,193 @@ 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(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(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(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"); + 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"); + 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(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(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"); + 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(); + 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"); + 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); 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"); + 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); + + 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 @DisplayName("registry and router remain aligned with explicit transport topology") void registryAndRouterRemainAlignedWithExplicitTransportTopology() { // Arrange - RedisTransportTopology.RouteSpec commandSpec = RedisTransportTopology.routeFor(TransportEvents.PlayerPasswordReset.class); - RedisTransportTopology.RouteSpec rpcSpec = RedisTransportTopology.routeFor(TransportEvents.MapsListRequest.class); - RedisRouteDescriptor commandDescriptor = registry.routeDescriptorFor(TransportEvents.PlayerPasswordReset.class); - RedisRouteDescriptor rpcDescriptor = registry.routeDescriptorFor(TransportEvents.MapsListRequest.class); + RedisTransportTopology.RouteSpec playerSessionSpec = RedisTransportTopology.routeFor(PlayerCustomNicknameChangedCommandV1.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(PlayerPasswordResetCommandV1.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"); + var playerSessionRoute = router.route(new PlayerCustomNicknameChangedCommandV1("uuid-7", "Commander", "survival"), "mini-pvp"); + var commandRoute = router.route(new PlayerPasswordResetCommandV1("uuid-7", "survival"), "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()); 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(); @@ -216,10 +407,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(TransportEvents.MessageEvent.class)).isNull(); + assertThat(router.responseTypeForRequest(MapsListRequestV1.class)) + .isEqualTo(MapsListResponseV1.class); + assertThat(router.responseTypeForRequest(ChatMessageV1.class)).isNull(); } }