diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index 034aa446..b5f9940b 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -9,6 +9,7 @@ on: push: branches: - main + - api-redesign jobs: lint: diff --git a/.github/workflows/ci-tests-and-coverage.yml b/.github/workflows/ci-tests-and-coverage.yml index 7b71aadd..00425f21 100644 --- a/.github/workflows/ci-tests-and-coverage.yml +++ b/.github/workflows/ci-tests-and-coverage.yml @@ -9,6 +9,7 @@ on: push: branches: - main + - api-redesign jobs: tests: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0cbe2a28..439722fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ mvn -B -DskipTests checkstyle:check - Use a clear title and summary. - Explain what changed and why. -- Call out configuration or migration impact. +- Call out configuration and operator impact. - Link related issues where relevant. - Keep commits readable and review-friendly. diff --git a/README.md b/README.md index 361af19f..f4194514 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![CI Lint](https://github.com/HauntedMC/ProxyFeatures/actions/workflows/ci-lint.yml/badge.svg?branch=main)](https://github.com/HauntedMC/ProxyFeatures/actions/workflows/ci-lint.yml) [![CI Tests and Coverage](https://github.com/HauntedMC/ProxyFeatures/actions/workflows/ci-tests-and-coverage.yml/badge.svg?branch=main)](https://github.com/HauntedMC/ProxyFeatures/actions/workflows/ci-tests-and-coverage.yml) -[![Latest Release](https://img.shields.io/github/v/release/HauntedMC/ProxyFeatures?sort=semver)](https://github.com/HauntedMC/ProxyFeatures/releases/latest) +[![Release](https://img.shields.io/github/v/release/HauntedMC/ProxyFeatures?sort=semver)](https://github.com/HauntedMC/ProxyFeatures/releases/latest) [![Java 25](https://img.shields.io/badge/Java-25-007396?logo=openjdk&logoColor=white)](https://adoptium.net/) [![License](https://img.shields.io/github/license/HauntedMC/ProxyFeatures)](LICENSE) -A modular feature framework and reusable API for your entire Velocity network. +A modular Velocity feature runtime with a reload-safe, dependency-free integration API. ## Quick Start @@ -58,9 +58,10 @@ public runtime integration and clean shutdown. Set `PLATFORM_ACCEPTANCE_KEEP_WOR ## Published Modules -- `proxyfeatures-api`: reusable commands, configuration, cache, localization, packet, and text contracts. -- `proxyfeatures-contracts`: small cross-platform persistence and wire-message contracts shared with ServerFeatures. -- `proxyfeatures`: the installable Velocity plugin; its jar keeps the historical `ProxyFeatures.jar` name. +- `proxyfeatures-api`: dependency-free root API, capability contracts, immutable DTOs, feature catalog, and extensions. +- `proxyfeatures-toolkit`: shared runtime implementation support for config, cache, localization, HTTP, and text. +- `proxyfeatures-contracts`: seven cross-platform wire-message contracts shared with ServerFeatures. +- `proxyfeatures`: the installable Velocity plugin, packaged as `ProxyFeatures.jar`. The testkit is reactor-internal and is not part of the supported production API. Maven consumers should depend on the smallest public artifact they need, with `provided` scope when the Velocity plugin supplies it at runtime. @@ -69,7 +70,7 @@ smallest public artifact they need, with `provided` scope when the Velocity plug nl.hauntedmc.proxyfeatures proxyfeatures-api - 3.0.0 + 3.3.0 provided ``` @@ -77,7 +78,8 @@ smallest public artifact they need, with `provided` scope when the Velocity plug ## Repository Layout - `proxyfeatures-api`: public, reusable integration surface. -- `proxyfeatures-contracts`: shared sanction, player persistence, and cross-platform messaging model. +- `proxyfeatures-toolkit`: reusable implementation support used by the Velocity runtime. +- `proxyfeatures-contracts`: cross-platform wire messages only. - `proxyfeatures-testkit`: common test infrastructure. - `proxyfeatures-platform-velocity`: feature framework, feature implementations, and distributable jar. - `proxyfeatures-platform-acceptance`: API-only consumer and real Velocity boot gate. @@ -87,10 +89,12 @@ smallest public artifact they need, with `provided` scope when the Velocity plug - [Configuration Guide](docs/CONFIGURATION.md) - [Documentation Index](docs/README.md) - [Architecture](docs/ARCHITECTURE.md) +- [Public API](docs/API.md) +- [Toolkit](docs/TOOLKIT.md) +- [Shared Contracts](docs/CONTRACTS.md) - [Development Notes](docs/DEVELOPMENT.md) - [Testing and Quality](docs/TESTING.md) - [Release Process](docs/RELEASE.md) -- [Migrating to 3.0](docs/MIGRATING-3.0.md) - [Contributing](CONTRIBUTING.md) ## Community diff --git a/SECURITY.md b/SECURITY.md index 6a8d0f9b..515bfa5c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,8 @@ # Security Policy -## Supported Versions +## Security Fixes -Security fixes are prioritized for the latest stable release line. -Older versions may receive fixes on a best-effort basis. +Security reports are assessed, prioritized by severity, and handled through the private reporting process below. ## Reporting a Vulnerability @@ -17,7 +16,7 @@ Use one of the following private channels: Include: -- Affected version(s) +- Affected deployment details - Reproduction steps / proof of concept - Impact assessment - Any proposed mitigation diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..cc508868 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,109 @@ +# ProxyFeatures Public API + +The API is a small dependency-free artifact. It exposes runtime discovery, feature lifecycle observations, +reload-safe capabilities, immutable domain snapshots, and lifecycle-safe extension registration. It does not expose +Velocity adapters, persistence entities, configuration implementation, feature classes, or internal coordination. + +## Dependency + +```xml + + nl.hauntedmc.proxyfeatures + proxyfeatures-api + 3.3.0 + provided + +``` + +Add an optional or required Velocity plugin dependency on `proxyfeatures` according to your integration policy. + +## Discover the root API + +The Velocity plugin instance implements `ProxyFeaturesApi`: + +```java +ProxyFeaturesApi proxyFeatures = proxy.getPluginManager() + .getPlugin("proxyfeatures") + .flatMap(container -> container.getInstance()) + .filter(ProxyFeaturesApi.class::isInstance) + .map(ProxyFeaturesApi.class::cast) + .orElseThrow(() -> new IllegalStateException("ProxyFeatures API is unavailable")); +``` + +`proxyFeatures.version()` reports the API and runtime version. `features()` lists every known built-in feature and +its `DISABLED`, `STARTING`, `ACTIVE`, `STOPPING`, or `FAILED` lifecycle state. + +## Resolve capabilities safely + +Keep the reference, not the provider: + +```java +CapabilityRef presence = + proxyFeatures.capabilities().reference(PresenceApi.class); + +boolean hidden = presence.get() + .map(api -> api.isHidden(playerId)) + .orElse(false); +``` + +`CapabilityRef` is stable for the lifetime of the plugin. Its `get()` and `require()` methods resolve the currently +active provider. A feature reload can remove one implementation and install another, so callers must resolve the +reference again for each operation. `require()` throws `CapabilityUnavailableException` when no provider is active. + +## Capability inventory + +| Contract | Owner | Purpose | +|---|---|---| +| `AdmissionApi` | Capacity | Atomic backend admission decisions and expiring leases | +| `QueueApi` | Queue | Asynchronous queue join/leave, lookup, enablement, and snapshots | +| `PresenceApi` | Vanish | Authoritative online visibility state and immutable snapshots | +| `FriendshipApi` | Friends | Asynchronous friendship decisions independent of ORM entities | +| `NetworkLocationApi` | AntiVPN | Session-scoped country-code lookup without triggering remote calls | +| `PlayerLanguageApi` | PlayerLanguage | Cached preference/resolved locale and asynchronous mutation | +| `PlayerCountApi` | PlayerCount | Vanish-aware network/backend count snapshots | +| `MaintenanceApi` | Maintenance | Global/backend state, bypass decisions, and snapshots | +| `RestartApi` | Restart | Drain state and expected return checks | +| `TwoFactorApi` | TwoFactor | Authentication lock and authentication-server checks | +| `VersionApi` | VersionCheck | Minimum supported protocol and version name | +| `SanctionsApi` | Sanctions | Asynchronous persistence-independent sanction history snapshots | +| `MotdExtensions` | Core | Ordered, lifecycle-safe MOTD contribution registration | + +Features not listed as capability providers remain operator-facing or implementation-only. Their concrete classes are +not supported integration contracts. + +## Admission and queue + +`AdmissionRequest` is intentionally untrusted: callers provide player identity, previous/target server, and intent; +the runtime derives permissions, maintenance, restart, two-factor, capacity, and reservation policy. An allowed +`AdmissionDecision` always contains exactly one `AdmissionLease`. Commit it after the exact connection succeeds or +release/close it when the attempt ends. Lease operations are idempotent. + +Queue operations return `CompletionStage` and typed status values. `QueueJoinRequest` cannot supply priority, +permission, capacity, or bypass values; those remain runtime-owned. All snapshots and contained collections are +immutable point-in-time views. + +## MOTD extensions + +External plugins can register an ordered contributor through the core capability: + +```java +CapabilityRef extensions = + proxyFeatures.capabilities().reference(MotdExtensions.class); + +ExtensionRegistration registration = extensions.require().register( + "my-plugin", + 100, + context -> Optional.of(MotdContribution.secondLine("Scheduled event tonight")) +); +``` + +Close the returned registration during plugin shutdown or before replacing it. Registration close is idempotent. +Blank contributions are ignored and contributor failures are isolated by the runtime registry. + +## Threading and data rules + +- Treat DTOs as immutable snapshots; obtain a new snapshot when freshness matters. +- Do not block Velocity event threads on `CompletionStage` results. +- Do not rely on concrete provider classes or cast API contracts to runtime implementations. +- Treat absence as normal for optional features and during reload transitions. +- API events and DTOs use Java types only; platform-specific events remain in the Velocity runtime. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58ba7ad6..90be129f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,59 +1,132 @@ -# Architecture Overview +# ProxyFeatures Architecture -ProxyFeatures has two kinds of modularity: Maven modules enforce compile-time ownership, while runtime feature -classes provide independently configurable network behavior. +ProxyFeatures separates public integration contracts, reusable implementation support, wire contracts, and the +Velocity runtime. Feature implementations collaborate through typed capabilities; they do not import one another. -## Build Modules +## Module boundaries ```text -proxyfeatures-testkit ──(test only)──▶ api / contracts / velocity -proxyfeatures-api ───────────────────▶ velocity plugin -proxyfeatures-contracts ─────────────▶ velocity plugin and ServerFeatures -velocity plugin ─────────────────────▶ packaged ProxyFeatures.jar +proxyfeatures-api ───────────────────────────────▶ external plugins + │ + └───────────────────────────────────────▶ Velocity runtime +proxyfeatures-toolkit ──────────────────────────▶ Velocity runtime +proxyfeatures-contracts ─▶ Velocity runtime / ServerFeatures +proxyfeatures-testkit ────(test scope only)─────▶ project tests +Velocity runtime + the three production modules ▶ ProxyFeatures.jar ``` -- `proxyfeatures-api` contains public reusable framework contracts. It knows about Velocity APIs but not the - ProxyFeatures plugin bootstrap or feature implementations. -- `proxyfeatures-contracts` owns the deliberately small persistence and cross-platform messaging schemas shared with - ServerFeatures. ServerFeatures therefore does not depend on the full proxy plugin, and both runtimes serialize the - same wire types. -- `proxyfeatures-testkit` contains test infrastructure and is never a production dependency. -- `proxyfeatures-platform-velocity` owns bootstrapping, lifecycle implementation, and concrete network features. -- `proxyfeatures-platform-acceptance` is activated by a Maven profile. Its API-only consumer proves the API is - available from the packaged plugin, and its final module boots a pinned Velocity runtime. +- `proxyfeatures-api` is dependency-free Java. It owns the root API, feature catalog, capability contracts, immutable + DTOs, identifiers, lifecycle-safe references, and public extension points. +- `proxyfeatures-toolkit` owns reusable config, cache, localization, formatting, parsing, and HTTP implementation + support. It is an implementation library, not a plugin integration API. +- `proxyfeatures-contracts` owns only seven cross-process wire messages. Persistence entities and runtime services do + not belong here. +- `proxyfeatures-platform-velocity` owns the plugin bootstrap, Velocity adapters, persistence, lifecycle framework, + internal collaboration ports, and all concrete features. +- `proxyfeatures-platform-acceptance` is an optional real-runtime gate whose consumer compiles against only public + APIs and obtains `ProxyFeaturesApi` from the Velocity plugin instance. -Dependencies point toward stable contracts. Public modules must not import plugin bootstrap, framework-internal, or -feature-implementation packages. +Dependencies point inward toward stable contracts. The API never imports Velocity, DataRegistry, DataProvider, +toolkit, contracts, runtime framework, or feature implementation packages. -## Design Goals +## Runtime ownership -- Keep features isolated so one module can be changed without destabilizing others. -- Centralize common lifecycle concerns such as command/listener/task registration. -- Let operators adopt features gradually, not all at once. +The `ProxyFeatures` plugin instance is the single authoritative `ProxyFeaturesApi` implementation and owns three +long-lived registries: -## Runtime Model +1. `DefaultCapabilityRegistry` publishes public API interfaces. +2. `DefaultFeatureCatalog` projects the explicit built-in manifest and lifecycle state. +3. `InternalServiceRegistry` connects implementation-only ports such as queue/admission coordination. -At startup, the plugin loads shared configuration, discovers available features, validates dependencies, and starts only the features that are enabled. +A feature registers services through its `FeatureApiManager`. Every registration is tagged with the owning feature +and gets an idempotent removal handle. Cleanup removes all services before the feature object is discarded. A +`CapabilityRef` remains stable across disable, enable, and reload, but resolves the current provider for each +operation. Consumers must not retain a resolved implementation across reloads. -During runtime, each feature owns its own behavior while using shared framework services for common tasks (config access, lifecycle management, logging, and integration points). +Feature lifecycle is externally observable as: -On reload/shutdown, features are asked to clean up resources so stale listeners, tasks, and cached state do not leak into the next run. - -The plugin entry point implements `ProxyFeaturesContext`, which gives reusable configuration/resource services the -minimal host capabilities they need without coupling the API module back to the concrete plugin. - -## Configuration and Data +```text +DISABLED → STARTING → ACTIVE → STOPPING → DISABLED + └──────────────────────→ FAILED +``` -- `config.yml` stores shared/global settings only. -- Each feature owns `features//config.yml`. +Startup includes context creation, defaults, configuration/localization reload, feature initialization, and optional +state restoration. Failure in any of those stages moves the catalog to `FAILED` and triggers cleanup. + +## Feature discovery and isolation + +`BuiltInFeatures` explicitly lists all 33 shipped features. Runtime classpath scanning is not used. The manifest is +the source of truth for feature identity, implementation constructor, startup phase, dependency declarations, +classification, reload/failure policy, and published capability names. Each feature owns only its configuration and +localization defaults plus runtime behavior. + +Production code inside one feature package may not import another feature package. Cross-feature behavior uses: + +- a public capability when external plugins can reasonably use the contract; +- a runtime-only collaboration port when the interaction is an implementation detail; +- a shared framework or toolkit utility when the behavior is genuinely generic; +- a wire contract when the boundary crosses processes. + +Automated architecture tests enforce these rules, the dependency-free API, the wire-only contracts module, the +explicit 33-feature manifest, and the declared discovery/service boundaries. + +## Feature inventory + +“Consumes” lists public capabilities. Internal ports are deliberately unavailable to external plugins. + +| Feature | Role | Provides | Consumes / internal collaboration | +|---|---|---|---| +| Announcer | Internal | — | shared lifecycle/toolkit | +| AntiBot | Internal | — | shared `IpAddressUtil` | +| AntiVPN | Provider | `NetworkLocationApi` | shared `IpAddressUtil` | +| Broadcast | Internal | — | — | +| Capacity | Provider | `AdmissionApi` | `MaintenanceApi`, `RestartApi`, `TwoFactorApi`; internal `QueueAdmissionPort` | +| ClientInfo | Internal | — | — | +| CommandHider | Internal | — | — | +| CommandLogger | Internal | — | — | +| CommandRelay | Internal | — | wire `CommandRelayMessage` | +| ConnectionInfo | Internal | — | — | +| Friends | Provider | `FriendshipApi` | `PresenceApi` | +| HLink | Internal | — | — | +| Hub | Internal | — | — | +| Maintenance | Provider | `MaintenanceApi` | core `MotdExtensions` | +| Messenger | Consumer | — | `FriendshipApi`, `PresenceApi` | +| Motd | Consumer | — | `PresenceApi`, `VersionApi`, core `MotdExtensions` | +| PlayerCount | Provider | `PlayerCountApi` | `PresenceApi`; wire `PlayerCountSnapshotMessage` | +| PlayerInfo | Consumer | — | `PlayerLanguageApi`, `SanctionsApi` | +| PlayerLanguage | Provider | `PlayerLanguageApi` | `NetworkLocationApi` | +| PlayerList | Consumer | — | `PresenceApi` | +| ProxyInfo | Internal | — | — | +| Queue | Provider | `QueueApi` | required `AdmissionApi` | +| ResourcePack | Internal | — | — | +| Restart | Provider | `RestartApi` | internal capacity coordination; wire `RestartLifecycleMessage` | +| Sanctions | Provider | `SanctionsApi` | — | +| ServerLinks | Internal | — | — | +| SlashServer | Internal | — | — | +| StaffChat | Internal | — | wire `StaffChatMessage` | +| TextCommands | Internal | — | — | +| TwoFactor | Provider | `TwoFactorApi` | — | +| Vanish | Provider | `PresenceApi` | wire `VanishStateMessage` | +| VersionCheck | Provider | `VersionApi` | — | +| Votifier | Internal | — | wire `VoteMessage` | + +## Shared implementation services + +Generic implementation code lives outside feature packages: + +- one cached `PlayerReferenceResolver` per plugin runtime serves persistence-aware features; +- `HttpTransport` is the shared HTTP transport, including Discord webhook JSON posts; +- `IpAddressUtil` is shared by AntiBot and AntiVPN; +- `DefaultMotdExtensions` owns ordered, lifecycle-safe MOTD contributions; +- toolkit config/cache writes use centralized path handling, validation, and atomic file replacement. + +## Configuration and data + +- `config.yml` stores shared feature enablement and global settings. +- Each feature owns `features//config.yml` and localized message files. - Framework messages live in `lang/messages*.yml`. -- Each feature owns `features//messages*.yml`. -- Some features may use additional local files for structured data. -- Shared database entities and Redis wire messages live in `proxyfeatures-contracts`; publishing and subscription - behavior remains platform-owned. - -## Why This Matters - -For operators, this architecture means safer rollout and easier troubleshooting. +- Persistence entities stay with their owning runtime feature or framework adapter. +- Redis messages stay in `proxyfeatures-contracts`; publishers/subscribers remain runtime-owned. -For contributors, it means clearer boundaries: implement behavior inside a feature, keep shared behavior in the framework, and avoid tight coupling between unrelated modules. +See [Public API](API.md) for plugin integration, [Toolkit](TOOLKIT.md) for reusable implementation support, and +[Shared Contracts](CONTRACTS.md) for cross-process message models. diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md new file mode 100644 index 00000000..cea7a52a --- /dev/null +++ b/docs/CONTRACTS.md @@ -0,0 +1,66 @@ +# ProxyFeatures Shared Contracts + +`proxyfeatures-contracts` contains the Java message types exchanged between ProxyFeatures and other processes, +including ServerFeatures. It gives publishers and consumers one shared payload shape and message type identifier +without placing Velocity runtime services, feature implementations, or persistence entities in a cross-process +module. + +The module contains message models only. DataProvider transports and serializes these messages; the owning feature +controls publication, subscription, acknowledgement, retries, deduplication, ordering, and failure handling. + +## Why this module exists + +Cross-process code needs the same message fields and identifiers at compile time. Keeping those types in a dedicated +module prevents the proxy runtime and backend consumers from depending on each other's implementations. + +The module extends DataProvider's `AbstractEventMessage`. Each message defines a stable `TYPE` constant that selects +the message kind on the configured transport. + +## Message inventory + +| Contract | Type | Purpose | +|---|---|---| +| `CapacitySnapshotMessage` | `capacity_snapshot` | Complete authoritative Capacity snapshot for backend presentation. | +| `CommandRelayMessage` | `commandrelay` | One allowlisted command-execution request with origin and operation ID. | +| `PlayerCountSnapshotMessage` | `playercount_snapshot` | Complete vanish-aware network and backend player-count snapshot. | +| `RestartLifecycleMessage` | `server_restart_lifecycle` | Backend restart actions: `PREPARE`, `READY`, and `CANCEL`. | +| `StaffChatMessage` | `staffchat` | Staff-channel prefix, message body, sender name, and sender server. | +| `VanishStateMessage` | `vanish_update` | Player visibility state, source server, and monotonic state revision. | +| `VoteMessage` | `votifier` | Vote service, player, address, and timestamp. | + +## Data and validation rules + +Message constructors enforce the invariants that are known when a publisher creates a payload. For example, +capacity and player-count snapshots require nonblank publisher identity, a positive sequence and timestamp, valid +nonnegative counts, and normalized unique backend or scope names. Restart and command-relay messages validate their +operation identifiers; restart actions and timing fields are also constrained. + +Messages are deserialized through no-argument constructors. Consumers must validate received fields before acting on +them, because deserialization can produce incomplete or malformed payloads that bypass constructor checks. + +Snapshots use publisher ID, publisher epoch, sequence, and publication time so consumers can fence stale state. +`VanishStateMessage` provides a per-player state revision, while `CommandRelayMessage` and +`RestartLifecycleMessage` provide operation IDs for idempotent processing by their owners. + +## Delivery boundaries + +The contracts do not guarantee that a message is delivered, processed once, processed in order, or acknowledged by a +downstream consumer. Those properties belong to the configured DataProvider topology and to the feature that owns the +message flow. + +Consumers must treat every payload as untrusted transport input. Validate identity and field constraints, apply the +feature's authorization rules, and implement idempotency where duplicate delivery is possible. + +## Where to find behavior documentation + +Each owning feature documents its producer and consumer behavior: + +- [Capacity snapshot feed](features/capacity-placeholders.md) +- [Command relay](features/commandrelay.md) +- [Player count](features/playercount.md) +- [Backend restart autoreconnect](features/restart.md) +- [Staff chat](features/staffchat.md) +- [Vanish](features/vanish.md) +- [Votifier reliable delivery](features/votifier-reliable-delivery.md) + +See [Architecture](ARCHITECTURE.md) for module boundaries and [Toolkit](TOOLKIT.md) for reusable runtime support. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fa14263b..38fab748 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -30,7 +30,7 @@ Target one module and its reactor dependencies during a tight feedback loop: 2. Implement the change with tests in the same pass. 3. Run local validation (`test` at minimum). 4. Update docs when behavior or operator workflow changes. -5. Open a PR with context, impact, and any migration notes. +5. Open a PR with context and operator impact. ## Engineering Guidelines @@ -39,7 +39,8 @@ Target one module and its reactor dependencies during a tight feedback loop: - Make external calls fail-safe and time-bounded. - Clean up tasks/listeners/resources during disable and reload paths. - Favor simple, explicit code over clever abstractions. -- Keep public code in `proxyfeatures-api`, cross-project persistence types in `proxyfeatures-contracts`, and +- Keep public contracts in `proxyfeatures-api`, reusable runtime support in `proxyfeatures-toolkit`, wire messages in + `proxyfeatures-contracts`, persistence types with their runtime owner, and Velocity implementation details in `proxyfeatures-platform-velocity`. - Do not make public modules depend on the plugin module. Introduce a narrow public interface when reusable code needs a host capability. @@ -51,4 +52,4 @@ Target one module and its reactor dependencies during a tight feedback loop: - New behavior is covered by tests. - Operationally important failures are logged clearly. - `./mvnw -B -ntp verify` passes from a clean checkout. -- Public API changes include compatibility and migration notes. +- Public API changes include clear contract and operator notes. diff --git a/docs/MIGRATING-3.0.md b/docs/MIGRATING-3.0.md deleted file mode 100644 index 0dc13e90..00000000 --- a/docs/MIGRATING-3.0.md +++ /dev/null @@ -1,46 +0,0 @@ -# Migrating to ProxyFeatures 3.0 - -Version 3.0 separates reusable contracts from the Velocity runtime. The installed plugin and its runtime identity are -unchanged, but source consumers should select an explicit public module. - -## Server operators - -- Replace the existing plugin with `ProxyFeatures.jar`; its filename, plugin id, and configuration locations are - unchanged. -- Use Java 25 and the Velocity version listed in the project README. -- Back up configuration and data, then exercise the acceptance checks relevant to your network before rollout. - -## Maven consumers - -The historical runtime coordinate remains available: - -```text -nl.hauntedmc.proxyfeatures:proxyfeatures:3.0.0 -``` - -New code should depend with `provided` scope on the smallest supported surface: - -- `nl.hauntedmc.proxyfeatures:proxyfeatures-api:3.0.0` for the reusable Velocity-facing API. -- `nl.hauntedmc.proxyfeatures:proxyfeatures-contracts:3.0.0` for player/sanction persistence and shared wire-message - contracts. - -Do not depend on `proxyfeatures-platform-velocity` as an artifact id; that is the repository module directory, while -the published runtime artifact deliberately retains the `proxyfeatures` artifact id. - -## Source compatibility - -- Reusable configuration and resource services now accept `ProxyFeaturesContext`, not the concrete plugin bootstrap. - Implement that narrow interface in tests or embedding hosts. -- `SimpleHttpClient` now uses its own `FormParameter` record and the JDK HTTP client. Replace Apache - `NameValuePair` arguments with `new SimpleHttpClient.FormParameter(name, value)` and handle - `InterruptedException` without clearing the thread's interruption signal. -- Persistence types shared with ServerFeatures now come from `proxyfeatures-contracts`. -- Command relay, staff chat, vanish state, and vote wire types now live under - `nl.hauntedmc.proxyfeatures.contracts.messaging`. Their message type is fixed by each class; callers no longer pass - a free-form type to `StaffChatMessage` or `VanishStateMessage`. -- Repository source paths moved from the root `src/` tree into `proxyfeatures-api`, `proxyfeatures-contracts`, and - `proxyfeatures-platform-velocity`. -- Build output moved from `target/ProxyFeatures.jar` to - `proxyfeatures-platform-velocity/target/ProxyFeatures.jar`. - -Recompile integrations against 3.0; do not assume binaries compiled against 2.x remain compatible. diff --git a/docs/README.md b/docs/README.md index affd5390..d0d070d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,14 +8,16 @@ If you run the plugin: - [Feature reference](features/README.md): commands, permissions, configuration, integrations, runtime behavior, and troubleshooting for every Velocity feature. - [Configuration](CONFIGURATION.md): day-to-day setup and safe change workflow. -- [Migrating to 3.0](MIGRATING-3.0.md): operator and dependency changes from the 2.x line. +- [Public API](API.md): capability discovery, reload-safe usage, contracts, and extension registration. +- [Toolkit](TOOLKIT.md): reusable configuration, cache, HTTP, formatting, and localization support. +- [Shared Contracts](CONTRACTS.md): cross-process message models and their ownership boundaries. - [Architecture](ARCHITECTURE.md): how the plugin is structured and how features are managed. If you contribute code: - [Development](DEVELOPMENT.md): local setup and coding workflow. - [Testing](TESTING.md): test strategy and local validation commands. -- [Release process](RELEASE.md): versioning, verification, publication, and artifacts. +- [Release process](RELEASE.md): verification, publication, and artifacts. - [Contributing Guide](../CONTRIBUTING.md): pull request expectations. ## Documentation rule diff --git a/docs/RELEASE.md b/docs/RELEASE.md index d3390b3d..83731031 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -3,7 +3,7 @@ ## 1. Prepare - Work from a clean `main` branch and confirm CI is green. -- Review compatibility and operator-facing changes. +- Review operator-facing changes. - Run the complete local gate: ```bash @@ -36,7 +36,7 @@ git push origin vX.Y.Z 1. Rejects malformed tags and any tag that differs from the Maven project version. 2. Runs one `deploy` reactor with the `release` and `platform-acceptance` profiles. -3. Enforces Java/Maven versions, pinned plugins, dependency convergence and upper bounds, banned legacy Adventure +3. Enforces Java/Maven requirements, pinned plugins, dependency convergence and upper bounds, banned Adventure modules, direct dependency declarations, duplicate classes, Checkstyle, tests, coverage, javadocs, distribution contents, and the real Velocity boot gate. 4. Uses Maven `deployAtEnd`, so deployment does not begin until every reactor build and verification gate succeeds. @@ -52,7 +52,8 @@ The Maven Wrapper distribution and third-party GitHub Actions are checksum/SHA p - Repository: `https://maven.pkg.github.com/HauntedMC/ProxyFeatures` - Group: `nl.hauntedmc.proxyfeatures` -- Public artifacts: `proxyfeatures-api`, `proxyfeatures-contracts` +- Public integration artifact: `proxyfeatures-api`; shared runtime artifacts: `proxyfeatures-toolkit` and + `proxyfeatures-contracts` - Runtime artifact: `proxyfeatures` ServerFeatures releases that consume a new contracts version must be published after the corresponding ProxyFeatures diff --git a/docs/TESTING.md b/docs/TESTING.md index fa1333fc..7242b7e8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -46,6 +46,14 @@ When you change behavior, add or update tests close to that behavior: Focus on user-visible behavior and regression-prone logic. Avoid writing tests that only duplicate framework boilerplate. +The Velocity module also contains architecture tests. They enforce that feature packages do not import each other, +the public API remains dependency-free, shared contracts remain wire-only, the built-in catalog remains explicit and +complete, and service/discovery boundaries remain enforced. Treat those tests as executable module boundaries, not +assertions to weaken when a change crosses a boundary. + +Public API contract tests cover identifier normalization, immutable snapshots, request invariants, stable capability +references, admission leases, queue results, extension values, and the domain contracts exposed to external plugins. + ## Test Quality Bar Use these rules when adding or reviewing tests: @@ -86,5 +94,5 @@ Prioritize methods with both high `line_missed` and high `branch_total`; these a ## CI Run `./mvnw -B -ntp -Pplatform-acceptance verify` to boot the packaged plugin on a real Velocity runtime with -DataProvider 3.1.6, DataRegistry 1.13.1, and MySQL. Docker, `curl`, `jq`, `jar`, and `sha256sum` are required. +DataProvider 3.1.8, DataRegistry 1.13.4, and MySQL. Docker, `curl`, `jq`, `jar`, and `sha256sum` are required. CI validates unit tests, coverage, linting, and this Velocity acceptance gate on pull requests and main branch updates. diff --git a/docs/TOOLKIT.md b/docs/TOOLKIT.md new file mode 100644 index 00000000..a1b994e9 --- /dev/null +++ b/docs/TOOLKIT.md @@ -0,0 +1,83 @@ +# ProxyFeatures Toolkit + +`proxyfeatures-toolkit` is the shared implementation library used by the Velocity runtime. It keeps generic +infrastructure in one place so features use consistent rules for configuration, local files, caching, HTTP, text, +localization, JSON, and pagination. + +The toolkit is not the plugin integration surface. External plugins should integrate through +[Public API](API.md). Runtime features and project-owned modules use the toolkit when they need common +implementation support without creating feature-to-feature dependencies. + +## Ownership and boundaries + +The toolkit owns reusable mechanics, not feature policy. A feature decides what to configure, cache, request, or +display; the toolkit provides the common operations needed to carry out that policy. + +The toolkit does not own: + +- Velocity listeners, commands, or plugin lifecycle; +- feature enablement, permissions, or domain decisions; +- persistence entities or database access; +- Redis message contracts or message delivery; +- public capability contracts. + +The Velocity runtime supplies a `ToolkitContext` with the plugin data directory, logger, and resource class loader. +Toolkit services therefore stay independent of Velocity while using the runtime's paths, logging, and bundled +resources. + +## Configuration + +`ConfigService` opens YAML files below the supplied data directory. It creates missing parent directories, can copy a +matching bundled resource as the default file, caches one `YamlFile` per normalized absolute path, and rejects paths +that escape the data directory. + +`YamlFile` and `ConfigView` provide typed, scoped access to Configurate nodes. Configuration writes use a temporary +file and replace the destination atomically when the filesystem supports it. Load and persistence failures are exposed +as configuration exceptions so the owning feature can apply its own lifecycle and operator-facing policy. + +Use a scoped `ConfigView` for feature sections rather than passing raw Configurate nodes through feature code. + +## Local cache files + +`CacheDirectory` creates a feature-specific directory below a caller-supplied cache root. Feature, cache, and store +segments are sanitized and canonicalized so a cache path cannot escape that root. + +The JSON-backed `FileCacheStore` stores one `CacheValue` per key with an expiry timestamp. Reads and listings remove +expired entries, writes are synchronized and atomically replace the JSON file when supported, and store operations are +safe for concurrent callers using the same store instance. + +These caches are local files. They do not provide distributed state, database persistence, or cross-process locking. + +## HTTP + +`AsyncHttpTransport` is the injectable non-blocking HTTP boundary. It returns `CompletionStage` and +lets the owning runtime choose the transport implementation. + +`JdkAsyncHttpTransport` uses the JDK HTTP client with no redirect following and bounded response handling. `HttpTransport` also provides +synchronous form and JSON POST helpers with bounded response bodies, timeouts, and optional HTTPS enforcement. +Features must keep blocking calls off Velocity event threads. + +Transport success means an HTTP response was received. The feature that makes the request owns response validation, +retry behavior, and domain-level failure handling. + +## Text, localization, and small utilities + +- `ComponentFormatter` converts color-code, MiniMessage, and plain input into Adventure components. Each conversion + selects the permitted MiniMessage features, can sanitize unsupported tags, and can auto-link URLs. +- `TextFormatter`, format inspectors, and color utilities support normalization and inspection of formatted text. +- `MessagePlaceholders` applies literal `{key}` replacements, processing longer keys first to avoid overlapping-token + ambiguity. +- `Language` and `MessageMap` hold localization data used by the runtime's localization layer. +- `JsonStrings` provides JSON helpers and `Paginator` provides clamped, one-based list paging. + +Formatting policy remains feature-owned. Treat player-provided text as untrusted and enable only the formatting +features appropriate for that input. + +## Use within this project + +Use the toolkit when the behavior is reusable and has no feature-specific policy. Keep feature-specific rules, +commands, listeners, persistence, and lifecycle ownership in the Velocity runtime. If an external plugin needs a +stable integration contract, define it in `proxyfeatures-api` rather than exposing a toolkit implementation type. + +See [Architecture](ARCHITECTURE.md) for module ownership and [Shared Contracts](CONTRACTS.md) for cross-process +messages. diff --git a/docs/features/README.md b/docs/features/README.md index 58dade4f..ef98e5f4 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -1,65 +1,63 @@ # ProxyFeatures Feature Reference -This directory documents all **33** feature modules currently present in ProxyFeatures. Every page combines operator-facing behavior with the exact Velocity lifecycle, persistence, messaging and failure contracts found in the implementation. +This directory explains every feature included in ProxyFeatures. Each page starts with what the feature is for, then +covers its settings, commands, player-facing behavior, and the runtime details that matter when operating a network. ## How to use these pages -- Start with commands, permissions and configuration when operating the network. -- Read lifecycle, ordering, persistence and failure sections before changing security or cross-server behavior. -- Treat implementation source and generated runtime configuration/messages as authoritative when a deployed version differs from these pages. -- Keep the matching page in the same pull request whenever a command, permission, configuration key, message variable, data model, wire contract or intrinsic changes. -- Preserve documented limitations as explicit contracts until the underlying implementation is improved. +- Start with the feature overview, commands, permissions, and configuration. +- Read the sections about runtime behavior before changing security, routing, or cross-server settings. +- Use the linked feature pages to understand the effect on players, staff, and backend servers. ## Routing and network status -- [Capacity](capacity.md) — Owns proxy/gameplay/group/backend capacity, reserved slots, exact connection leases, runtime states and restart return reservations. -- [Hub](hub.md) — Connects players to the one exact registered backend named `lobby` after an asynchronous ping. -- [MOTD](motd.md) — Rewrites server-list description, displayed protocol and vanish-aware counts while preserving the incoming favicon/sample. -- [Queue](queue.md) — Maintains Capacity-backed priority/FIFO queues and paced lease dispatch for configured full backends. -- [HLink](hlink.md) — Creates website link/register tokens through the external HTTPS API and synchronizes username/LuckPerms group cache data. -- [SlashServer](slashserver.md) — Registers one dynamically managed root command for each enabled exact backend name. -- [ServerLinks](serverlinks.md) — Replaces the supported client's server-link list with seven fixed HauntedMC URLs. -- [Maintenance](maintenance.md) — Persists and enforces global or exact-backend maintenance, countdowns, bypasses, redirects and evacuation. -- [ResourcePack](resourcepack.md) — Selects global/exact-backend packs, blocks modern configuration transitions and applies terminal status policy. -- [Restart](restart.md) — Coordinates local proxy restart countdowns/schedules and optional durable backend-restart autoreconnect sessions. +- [Capacity](capacity.md) — Controls who can join the proxy or a backend, including reserved slots, queues, and temporary backend states. +- [Hub](hub.md) — Lets players use `/hub` to connect to the configured `lobby` backend. +- [MOTD](motd.md) — Controls the text, displayed protocol, and visible player count shown in the server list. +- [Queue](queue.md) — Holds players for full backends and sends them through in priority and arrival order. +- [HLink](hlink.md) — Links player accounts with the HauntedMC website and keeps profile information in sync. +- [SlashServer](slashserver.md) — Creates short commands such as `/survival` for enabled backend servers. +- [ServerLinks](serverlinks.md) — Shows the HauntedMC link menu in supported Minecraft clients. +- [Maintenance](maintenance.md) — Limits access to the whole network or selected backends while maintenance is active. +- [ResourcePack](resourcepack.md) — Offers the right global or backend-specific resource pack as players move around the network. +- [Restart](restart.md) — Schedules proxy restarts and returns players after planned backend restarts. ## Social and moderation -- [Vanish](vanish.md) — Mirrors versioned backend vanish updates into a proxy-local online-only `VanishAPI` view. -- [Friends](friends.md) — Stores canonical directional relationship rows for requests, accepted friendships and blocks, with local caches and activity notices. -- [Messenger](messager.md) — Provides online private messages, replies, toggle/privacy modes, blocks, spy copies and delivered-message history. -- [StaffChat](staffchat.md) — Subscribes to Redis staff-chat payloads, routes them to local permission viewers and announces staff activity. -- [Sanctions](sanctions.md) — Issues/history-logs sanctions, enforces player/IP bans at proxy login and shares mute state for backend enforcement. -- [TwoFactor](twofactor.md) — Encrypts TOTP secrets and confines required/unauthenticated accounts to a dedicated lock backend. +- [Vanish](vanish.md) — Keeps player visibility consistent across the proxy for features that need to hide vanished players. +- [Friends](friends.md) — Provides friend requests, lists, blocks, online activity notices, and friend-server travel. +- [Messenger](messager.md) — Provides private messages, replies, privacy settings, blocks, spy copies, and message history. +- [StaffChat](staffchat.md) — Displays staff chat and staff activity announcements to permitted staff members. +- [Sanctions](sanctions.md) — Lets staff issue and review sanctions while enforcing player and IP bans at login. +- [TwoFactor](twofactor.md) — Requires TOTP authentication for protected accounts before they can access the network normally. ## Security and diagnostics -- [AntiBot](antibot.md) — Applies local connection-rate, identity and adaptive attack-mode admission rules. -- [AntiVPN](antivpn.md) — Evaluates cached/sequential IP-reputation providers with explicit timeout and fail-open/fail-closed policies. -- [CommandHider](commandhider.md) — Filters advertised command roots according to configuration and permissions without replacing execution authorization. -- [ClientInfo](clientinfo.md) — Captures bounded untrusted client settings, brand, mod and plugin-channel telemetry with heuristic classification and persistence. -- [ConnectionInfo](connectioninfo.md) — Shows live ping/protocol/address/virtual-host/session diagnostics for online players. +- [AntiBot](antibot.md) — Detects connection floods and suspicious login patterns before they reach the network. +- [AntiVPN](antivpn.md) — Uses IP location and VPN providers to allow or deny connections by network policy. +- [CommandHider](commandhider.md) — Hides selected commands from a player's command suggestions without changing command permissions. +- [ClientInfo](clientinfo.md) — Records client details for staff diagnostics and can show players useful client recommendations. +- [ConnectionInfo](connectioninfo.md) — Shows staff a player’s ping and live connection details. ## Communication and information -- [Announcer](announcer.md) — Schedules configured announcements with per-player persisted enable state. -- [Broadcast](broadcast.md) — Sends one immediate formatted network broadcast through a permissioned command. -- [ProxyInfo](proxyinfo.md) — Shows fixed live Velocity/JVM/OS diagnostics, including bound address and CPU/memory snapshots. -- [PlayerInfo](playerinfo.md) — Combines canonical profile/history, live presence, language, shared-last-IP names and active sanctions for staff. -- [PlayerList](playerlist.md) — Renders local/global vanish-filtered player lists with staff grouping and synchronous backend health pings. -- [TextCommands](textcommands.md) — Registers universally available player-only roots that send configured localization messages with static placeholders. -- [PlayerLanguage](playerlanguage.md) — Persists `AUTO`/NL/EN preference and effective language with optional country-based AUTO detection. -- [VersionCheck](versioncheck.md) — Enforces a minimum Minecraft client protocol at login and audits allowed/denied observations. +- [Announcer](announcer.md) — Sends scheduled, configurable announcements to the right audience. +- [Broadcast](broadcast.md) — Lets permitted staff send an immediate formatted message or title to connected players. +- [ProxyInfo](proxyinfo.md) — Shows staff live proxy, Java, memory, CPU, and network-listener details. +- [PlayerInfo](playerinfo.md) — Gives staff a combined view of a player’s profile, presence, language, possible alts, and sanctions. +- [PlayerList](playerlist.md) — Shows local and global player lists while respecting vanish visibility. +- [TextCommands](textcommands.md) — Creates simple player commands that send configured messages. +- [PlayerLanguage](playerlanguage.md) — Saves a player’s Dutch or English preference and can choose automatically from their country. +- [VersionCheck](versioncheck.md) — Blocks client protocols below the configured minimum and explains the requirement in the server list. ## Operations and integration -- [Votifier](votifier.md) — Accepts legacy RSA Votifier v1 ingress, publishes stable-ID durable vote messages and maintains optional statistics/rollover/reminders. -- [PlayerCount](playercount.md) — Captures complete vanish-aware local network/backend counts and periodically broadcasts full non-durable snapshots. -- [CommandRelay](commandrelay.md) — Consumes/publishes allowlisted durable command operations with audit rows and a local processed-operation ledger. -- [CommandLogger](commandlogger.md) — Persists observed proxy/backend command text and execution outcomes for audit purposes. +- [Votifier](votifier.md) — Receives votes and reliably sends each vote to every configured reward backend. +- [PlayerCount](playercount.md) — Publishes visible and total player counts for use across the network. +- [CommandRelay](commandrelay.md) — Receives approved remote command requests and runs them safely on the proxy. +- [CommandLogger](commandlogger.md) — Records proxy commands for operational auditing. -## Coverage contract +## Keeping this guide useful -The index contains exactly one page for every feature package under the Velocity feature root. A feature with no command, persistence or external integration still receives a page that explicitly states those absences. - -Generated/dynamic commands are documented from their runtime registration rules rather than invented as fixed syntax. Known races, fail-open/fail-closed behavior, volatile state and incomplete integrations remain visible so operators and developers do not mistake intended architecture for current behavior. +Each feature has one page. Update its explanation whenever its settings, commands, messages, player experience, or +runtime behavior changes. diff --git a/docs/features/announcer.md b/docs/features/announcer.md index 0f74e0d9..4565ff78 100644 --- a/docs/features/announcer.md +++ b/docs/features/announcer.md @@ -1,7 +1,5 @@ # Announcer -> Velocity · Feature ID `announcer` · disabled by default · scheduled audience-aware announcements with persisted opt-out - Announcer loads definitions from `local/announcer.yml`, evaluates global and per-message audience/schedule constraints, chooses one eligible message through sequential, shuffle or weighted-random mode, then renders that message separately for every recipient. Players with toggle/admin access can persist an opt-out in MySQL. Players without either permission are force-enabled during login warm-up, so an old disabled database value cannot suppress announcements after their toggle entitlement is removed. @@ -56,7 +54,7 @@ messages: A definition needs either: -- `text`: inline mixed legacy/MiniMessage input; or +- `text`: inline mixed color-code/MiniMessage input; or - `key`: localization key, with optional `announcer.` prefix stripped internally. When both exist, `text` wins. Missing both makes the definition invalid. Inline content is converted to MiniMessage after feature placeholders are applied. Localization-key content is built through the normal audience-aware localization handler. @@ -246,43 +244,3 @@ Rotation state and schedule replacement use a private lock. The scheduled task, Recipients are selected under the lock, then messages are rendered/sent outside it. A player can disconnect/change backend between plan construction and delivery; the captured `Player` is still used. Settings database/cache lookups happen while building the base audience. An uncached player can trigger synchronous ORM work in an announcement cycle, so login warm-up is important for scheduler latency. - -## Important boundaries - -- Definitions are in `local/announcer.yml`, not the feature config. -- Player toggle state is globally persisted by DataRegistry player ID. -- Players without toggle/admin permission are force-enabled. -- Cooldown is in successful cycles, not time. -- `/announcer now` mutates normal rotation state. -- Debug/test do not mutate selection counters. -- Reload resets sequence/shuffle/cooldown state. -- Inline rendering occurs once per recipient. -- An uncached preference can cause ORM work during a cycle. -- No Redis/network message is used; this Velocity instance directly sends to connected players. -- No PlaceholderAPI expansion exists on Velocity. -- Invalid definitions are skipped individually; fallback is used only when no valid definitions exist. - -## Verification checklist - -1. Test all three modes with weights and multiple cooldown values. -2. Test no players, no global audience, no message audience and min/max audience boundaries. -3. Exercise date-only/date-time/offset/zone, weekdays and overnight daily windows. -4. Configure duplicate/unsafe IDs and both text/key content. -5. Validate per-player language, `{player}`, `{server}`, counts and backend switches. -6. Toggle preference, disconnect/reconnect and inspect `player_announcer_settings`. -7. Remove toggle permission from a disabled player and verify force-enabled warm/persistence. -8. Stop database access during warm, toggle and a cache-miss cycle. -9. Run `/announcer now`, debug and reload while the scheduled task is due. -10. Verify repeated reloads leave exactly one scheduled task. -11. Disconnect recipients after plan selection and before send in a controlled test. -12. Review direct admin command output separately from localized player-toggle messages. - -## Source map - -- Defaults, ORM and lifecycle: `features/announcer/Announcer.java` -- Scheduling, modes, evaluation and rendering: `features/announcer/internal/AnnouncerHandler.java` -- Definition parsing/mutation: `features/announcer/internal/AnnouncerRegistry.java` -- Audience/schedule contracts: `AnnouncementAudience.java`, `AnnouncementSchedule.java` -- Persisted preference: `AnnouncerSettingsService.java`, `entity/PlayerAnnouncerSettingsEntity.java` -- Login/cache lifecycle: `listener/AnnouncerPlayerListener.java` -- Complete Brigadier tree: `command/AnnouncerCommand.java` diff --git a/docs/features/antibot.md b/docs/features/antibot.md index d64e85af..e950d8b7 100644 --- a/docs/features/antibot.md +++ b/docs/features/antibot.md @@ -1,7 +1,5 @@ # AntiBot -> Velocity · Feature ID `antibot` · disabled by default · synchronous login admission policy - AntiBot evaluates every Velocity `LoginEvent` against ordered in-memory heuristics: 1. feature/bypass/whitelist; @@ -305,47 +303,3 @@ Feature disable closes/syncs known-player store. Lifecycle managers unregister l The service has no explicit closed flag; an already executing login evaluation can finish during disable. ORM audit futures can also complete later. Master feature reload recreates runtime counters/statistics. Command toggles call only `service.reloadConfig()` and preserve runtime state. - -## Security and operational boundaries - -- Known status is username-based, not UUID-based. -- Private-range bypass should be enabled only when the real client IP is reliably forwarded and trusted. -- Whitelist bypass skips burst counting as well as all denials. -- Permission bypass requires permissions to be available during Velocity LoginEvent. -- No cross-proxy/Redis sharing exists; multiple proxy instances maintain independent counters, cooldowns, attack state and known files. -- Distributed traffic across proxies can stay below every local threshold. -- File persistence silently ignores corruption/write failure. -- Per-key maps have retention cleanup but no absolute cardinality cap. -- Shared NATs can hit IP/subnet/diversity thresholds. -- Audit persistence is optional and non-blocking. -- Attack override is in-memory and resets on service recreation. - -## Verification checklist - -1. Test exact threshold boundary: configured max versus max+1 for every check. -2. Compare known/new IP/subnet limits and username/diversity exclusions. -3. Test IPv4, IPv6, mapped/normalized forms, prefix 0/max and invalid IP resolution. -4. Exercise exact IP, CIDR and private-range whitelist bypass. -5. Trigger burst activation, expiry and each manual override. -6. Verify attack multipliers and known-only gate order. -7. Test username rename/offline-mode reuse and disabled known-player store. -8. Fill/prune/persist/reload/corrupt `known-players.json`; profile post-login rewrite time. -9. Stop ORM/DataRegistry and verify enforcement remains while audits fail. -10. Validate notification throttles and console/staff audiences. -11. Exercise every command and note unimplemented usage `enable/disable`. -12. Run multiple proxy instances to demonstrate state is not shared. -13. Load-test many unique keys and cleanup retention. -14. Combine with other LoginEvent policies and verify priority/result ordering. - -## Source map - -- Defaults, ORM/task/lifecycle: `features/antibot/AntiBot.java` -- Typed config/clamping: `internal/AntiBotConfig.java` -- Evaluation/order/counters/attack mode: `internal/AntiBotService.java` -- IP/CIDR handling: `internal/AddressWhitelist.java`, `IpAddressUtil.java` -- Rolling state: `RollingWindowCounter.java`, `DistinctValueWindow.java` -- Known persistence: `KnownPlayerStore.java`, `KnownPlayerRegistry.java` -- Login/post-login events: `listener/AntiBotListener.java`, `AntiBotLoginPolicy.java` -- Administration: `command/AntiBotCommand.java` -- Notifications: `internal/AntiBotNotificationService.java` -- Audit: `audit/AntiBotAuditLogService.java`, `PlayerAntiBotLogEntity.java` diff --git a/docs/features/antivpn.md b/docs/features/antivpn.md index 1376f2a5..f9a2cd31 100644 --- a/docs/features/antivpn.md +++ b/docs/features/antivpn.md @@ -1,8 +1,6 @@ # AntiVPN -> Velocity · Feature ID `antivpn` · disabled by default · asynchronous IP intelligence admission policy - -AntiVPN resolves every login's numeric remote IP, bypasses configured exact/CIDR/private ranges, consults a bounded memory/disk cache, then runs an ordered asynchronous provider chain until it has enough country/VPN data. Region policy is evaluated before VPN policy. The result either allows login and stages the country in `CountryAPI`, or denies the `LoginEvent`, notifies staff and asynchronously writes an audit row. +AntiVPN resolves every login's numeric remote IP, bypasses configured exact/CIDR/private ranges, consults a bounded memory/disk cache, then runs an ordered asynchronous provider chain until it has enough country/VPN data. Region policy is evaluated before VPN policy. The result either allows login and stages the country in `NetworkLocationApi`, or denies the `LoginEvent`, notifies staff and asynchronously writes an audit row. There is no permission bypass for ordinary players; trusted sources must be handled by the IP whitelist. @@ -141,9 +139,9 @@ A timeout, exception or null result increments error metrics. `on_api_error=DENY This is distinct from a provider successfully returning unknown country/VPN, which uses the two unknown policies. -## Country API +## Network location API -`CountryAPI` is registered through the feature API manager and backed by `CountryService`. +`NetworkLocationApi` is registered through the feature API manager and backed by `CountryService`. It exposes the recently evaluated country for connected UUIDs. AntiVPN clears the entry on `DisconnectEvent`; TTL protects against missing disconnect cleanup. @@ -227,50 +225,3 @@ Disable calls `cache.close()`, which currently performs no action. It does not c A login lookup already in flight during disable can complete and mutate the old cache/service. There is no closed/generation token. Whitelisting an IP while a lookup is in flight affects future `evaluate` calls; the current evaluation already passed the whitelist check. - -## Important implementation boundaries - -- No permission bypass; whitelist is IP/CIDR based. -- DataRegistry is mandatory even though audit ORM is optional. -- Region is evaluated before VPN. -- Provider chain outer timeout uses maximum, not sum, of sequential provider timeouts. -- `cache.enabled` is not consulted by `PersistentIpCache`. -- Cache clear does not cancel in-flight computations. -- Fresh provider results can be labeled MEM by policy evaluation. -- Only whitelist can be live-reloaded; check/policy/provider/cache fields are captured. -- Login waits on async provider completion through `.join()` on an async event worker. -- Country API is staged state, not lookup-on-demand. -- No cross-proxy cache/decision sharing exists. -- Fail-open provider errors are allowed but audited when possible. -- Missing remote IP always denies, independently of on-api-error policy. - -## Verification checklist - -1. Exercise every combination of region/VPN check and unknown/error policies. -2. Test empty and populated allowed-country sets, casing and whitespace. -3. Test exact IPv4/IPv6, CIDR and private-range whitelist behavior. -4. Disable all providers, remove API keys and simulate each provider's timeout/error/partial data. -5. Verify sequential fallback against the max-not-sum outer timeout. -6. Compare first lookup/cache source with later memory/disk hits and restart persistence. -7. Set `cache.enabled=false` and verify actual behavior; test TTL/max/persist independently. -8. Clear cache during an in-flight lookup and inspect repopulation. -9. Test missing IP, online/offline command resolution and DataRegistry outage. -10. Stop ORM and confirm policy remains while audit disables/fails. -11. Inspect staff notification throttling and allowed-with-error audit rows. -12. Disconnect during lookup and verify CountryAPI lifecycle. -13. Run multiple proxy instances and confirm cache/metrics/whitelist runtime are local. -14. Review IP retention/privacy in JSON cache and MySQL audit. - -## Source map - -- Defaults, cache/provider/API/ORM lifecycle: `features/antivpn/AntiVPN.java` -- Policy orchestration: `internal/AntiVPNService.java` -- Login/disconnect ordering: `listener/AntiVPNListener.java`, `AntiVPNLoginPolicy.java` -- Cache/in-flight dedupe: `internal/PersistentIpCache.java` -- Whitelist: `internal/IpWhitelist.java` -- Provider merge: `internal/provider/ProviderChain.java` -- Provider construction/implementations: `ProviderRegistry.java`, `ProxyCheckProvider.java`, `IP2LocationProvider.java` -- Country API/state: `api/CountryAPI.java`, `internal/CountryService.java` -- Command: `command/AntiVPNCommand.java` -- Metrics/notifications: `MetricsCollector.java`, `NotificationService.java` -- Audit: `audit/AntiVpnAuditLogService.java`, `PlayerAntiVpnLogEntity.java` diff --git a/docs/features/broadcast.md b/docs/features/broadcast.md index da1125ef..c52c4105 100644 --- a/docs/features/broadcast.md +++ b/docs/features/broadcast.md @@ -1,7 +1,5 @@ # Broadcast -> Velocity · Feature ID `broadcast` · disabled by default · command `/broadcastproxy` - Broadcast sends one staff-authored Adventure component directly to every player currently connected to this Velocity proxy. It supports either a chat component or a title/subtitle pair. It does not use Redis or backend plugin messaging; multiple proxy instances do not automatically share broadcasts. @@ -36,7 +34,7 @@ Suggestions include one chat announcement template and two title examples. Title values are clamped to zero or greater and multiplied by 50 ms. -The command caches `Title.Times` in its constructor. A soft config reload that does not reconstruct the command leaves old timings active. `reloadTitleTimesCache()` exists but the feature does not register a reload hook that calls it. +The command reads these values when each title is created. A feature soft reload therefore applies new title timings to the next broadcast without reconstructing or re-registering the command. There is no config prefix, target server, audience permission, logging, cooldown, scheduling or message length setting. @@ -44,7 +42,7 @@ There is no config prefix, target server, audience permission, logging, cooldown Chat input is parsed through `ComponentFormatter` with: -- mixed input, supporting configured legacy/MiniMessage-style syntax; +- mixed input, supporting configured color-code and MiniMessage-style syntax; - all default formatting features; - automatic URL linking enabled. @@ -63,7 +61,7 @@ The first literal `|` splits the input: Title and subtitle are independently parsed as mixed formatted input with all default features. Unlike chat, `autoLinkUrls(true)` is not enabled. -The cached timing object is applied to one `Title`, which is shown to every connected player. +A point-in-time timing snapshot is applied to one `Title`, which is shown to every connected player. ## Delivery snapshot and ordering @@ -97,30 +95,3 @@ The feature has: - no permission filtering for recipients. Every connected player receives the component, including vanished/staff players and players not yet connected to a backend. - -## Important boundaries - -- Correct command is `/broadcastproxy`, not `/broadcast`. -- This is proxy-instance-wide, not automatically multi-proxy-wide. -- Message content is not localized per recipient. -- Chat auto-links URLs; titles do not. -- First pipe only separates title/subtitle. -- Timings are cached until command reconstruction/manual cache reload. -- Sender success means iteration completed, not that every client displayed it. -- No exception isolation, audit or rate limit exists. - -## Verification checklist - -1. Run chat/title from console and player with/without permission. -2. Test legacy codes, MiniMessage, URLs and malformed formatting. -3. Test no pipe, one pipe, multiple pipes and empty title/subtitle sides. -4. Change timings through soft reload and confirm cached behavior. -5. Disconnect/connect players during a large broadcast. -6. Make one audience send/formatter path fail and inspect later delivery/acknowledgement. -7. Run two Velocity instances and verify only the executing proxy's players receive it. -8. Check backend commands for root-name conflicts. - -## Source map - -- Defaults and command lifecycle: `features/broadcast/Broadcast.java` -- Complete command, rendering and timing cache: `features/broadcast/command/BroadcastProxyCommand.java` diff --git a/docs/capacity-placeholders.md b/docs/features/capacity-placeholders.md similarity index 74% rename from docs/capacity-placeholders.md rename to docs/features/capacity-placeholders.md index 16da6dab..12754617 100644 --- a/docs/capacity-placeholders.md +++ b/docs/features/capacity-placeholders.md @@ -1,7 +1,5 @@ # Capacity Snapshot Feed -> ProxyFeatures Capacity 1.4.0 · versioned Redis latest-value feed for ServerFeatures placeholders - ## Purpose Capacity remains the single admission authority. It publishes one complete in-memory snapshot for backend presentation through ServerFeatures and PlaceholderAPI. @@ -111,18 +109,3 @@ Capacity: ``` The backend stale window should remain comfortably above the proxy publish interval. With the defaults, five consecutive two-second publication periods must be missed before values become unavailable. - -## Operational verification - -1. Enable Capacity on ProxyFeatures and one ServerFeatures backend. -2. Verify the proxy logs the snapshot channel and interval. -3. Verify the backend logs the same channel, publisher and exact local `server_name`. -4. Resolve `%capacity_available%` and confirm `true`. -5. Compare `%capacity_network_used%` and `/capacity status`. -6. Compare one group with `/capacity status group ` and one exact server with `/capacity info `. -7. Set a server to `DRAINING` and verify state/accepting placeholders update. -8. Stop Redis and verify placeholders become stale without affecting admission. -9. Restore Redis and verify the reconnecting subscription receives a fresh snapshot. -10. Restart the proxy and verify the new publisher epoch is accepted while the retired epoch cannot regain authority. - -There is no legacy PlayerSlots identifier, compatibility channel, database-poll fallback or dual publication path. diff --git a/docs/features/capacity.md b/docs/features/capacity.md index b3bf40d9..b60f298c 100644 --- a/docs/features/capacity.md +++ b/docs/features/capacity.md @@ -1,7 +1,5 @@ # Capacity -> Velocity · Feature ID `Capacity` · disabled by default · authoritative admission control - Capacity is the single authority for proxy login and backend admission. It combines numeric limits, reserved slots, server states and Queue integration before Velocity starts a connection. @@ -201,3 +199,15 @@ Capacity publishes its current in-memory snapshot for ServerFeatures placeholder observational only. Redis failure does not alter admission decisions. A reload reconfigures the publisher when its channel, publisher ID or interval changes and keeps the existing publisher when those values are unchanged. + +## Public `AdmissionApi` + +Capacity registers `AdmissionApi` from `proxyfeatures-api`. `tryAcquire(AdmissionRequest)` performs one atomic +evaluation and returns either a typed denial or an `AdmissionLease` for the exact player/target/intent. Public callers +cannot submit reserved status, permissions, bypass flags, maintenance state, or restart state; Capacity derives every +trusted input itself. Callers commit after a successful connection or release/close an abandoned lease. + +`snapshot()` returns immutable proxy, gameplay, group, and server scope observations. Snapshots and Redis messages are +diagnostic only and must never be used to authorize a connection. + +The richer `CapacityAPI` used by Queue/Restart is a runtime-only collaboration port. It is not an external API. diff --git a/docs/features/clientinfo.md b/docs/features/clientinfo.md index 9103451a..9a102b7e 100644 --- a/docs/features/clientinfo.md +++ b/docs/features/clientinfo.md @@ -1,7 +1,5 @@ # ClientInfo -> Velocity · Feature ID `clientinfo` · disabled by default · untrusted client telemetry, persistence and recommendations - ClientInfo observes Velocity client events for protocol, brand, settings, Forge-style mod lists and plugin-channel registration. It keeps a bounded in-memory snapshot per connected UUID, infers a best-effort client family, persists the latest snapshot through DataRegistry-backed ORM tables, and exposes `/clientinfo` views/recommendations plus optional login advice. All values originate from the client/protocol and are diagnostic signals, not proof of a client, mod or rule violation. Clients can omit, forge or change them. @@ -323,45 +321,3 @@ Feature disable clears telemetry state after listener/task/ORM lifecycle cleanup There is no persisted session ID. A stale DB row remains after disconnect and represents last known telemetry. When ORM/DataRegistry is unavailable at initialization, the feature fails rather than operating memory-only. - -## Important boundaries - -- Client telemetry is optional and forgeable. -- Latest-state DB storage is not a historical audit trail. -- No retention cleanup exists. -- Observed channels persist within snapshot even after unregister; current channels do not. -- Detection uses observed channels and equal-score first-family ordering. -- Forge family can be inferred from any nonnull mod-list type. -- Output flags and sensitive permission both apply. -- Base command permission is required for every branch. -- Personal notify preference is persisted; other advice state is in memory. -- Profiles are first-match and captured at initialization. -- Persistence is debounced/generation-coalesced, not transactional with disconnect/advice. -- No Redis/cross-proxy live telemetry sharing exists; each proxy sees its connected sessions and writes shared latest rows. - -## Verification checklist - -1. Join with vanilla, no-brand/settings, malformed/long brand, Forge/Fabric/Quilt and client-channel combinations. -2. Send more than 64 mods and 256 channels; verify truncation, sorting and current/observed semantics. -3. Create ambiguous equal/higher scoring evidence and inspect selected family/evidence. -4. Test every output flag with and without sensitive permission. -5. Exercise all command permissions, console behavior and online target completion. -6. Toggle notifications and inspect `player_clientinfo_settings` across reconnect/proxy switch. -7. Change backend profiles and verify effective checks/recommendations. -8. Generate rapid telemetry changes and confirm one latest debounced transaction. -9. Disconnect during debounce and compare flushed/latest DB state. -10. Stop ORM/DataRegistry during initialization and runtime writes/settings reads. -11. Inspect main/mod/channel row deletion/update behavior between sessions. -12. Review retention/privacy access for all four tables. - -## Source map - -- Defaults, ORM and lifecycle: `features/clientinfo/ClientInfo.java` -- Typed config/profiles/output: `internal/ClientInfoConfig.java` -- Session capture/bounds/detection: `internal/ClientTelemetryService.java` -- Recommendations/views/notification state: `internal/ClientInfoAdvisor.java` -- DB debounce/reconciliation: `internal/ClientInfoPersistenceService.java` -- Personal setting: `internal/ClientInfoSettingsService.java` -- Events: `listener/PlayerListener.java` -- Command/permissions: `command/ClientInfoBrigadierCommand.java` -- Entities: `entity/PlayerClientInfo*.java` diff --git a/docs/features/commandhider.md b/docs/features/commandhider.md index 461ae473..6d45ac96 100644 --- a/docs/features/commandhider.md +++ b/docs/features/commandhider.md @@ -1,7 +1,5 @@ # CommandHider -> Velocity · Feature ID `commandhider` · disabled by default · client command-tree filtering only - CommandHider removes configured **root literals** from the `PlayerAvailableCommandsEvent` Brigadier tree sent to a player's client. This hides commands from tab completion/client help discovery, but does not unregister, block or authorize the underlying command. Every real command must still enforce its own permission. @@ -126,34 +124,3 @@ The hidden list persists in the feature config. There is no database, Redis or p Initialization builds the snapshot before registering the listener/command. Disable has no explicit work; lifecycle manager unregisters resources and the snapshot becomes unreachable. A soft config reload that changes YAML but does not call `refreshFromConfig()` leaves the previous runtime snapshot. The built-in add/remove paths do refresh it. - -## Important boundaries - -- Presentation hardening only; not access control. -- Exact root-literal matching only. -- Leading slashes/case normalize; spaces remain part of one unusable literal. -- Bypass skips every hidden root. -- Base command permission is required in addition to granular child permissions. -- Live edit does not explicitly resend command trees. -- Aliases must be listed individually. -- No allow-list mode or backend-specific policy exists despite the old page's implication. -- Config write failure is not converted to localized command feedback. - -## Verification checklist - -1. Hide a Velocity command and verify disappearance from tab tree but manual execution still follows its own permission. -2. Test mixed case, repeated slashes, blank entries, duplicates and entries containing spaces. -3. List every alias separately and confirm root-only behavior. -4. Grant bypass and compare command trees. -5. Grant granular admin permission without base, then with base. -6. Add/remove while players are online and determine when Velocity resends their tree. -7. Trigger permission/server-switch command-tree refresh after a live edit. -8. Test config-write failure and soft reload without explicit handler refresh. -9. Inspect client command tree from modified clients and backend aliases. - -## Source map - -- Defaults/lifecycle/messages: `features/commandhider/CommandHider.java` -- Normalization/atomic snapshot: `internal/HiderHandler.java` -- Tree mutation: `listener/AvailableCommandListener.java` -- Administration/config writes: `command/CommandHiderCommand.java` diff --git a/docs/features/commandlogger.md b/docs/features/commandlogger.md index f43f58a2..18b5d44d 100644 --- a/docs/features/commandlogger.md +++ b/docs/features/commandlogger.md @@ -1,7 +1,5 @@ # CommandLogger -> Velocity · Feature ID `commandlogger` · disabled by default · verified proxy-command console/MySQL audit - CommandLogger observes `CommandExecuteEvent` near the beginning of Velocity's event order. It logs only commands that Velocity's `CommandManager` reports as registered and executable for that source. The raw command line is written to the feature log immediately and persisted to MySQL through the shared player reference model. It does not observe arbitrary backend commands that Velocity does not own for that source. @@ -122,77 +120,6 @@ This can include secrets from commands such as: - staff commands containing private evidence/data; - private messages when implemented as proxy commands. -The old page's claim that arguments are redacted/configurable is not implemented. Operators must treat both destinations as sensitive, restrict access/retention and add an explicit command-aware redaction policy before relying on this for privacy-safe auditing. +Arguments are logged without redaction or configuration. Operators must treat both destinations as sensitive, restrict access and retention, and add an explicit command-aware redaction policy before relying on this for privacy-safe auditing. Redaction should happen before **both** console and database output and preserve enough alias/actor/result context for investigation. - -## Scope boundaries - -A command is logged only when `CommandManager.hasCommand(alias, source)` is true at this early point. - -Consequences: - -- a registered proxy command hidden by CommandHider is still logged when manually executed and permitted; -- a proxy command for which this source lacks availability/permission is not logged; -- a command forwarded to Paper because Velocity has no applicable root is not logged here; -- an alias is logged as typed, not canonicalized to primary command name; -- leading `/` inside the event string is not stripped by alias extraction. If Velocity supplies `/alias` rather than `alias`, `hasCommand` can fail and skip it; current Velocity normally provides no leading slash. - -Audit backend execution separately through ServerFeatures CommandLogger. - -## Concurrency and durability - -The immediate plaintext logger is synchronous. Player database writes are delayed until identity readiness plus lifecycle scheduling; nonplayer writes are synchronous ORM transactions in the event thread. - -There is: - -- no queue/outbox; -- no retry; -- no batching; -- no write acknowledgement associated with the command; -- no deduplication/event ID; -- no success/failure/status update; -- no cleanup/retention task. - -A process crash after console log but before DB commit produces only the log-file record. A DB retry added externally could duplicate rows because there is no unique execution ID. - -## Disable/reload - -`disable()` is empty. ORM/listener/task cleanup belongs to lifecycle/data managers. Outstanding identity futures can complete after disable and attempt scheduling; scheduling failure is caught/warned, but already scheduled ORM tasks rely on lifecycle behavior. - -There is no explicit service closed/generation flag. - -## Important boundaries - -- Only registered/available Velocity commands are observed. -- The listener records attempts before later execution outcome is known. -- No redaction/exclusion configuration exists. -- Full command arguments go to logs and MySQL. -- `server` is always literal `proxy`. -- Player DB writes require DataRegistry identity twice (readiness then managed transaction resolution). -- Nonplayer ORM work runs directly in the command event callback. -- No command result, denial reason or duration is stored. -- Column-length overflow is not handled. -- Logging failure does not intentionally cancel the command. -- No retention or audit query command exists. - -## Verification checklist - -1. Run proxy-owned, backend-forwarded, unavailable and aliased commands from player/console. -2. Verify event command slash/whitespace assumptions on deployed Velocity. -3. Deny a command in a later listener and confirm it remains recorded as an attempt. -4. Run secret-bearing commands and inspect both log destinations before production enablement. -5. Stop DataRegistry and ORM independently. -6. Disconnect player before identity readiness completes. -7. Disable/reload during readiness/scheduled persistence. -8. Send commands longer than database limits in a disposable schema. -9. Compare with ServerFeatures CommandLogger for roots handled at different layers. -10. Confirm retention/access controls for application logs and `player_command_executions`. - -## Source map - -- ORM/lifecycle: `features/commandlogger/CommandLogger.java` -- Early event filtering: `listener/CommandListener.java`, `CommandExecutionPolicy.java` -- Plaintext log: `internal/LogHandler.java` -- Identity/transaction write: `service/CommandLogService.java` -- Schema: `entity/CommandExecutionEntity.java` diff --git a/docs/features/commandrelay.md b/docs/features/commandrelay.md index 0de4bd7a..d949747a 100644 --- a/docs/features/commandrelay.md +++ b/docs/features/commandrelay.md @@ -1,7 +1,5 @@ # CommandRelay -> Velocity · Feature ID `commandrelay` · disabled by default · durable Redis console-command consumer/publisher helper - CommandRelay consumes `CommandRelayMessage` records from one fixed durable Redis stream and executes allowlisted command roots as the Velocity console. Each requested execution has a stable operation ID that must equal the durable envelope processing key. Completed operation IDs are retained in a local JSON ledger before Redis acknowledgement, reducing ordinary replay after reconnect/restart. The feature has no player/staff command, target selector, response message, permission surface or remote-result channel. It is an infrastructure consumer, not an interactive command UI. @@ -206,168 +204,3 @@ with configured TTL. `markProcessed` synchronizes on the store, writes the JSON marker, then adds the key to memory. There is no explicit ledger pruning beyond cache-store TTL behavior and no administration command to inspect/clear markers. - -## Delivery guarantees and duplicate window - -The normal successful ordering is: - -```text -execute command -→ persist processed marker -→ acknowledge Redis -``` - -This makes acknowledgement loss safe: on redelivery, the persisted marker suppresses another execution. - -However, arbitrary console command execution cannot participate atomically in the file-cache write. A process crash or completion-scheduling failure after command side effects but before marker persistence can execute the command again after durable redelivery. - -This is the critical at-least-once side-effect window. Relayed commands should be idempotent or carry their own operation-aware business ledger when duplicate side effects are unacceptable. - -Additional cases: - -- marker write succeeds, ack fails → redelivery is suppressed by ledger and acknowledged again; -- dispatch throws before known terminal result → no marker/ack, so retry is intentional; -- forbidden/invalid/replay cases acknowledge without writing a new marker; -- local marker TTL expiry permits a very late duplicate operation ID to execute again; -- different consumer groups each have their own local ledgers and can each execute once. - -The local ledger is not shared between proxy nodes. Correct single-execution architecture relies on one consumer group plus durable pending ownership; moving a pending entry to another proxy after the first proxy executed but crashed before marker write remains subject to the duplicate window. - -## Audit database - -Optional table: - -```sql -command_relay_logs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - relay_channel VARCHAR(100), - origin_server VARCHAR(100), - command_alias VARCHAR(64), - command_text VARCHAR(512), - event_type VARCHAR(64) NOT NULL, - details VARCHAR(512), - created_at BIGINT NOT NULL -) -``` - -Indexes: - -- `(origin_server, created_at)`; -- `(command_alias, created_at)`; -- `(event_type, created_at)`. - -Event types emitted: - -- `invalid_payload`; -- `replay_ignored`; -- `forbidden_command`; -- `dispatch_error`; -- `executed`; -- `dispatch_rejected`. - -Every string is trimmed, blank-to-null and truncated to its column limit before persistence. The transaction runs synchronously in `logEvent`; exceptions are caught and warned. - -Audit failure never changes acknowledgement or dispatch policy. Some audit calls occur on Redis callback threads and some on scheduled/async command completion paths, so synchronous ORM latency can delay those paths. - -The audit row does not contain operation ID as a dedicated column. It appears only in details for replay events and is absent from normal executed rows, making end-to-end operation tracing incomplete despite the stable ID contract. - -## Publisher helper - -`EventBusHandler.publish(stream, command)`: - -- accepts any caller-supplied stream string; -- creates a new `CommandRelayMessage(command, "proxy")` and random operation ID; -- publishes one durable event; -- logs publication failure; -- returns the publication future. - -The feature itself registers no command/API service that calls this method. Other code must obtain the concrete handler and choose the remote stream. There is no retry wrapper; callers wanting safe retry must preserve/reuse an explicit operation ID, but this helper always generates a new ID and therefore is unsuitable for caller retries that must deduplicate. - -The public helper cannot supply a stable caller operation ID or custom origin. Producers requiring retry safety should construct/publish the shared contract directly or extend the API. - -## Targeting and result routing - -There is no target field in `CommandRelayMessage` and no target validation in this consumer. Targeting is achieved only by selecting the Redis stream/consumer group topology. - -There is also no response contract. The producer receives publication confirmation, not execution success. Execution outcome is visible only through proxy logs and optional MySQL audit. - -The old page's claims about target routing, timeout, correlation responses and one terminal result message are not implemented. - -## Security boundary - -Any actor able to publish a structurally valid message into the stream can request any root in `command_whitelist` as full Velocity console authority. - -Security therefore depends on: - -- Redis credentials/network isolation; -- producer authentication/authorization; -- minimal root allowlist; -- command handlers' argument-level safety; -- safe consumer-group topology. - -Root allowlisting does not constrain arguments/subcommands. Allowing a broad administrative root grants every subcommand the Velocity console can execute beneath it. - -Origin server is informational and not authenticated or allowlisted. It can be spoofed by a Redis publisher. - -Command text and arguments are logged/audited without redaction. - -## Shutdown and lifecycle - -Disable detaches the subscription reference and blocks up to five seconds on `closeAsync().get(...)`. - -- interruption restores interrupt flag and warns; -- execution failure/timeout warns; -- active command futures/completion writes are not explicitly awaited; -- no closed flag prevents an already scheduled command from finishing after subscription closure; -- local ledger/store is not explicitly closed. - -Unexpected subscription completion logs an error. The feature itself does not recreate the consumer; DataProvider durable implementation may self-heal its logical handle depending on provider guarantees. - -When `listening=false`, disable has no subscription to close but the event-bus handler can still exist for publishing. - -## Commands, permissions and placeholders - -None. There is no `/commandrelay` admin command, no permission node, no player audience and no PlaceholderAPI expansion. - -Configuration changes require feature reload/reconstruction. The whitelist is read for each delivery, but whether the config handler sees external file edits without reload depends on the config framework; consumer group/listening/TTL are captured at initialization. - -## Important boundaries - -- Consumed stream is fixed; target routing is topology-only. -- Empty whitelist denies every command. -- Root-only allowlist grants all arguments/subcommands beneath that root. -- Origin server is untrusted informational text. -- Incoming operation IDs are not regex-revalidated. -- False dispatch is terminal; exceptional dispatch retries. -- Active duplicate delivery is left unacknowledged. -- Execution-to-marker crash window can duplicate side effects. -- Marker ledger is local JSON, not shared DB/Redis. -- Publisher helper generates a new ID on every call and has no retry API. -- No execution response/timeout/remote acknowledgement exists. -- Audit is optional, synchronous and lacks dedicated operation-ID column. -- Disable waits for subscription close but not active command completion. -- Command arguments are not redacted. - -## Verification checklist - -1. Publish null/missing/mismatched operation ID, origin and command payloads; verify terminal acknowledgement. -2. Test leading slash, double slash, tabs, aliases, namespaced roots and empty whitelist. -3. Allow a broad root and verify every console-authorized subcommand/argument is reachable. -4. Run same operation ID concurrently and after restart/ack failure. -5. Interrupt process after side effect but before marker write in a disposable environment. -6. Force initial/completion scheduling failure, dispatch false and dispatch exception. -7. Expire/delete processed markers and replay an old operation ID. -8. Run two proxies in same and distinct consumer groups. -9. Stop ORM and confirm command/ack policy remains while audit fails. -10. Inspect audit truncation and absence of normal operation-ID field. -11. Test Redis reconnect/pending recovery and unexpected subscription completion. -12. Disable during an active command and marker write. -13. Review every allowed root for argument-level privilege escalation and secret logging. - -## Source map - -- Defaults, fixed stream, provider/group and lifecycle: `features/commandrelay/CommandRelay.java` -- Consume/validate/allowlist/dispatch/ack/publish: `internal/EventBusHandler.java` -- Persistent replay ledger: `internal/ProcessedCommandLedger.java` -- Shared contract: `proxyfeatures-contracts/.../CommandRelayMessage.java` -- Audit: `audit/CommandRelayAuditLogService.java`, `CommandRelayAuditLogEntity.java` diff --git a/docs/features/connectioninfo.md b/docs/features/connectioninfo.md index da05f912..e90930d9 100644 --- a/docs/features/connectioninfo.md +++ b/docs/features/connectioninfo.md @@ -1,7 +1,5 @@ # ConnectionInfo -> Velocity · Feature ID `connectioninfo` · disabled by default · live online connection diagnostics - ConnectionInfo registers two read-only commands: - `/ping [online player]` for latency; @@ -89,7 +87,7 @@ The command always sends these five hard-coded entries: | `Virtual Host` | Host string plus port from Velocity virtual host, or `N/A`. | | `Sessieduur` | `HH:MM:SS` since the local `SessionHandler` join timestamp, or `N/A`. | -The old page's claim that backend/server metadata is shown is incorrect: current backend is not read or displayed. Client brand/mods/settings are also not integrated; use ClientInfo separately. +The command does not read or display backend/server metadata. Client brand, mods, and settings are not integrated; use ClientInfo for those fields. The header adds literal Dutch text ` van ` for another target before inserting it into localized `{subject}`. Entry labels themselves are hard-coded and not localization keys. @@ -165,37 +163,3 @@ All fields are fetched synchronously from the current Velocity player object at ## Lifecycle Disable has no explicit cleanup. Lifecycle manager unregisters commands/listener and the feature/session handler becomes unreachable. The concurrent join map is not explicitly cleared, but contains only online-session UUIDs and is discarded with the feature instance. - -## Important boundaries - -- Online players only. -- Full IP and port are exposed with no redaction. -- Backend server is not displayed. -- Session duration resets on feature/proxy reload. -- Ping thresholds apply only to `/ping`, not the detailed command. -- Thresholds are constructor-cached. -- Entry labels/other-subject phrase are partly hard-coded Dutch/English. -- No sorting/limit on player suggestions. -- No historical database or API exists. -- Empty-string alias behavior depends on command framework. - -## Verification checklist - -1. Execute both self/other forms from player and console with each permission combination. -2. Test threshold values exactly below/at/above green and yellow. -3. Change threshold config through soft reload and verify constructor caching. -4. Compare IPv4/IPv6 remote formatting and virtual hosts with/without optional value. -5. Enable/reload while players are online and inspect reset session duration. -6. Test proxy clock rollback and sessions over 24 hours. -7. Verify no backend/client-brand fields appear. -8. Inspect command registration for empty alias handling. -9. Review permission assignment and privacy controls for full IP disclosure. -10. Compare output with AntiVPN/ClientInfo separately rather than assuming integration. - -## Source map - -- Defaults/lifecycle: `features/connectioninfo/ConnectionInfo.java` -- Session map: `internal/SessionHandler.java` -- Login/disconnect events: `listener/PlayerListener.java` -- Ping command/permissions: `command/PingCommand.java`, `PingCommandPolicy.java` -- Detailed output: `command/ConnectionInfoCommand.java` diff --git a/docs/features/friends.md b/docs/features/friends.md index d318947b..a1a90486 100644 --- a/docs/features/friends.md +++ b/docs/features/friends.md @@ -1,10 +1,8 @@ # Friends -> Velocity · Feature ID `friends` · disabled by default · persistent canonical social graph - Friends manages network-wide friend requests, accepted relationships, blocking, per-player availability, list/request views, bulk actions and friend activity notifications. Relationships are stored by canonical DataRegistry player IDs in one normalized pair row, while command/API results use UUID/name snapshots and online Velocity state. -It also registers `FriendshipAPI`, which Messenger uses for `FRIENDS` privacy checks. +It also registers `FriendshipApi`, which Messenger uses for `FRIENDS` privacy checks. ## Configuration @@ -265,25 +263,25 @@ Failure cases include: It is a server-join helper, not teleport-to-player coordinates. It uses Velocity server connection APIs and does not bypass backend admission/maintenance/full/queue policies. -## `FriendshipAPI` +## `FriendshipApi` Registered API interface: ```java -boolean areFriends(UUID first, UUID second); -CompletionStage areFriendsAsync(UUID first, UUID second); +CompletionStage areFriends(UUID firstPlayer, UUID secondPlayer) +CompletionStage relationship(UUID firstPlayer, UUID secondPlayer) ``` Implementation behavior: -- null/self pairs return false; -- synchronous method delegates to `FriendsService.areFriends` and can load cache/ORM depending on service state; -- async method runs the same check through feature async scheduling; -- errors log and return false. +- null inputs fail fast and self pairs complete with `false`; +- lookup runs through the feature task manager and never exposes ORM entities; +- disabling the feature completes pending/new lookups exceptionally; +- `relationship` derives `FRIENDS` or `NOT_FRIENDS` from the asynchronous decision. Messenger's `FRIENDS` privacy mode uses this service. Failure/unavailability therefore fails closed to “not friends.” Consumers must also handle the Friends feature being disabled/unregistered. -The API checks ACCEPTED relation only; PENDING/BLOCKED are false. +The API checks reciprocal ACCEPTED relation only; PENDING/BLOCKED are false. ## Activity notifications @@ -371,46 +369,3 @@ Initialization fails when DataRegistry or player ORM is unavailable. The feature Disable explicitly calls `activityService.shutdown()`; lifecycle managers remove listeners/command/API/tasks/ORM resources. Friendship rows/settings persist. In-flight command futures can complete around disable; service/task-manager rejection/error paths return localized failure or log according to the command helper. There is no distributed mutation queue/outbox. - -## Important boundaries - -- Canonical DB identity is numeric DataRegistry player ID, not names. -- One normalized pair row represents all states/directions. -- Block replaces friendship/request and is directional by row orientation. -- Disable removes pending requests but keeps accepted friends/blocks. -- Cache is local to one proxy and can be stale across proxy nodes until TTL. -- Request expiry is milliseconds and cleanup runs every 5 minutes. -- API errors return false. -- Root base permission is required for all subcommands. -- `/friend server` connects to a backend; it does not bypass queue/maintenance. -- Activity state/generations are process-local and vanish-aware. -- Known offline targets come from DataRegistry; no arbitrary username rows are created. -- There is no PAPI expansion. - -## Verification checklist - -1. Exercise every command branch and base/use/server permission combination. -2. Test unknown, renamed, offline and vanished targets in execution and tab completion. -3. Send simultaneous crossed requests and duplicate requests from multiple proxy threads/nodes. -4. Reach friend limits on requester, target and during concurrent accept. -5. Disable settings with incoming/outgoing requests and accepted friends. -6. Block while pending/accepted, reverse-direction block attempts and unblock ownership. -7. Run acceptall/denyall while individual requests expire/change concurrently. -8. Inspect pair uniqueness, direction and cache invalidation after every mutation. -9. Run two Velocity instances against shared DB and measure stale cache behavior. -10. Test join delay/jitter, rapid reconnect, feature reload suppression and server-switch baseline. -11. Vanish/unvanish a friend and verify activity/list/server visibility. -12. Stop DataRegistry/ORM during commands/API/activity/maintenance. -13. Disable feature with pending command/activity futures. -14. Validate Messenger FRIENDS privacy while Friends is enabled, disabled and failing. - -## Source map - -- Defaults/ORM/API/activity/maintenance: `features/friends/Friends.java` -- Relationship/settings transactions: `entity/FriendsService.java` -- ORM entities: `entity/FriendRelationEntity.java`, `FriendSettingsEntity.java`, `FriendStatus.java` -- Local snapshots: `support/FriendsCache.java`, `FriendSnapshot.java` -- Command tree/feedback/visibility: `command/FriendCommand.java` -- Registered API: `api/FriendshipAPI.java`, `FriendshipApiImpl.java` -- Activity events/generation: `listener/FriendActivityListener.java`, `FriendActivityPolicy.java` -- Activity delivery: `FriendsActivityService` and friends messaging support diff --git a/docs/features/hlink.md b/docs/features/hlink.md index be14d0d4..b8b2c873 100644 --- a/docs/features/hlink.md +++ b/docs/features/hlink.md @@ -1,7 +1,5 @@ # HLink -> Velocity · Feature ID `hlink` · disabled by default · external website link-key and player-cache synchronization - HLink integrates ProxyFeatures with HauntedMC's HTTPS website API. It creates or reuses website link tokens for players, exposes clickable link/register URLs, synchronizes the player's username and selected LuckPerms groups, and reacts to LuckPerms node changes. The feature does not store link tokens in ProxyFeatures, DataRegistry or the local filesystem. Link/account state is owned by the external website API. @@ -232,39 +230,3 @@ Disable: 3. clear pending update references, link admission gates and update cache. Shutdown does not wait for active HTTP tasks to finish. Interrupted requests log and return failure. Late command completions still pass through the feature task manager, whose lifecycle fencing is responsible for suppressing invalid work after disable. - -## Failure semantics and limitations - -- Website failures are generally logged without response bodies and presented to players as a generic link error. -- `alreadyRegistered` fails open to `false` on HTTP/parsing errors; the workflow may proceed to token lookup/creation after a failed account check. -- `doesKeyExist` also returns string `false` on errors. -- There is no local durable retry queue. -- The link token/account lifecycle, expiration, one-time use and uniqueness guarantees are external website responsibilities—not enforced by ProxyFeatures. -- The in-memory successful-update cache is lost on proxy restart. -- LuckPerms node events resolve by friendly name and only update players currently online on this proxy. - -## Operational verification - -1. Configure a real HTTPS website URL and API key. -2. Run `/link`; verify one clickable link is returned and the website records key type 1. -3. Run `/link` again; verify the website's existing-token behavior is reflected. -4. Run `/register`; verify key type 2 and its distinct already-registered message. -5. Repeat a command immediately; verify `hlink.retryLater` while the admission gate/cooldown applies. -6. Fill or artificially block the HTTP executor; verify requests fail safely rather than blocking Velocity threads. -7. Run `/hlink sync ` and inspect `updatePlayerCache` fields. -8. Change a direct global LuckPerms group on a loaded track; verify one asynchronous cache update. -9. Change only a server-context group; verify it is excluded from transmitted groups. -10. Disconnect before a link request completes; verify no clickable result is sent to a different session. -11. Disable the feature during HTTP work; verify subscriptions are removed and executor tasks are interrupted/cleared. - -## Source reference - -Primary implementation: - -- `features/hlink/HLink.java` -- `features/hlink/command/LinkCommand.java` -- `features/hlink/command/RegisterCommand.java` -- `features/hlink/command/HLinkCommand.java` -- `features/hlink/internal/HLinkHandler.java` -- `features/hlink/internal/HLinkRequestGate.java` -- `features/hlink/internal/hook/LuckPermsHook.java` diff --git a/docs/features/hub.md b/docs/features/hub.md index bed4b1b6..240e1da0 100644 --- a/docs/features/hub.md +++ b/docs/features/hub.md @@ -1,7 +1,5 @@ # Hub -> Velocity · Feature ID `hub` · disabled by default · fixed lobby transfer command - Hub registers one player command that connects the caller to the exact Velocity backend named `lobby`. It does not discover, balance or select among multiple lobby/limbo servers. ## Configuration @@ -112,35 +110,3 @@ Initialization only registers `HubCommand`. `disable()` is empty. Command unregistration is delegated to the feature lifecycle manager. There are no tasks, listeners, sockets or caches to stop. - -## Operational limitations - -- fixed exact destination `lobby`; -- no multi-lobby balancing or failover; -- no configurable aliases such as `/lobby`; -- extra arguments ignored; -- no ping timeout configured by the feature; -- no callback generation/session fencing; -- no player re-resolution after async work; -- unsuccessful connection without a reason is silent; -- ping success does not guarantee connection success; -- no direct Queue/Maintenance/TwoFactor awareness; -- no cooldown or anti-spam control. - -## Operational verification - -1. Register an exact backend named `lobby` and verify `/hub` success. -2. Rename/remove it and verify `hub.not_available`. -3. Stop/unroute the backend and verify the ping-failure message. -4. Run while already on lobby and verify duplicate detection. -5. Force a connection rejection with and without a reason component. -6. Run from console and without permission. -7. Run `/hub extra` and confirm arguments are currently ignored. -8. Delay ping completion, then disconnect/switch/disable the feature and inspect late-callback behavior. -9. Enable Maintenance/Queue/TwoFactor restrictions and verify their server-connect listeners can still intercept the request. -10. Review whether a fixed single lobby meets network availability requirements; use a dedicated routing feature when multiple fallback destinations are required. - -## Source reference - -- `features/hub/Hub.java` -- `features/hub/command/HubCommand.java` diff --git a/docs/features/maintenance.md b/docs/features/maintenance.md index 2bb31668..5167c432 100644 --- a/docs/features/maintenance.md +++ b/docs/features/maintenance.md @@ -1,7 +1,5 @@ # Maintenance -> Velocity · Feature ID `maintenance` · disabled by default · persistent global and per-backend admission control - Maintenance controls two independent scopes: - **global maintenance**, which blocks proxy login and disconnects all non-bypass players after an optional countdown; @@ -198,9 +196,16 @@ Restored gamemode maintenance similarly schedules immediate evacuation for every This matters during feature reloads: reloading while a countdown is active turns persisted active state into immediate enforcement after initialization. +## Public `MaintenanceApi` + +The feature publishes read-only `MaintenanceApi`. It reports global/backend scope activity, evaluates a player's +bypass permission for a scope, and returns an immutable `MaintenanceSnapshot`. Admission and external integrations use +this contract instead of accessing `MaintenanceHandler` or feature registry state. + ## MOTD integration -Maintenance registers line-2 override key `maintenance` with priority `100` in `MotdLine2OverrideRegistry`. +Maintenance resolves core `MotdExtensions` and registers owner `maintenance` with priority `100`. Its lifecycle-owned +`ExtensionRegistration` is closed during disable. The override returns configured `motd_line2` only when: @@ -234,37 +239,3 @@ Global flags/counters use atomics. Per-gamemode active/countdown/remaining/gener Countdowns schedule one delayed task per second rather than one repeating handle. Generation checks fence cancelled/replaced callbacks. Velocity player iteration, messaging and connection operations are invoked through the framework task manager or Velocity event callbacks. Connection-request completions directly message/disconnect the captured `Player`; lifecycle validity depends on framework task/listener teardown and Velocity's player object semantics. - -## Operational caveats - -- Global state is persisted before the countdown completes, so a proxy restart during countdown restores immediate active enforcement. -- Enabling a gamemode persists it before countdown completion with the same restart behavior. -- The feature treats each backend as a gamemode; it has no wildcard, group or server-prefix concept. -- A backend must be registered when enabled or restored. -- The configured lobby is not ping-tested before redirect/transfer. -- A gamemode-specific bypass node is built from the backend name; backend names should remain safe permission segments. -- Warnings include bypass players. -- Disabling the feature stops countdown bookkeeping but does not change persisted `active` or `active_gamemodes`; re-enabling the feature restores/enforces them. -- Global and gamemode maintenance may be active simultaneously. Global login denial takes precedence at login; per-backend rules still apply to bypass entrants and current connections. - -## Operational verification - -1. Enable global maintenance with a short countdown; confirm new non-bypass logins are denied immediately while existing players count down. -2. Confirm global-bypass users can join, receive the bypass notice and survive final enforcement. -3. Disable during countdown; confirm queued ticks do not later disconnect players. -4. Restart/reload during countdown; confirm persisted active state is enforced immediately after initialization. -5. Enable one backend; confirm initial joins redirect to lobby but transfers from another backend are denied. -6. Confirm backend-specific bypass and global bypass independently. -7. Put the configured lobby in maintenance; confirm non-bypass players cannot be redirected into it. -8. Make the lobby unavailable; confirm current target players are disconnected at evacuation. -9. Force a failed lobby connection result; confirm a failure is logged and the player is disconnected. -10. Enable global maintenance and query the server list; confirm MOTD line 2 changes only while MOTD is loaded and `motd_enabled` is true. -11. Disable and re-enable the feature with persisted state; confirm immediate reconciliation. - -## Source reference - -- `features/maintenance/Maintenance.java` -- `features/maintenance/internal/MaintenanceHandler.java` -- `features/maintenance/command/MaintenanceCommand.java` -- `features/maintenance/listener/MaintenanceConnectionListener.java` -- `features/motd/internal/MotdLine2OverrideRegistry.java` diff --git a/docs/features/messager.md b/docs/features/messager.md index dd7a7f0b..3e124070 100644 --- a/docs/features/messager.md +++ b/docs/features/messager.md @@ -1,7 +1,5 @@ # Messenger -> Velocity · Feature ID `messager` · disabled by default · private messages, privacy modes, blocking, reply, spy and delivery history - Messenger provides online-only private messaging across the proxy. It persists per-player enable/spy/privacy/block settings, uses Friends for `FRIENDS` policy, treats vanished targets as offline to ordinary viewers, maintains in-memory reply partners, and stores every delivered message in MySQL. ## Configuration @@ -9,7 +7,7 @@ Messenger provides online-only private messaging across the proxy. It persists p | Key | Default | Meaning | |---|---|---| | `enabled` | `false` | Loads ORM, handler, commands and listener. | -| `default_message_mode` | `FRIENDS` | Mode assigned to new rows and legacy null values. Supported: `ALL`, `FRIENDS`. | +| `default_message_mode` | `FRIENDS` | Mode assigned when new settings rows are created. Supported: `ALL`, `FRIENDS`. | Invalid default mode warns and falls back to `FRIENDS`. @@ -25,7 +23,7 @@ Primary key is canonical DataRegistry `player_id`. |---|---| | `msg_toggle` | `true` | | `msg_spy` | `false` | -| `message_mode` | nullable legacy field; initialized to configured default when loaded | +| `message_mode` | required privacy mode (`ALL` or `FRIENDS`) | Blocked targets are stored in element-collection table: @@ -50,7 +48,7 @@ message_text created_at ``` -Conversation key is the two UUID strings sorted lexicographically and joined with `:`. `source_kind` is `direct` or `reply`. Message text is legacy-code stripped, trimmed and truncated to 1024 characters. History persistence is best-effort through the shared audit-log base; delivery already occurred when logging is attempted. +Conversation key is the two UUID strings sorted lexicographically and joined with `:`. `source_kind` is `direct` or `reply`. Message text has color codes stripped, is trimmed, and is truncated to 1024 characters. History persistence is best-effort through the shared audit-log base; delivery already occurred when logging is attempted. ## Runtime caches @@ -205,7 +203,7 @@ The reply partner is volatile and set symmetrically after each delivered message ## Target visibility and vanish -Ordinary viewers cannot resolve/list a target reported vanished by `VanishAPI`. Commands and replies return the same `message.offline` output as a genuinely offline player. +Ordinary viewers cannot resolve/list a target reported vanished by `PresenceApi`. Commands and replies return the same `message.offline` output as a genuinely offline player. Visibility bypass: @@ -213,7 +211,7 @@ Visibility bypass: proxyfeatures.feature.vanish.bypass ``` -When VanishAPI is absent, direct lookups/listing fail open and all online players are visible. If the Vanish service throws, single-target visibility fails closed while suggestion listing returns empty and logs the error. +When PresenceApi is absent, direct lookups/listing fail open and all online players are visible. If the Vanish service throws, single-target visibility fails closed while suggestion listing returns empty and logs the error. Console suggestions list all online players; commands themselves remain player-only for direct/reply/settings operations. @@ -274,7 +272,7 @@ Block/unblock writes the ORM row and then updates local cache. Database exceptio Before display/history: -1. legacy formatting codes are stripped; +1. color formatting codes are stripped; 2. text is trimmed for stored form; 3. display text is escaped for MiniMessage. @@ -315,7 +313,7 @@ Root suggestions combine subcommands with visible online names. They are not com - `block`: visible online players only; - direct target suggestions exclude vanished players for ordinary viewers. -## Lifecycle and limitations +## Lifecycle Initialization requires both ORM access and DataRegistry. It creates settings/log entities, preloads players, registers `/msg`, `/reply`, and listener. @@ -337,33 +335,3 @@ Important limitations: - settings operations use ORM synchronously from command/task callbacks according to framework execution context. No PlaceholderAPI expansion or public Messenger API is registered. - -## Operational verification - -1. Test all sender/receiver toggle combinations and toggle bypass. -2. Test all mode combinations for friends/nonfriends and mode bypass. -3. Disable Friends and verify `FRIENDS` traffic receives service-unavailable while `ALL`↔`ALL` works. -4. Vanish a target and verify direct, reply and suggestions present them as offline to ordinary players. -5. Test vanish bypass staff visibility. -6. Block in each direction and verify symmetric delivery denial; test online/offline block-bypass target. -7. Disconnect/reconnect and verify settings persist but reply partner does not. -8. Test spy persistence and automatic permission reconciliation. -9. Send legacy/MiniMessage-like text and verify safe plain display/history. -10. Inspect `player_message_logs` fields and conversation-key stability. -11. Force history persistence failure and confirm delivery still occurs. -12. Race a Friends lookup with toggle, mode, block, vanish, disconnect and relog; verify completion revalidation. -13. Test missing DataRegistry identity during first load and subsequent settings operations. - -## Source reference - -- `features/messager/Messenger.java` -- `features/messager/command/MessagingCommand.java` -- `features/messager/command/ReplyCommand.java` -- `features/messager/internal/MessagingHandler.java` -- `features/messager/internal/MessagingSettingsService.java` -- `features/messager/internal/MessagePrivacyPolicy.java` -- `features/messager/internal/MessengerTargetVisibility.java` -- `features/messager/entity/PlayerMessageSettingsEntity.java` -- `features/messager/history/PlayerMessageLogEntity.java` -- `features/messager/history/PlayerMessageHistoryLogService.java` -- `features/messager/listener/PlayerListener.java` diff --git a/docs/features/motd.md b/docs/features/motd.md index f9af562b..560b45a9 100644 --- a/docs/features/motd.md +++ b/docs/features/motd.md @@ -1,7 +1,5 @@ # MOTD -> Velocity · Feature ID `motd` · disabled by default · server-list response presentation - MOTD handles `ProxyPingEvent` at priority `10` and replaces the ping's displayed version, player-count object and description while preserving the incoming favicon. It performs no remote I/O and registers no command. @@ -71,26 +69,26 @@ A runtime override is checked first. Without a nonblank override, line 2 resolve This makes the ping description tolerant of incomplete lists rather than failing the event. -## Runtime line-2 override registry +## Public MOTD extension registry -`MotdLine2OverrideRegistry` is a process-global concurrent registry used by other features. +Core publishes the lifecycle-safe `MotdExtensions` capability for internal features and external plugins. Registration contract: ```java -register(String key, int priority, Supplier supplier) -unregister(String key) +ExtensionRegistration register(String owner, int priority, MotdContributor contributor) ``` Resolution: 1. entries are sorted by descending priority; -2. ties are sorted by ascending registry key; -3. suppliers are called in that order; -4. a supplier exception, null or blank result is skipped; +2. ties are sorted by normalized owner key; +3. contributors receive `MotdContext` and return optional `MotdContribution` line overrides; +4. an exception, null, empty or blank contribution is skipped; 5. the first usable value wins. -Maintenance currently registers key `maintenance` at priority `100`. Overrides are raw line-2 templates and receive the normal MOTD placeholders after selection. +The returned registration is idempotently closed by its owner. Maintenance registers owner `maintenance` at priority +`100`. Overrides receive the normal MOTD placeholders after selection. ## Player-count presentation @@ -100,7 +98,7 @@ The handler starts from the incoming `ServerPing.Players` object: raw online = incoming online, or 0 when absent raw max = incoming max, or raw online when absent sample = incoming sample, or empty -vanished = VanishAPI.getVanishedCount(), or 0 when unavailable +vanished = PresenceApi.snapshot().hiddenCount(), or 0 when unavailable visible = max(0, raw online - vanished) adjusted online = (int) (visible * max(0, multiplier)) adjusted max = max(adjusted online, raw max) @@ -108,7 +106,7 @@ adjusted max = max(adjusted online, raw max) The multiplication uses Java's narrowing cast to `int`, so fractional results are truncated toward zero. The original sample list is preserved even though it may contain players excluded from the displayed count; MOTD itself does not filter sample identities. -When no Vanish API is registered, vanished count is treated as zero. +When no `PresenceApi` is registered, hidden count is treated as zero. ## Version presentation @@ -133,7 +131,7 @@ Both lines and runtime overrides support exact uppercase replacements: | `{MAX}` | Adjusted max count. | | `{ONLINE_REAL}` | Incoming raw online count. | | `{MAX_REAL}` | Incoming raw max count. | -| `{VANISHED}` | Current Vanish API count. | +| `{VANISHED}` | Current `PresenceApi` hidden count. | | `{SERVER_NAME}` | Global setting `server_name`, default `proxy`, escaped for MiniMessage. | | `{VERSION}` | Displayed version name, escaped for MiniMessage. | | `{PROTOCOL}` | Displayed protocol integer. | @@ -168,7 +166,7 @@ The replacement `ServerPing` contains: - rendered two-line component; - original favicon, if present. -MOTD does not load or validate favicon files and does not define hover entries itself. Those claims in the old high-level page did not match the implementation. +MOTD does not load or validate favicon files and does not define hover entries itself. ## Commands, permissions and PAPI @@ -182,28 +180,6 @@ No PlaceholderAPI expansion is registered; this is a Velocity server-list featur - Invalid/unknown line-2 modes degrade to random words. - Blank lists/templates follow the fallback chain. - A runtime override supplier exception is swallowed and the next provider is attempted. -- MiniMessage/legacy parse behavior is delegated to the shared component formatter. +- Component parsing is delegated to the shared component formatter. - VersionCheck and Vanish are optional service/load-state integrations. - Another ping listener running after priority 10 can replace MOTD's output; one running before it contributes the incoming favicon/sample/raw counts that MOTD uses. - -## Operational verification - -1. Ping with no players object and confirm counts fall back to zero safely. -2. Vanish one player and confirm `{ONLINE}` excludes them while `{ONLINE_REAL}` does not. -3. Set multiplier `1.5`; confirm truncation and max never below adjusted online. -4. Test all line-2 mode aliases and empty lists. -5. Verify sequential messages advance and reset after feature reload. -6. Verify random words are escaped and selected without duplicate positions. -7. Activate Maintenance and confirm its priority-100 line overrides only line 2. -8. Register multiple test overrides and verify priority/key ordering and exception skipping. -9. Test an unsupported protocol with VersionCheck loaded and confirm only the displayed version changes. -10. Confirm the incoming favicon and sample list remain unchanged. -11. Edit configuration and confirm cache expiry/reload timing. - -## Source reference - -- `features/motd/Motd.java` -- `features/motd/internal/MotdHandler.java` -- `features/motd/internal/MotdConfig.java` -- `features/motd/internal/MotdLine2OverrideRegistry.java` -- `features/motd/listener/PingListener.java` diff --git a/docs/features/playercount.md b/docs/features/playercount.md index fc109f71..18ab7f84 100644 --- a/docs/features/playercount.md +++ b/docs/features/playercount.md @@ -1,7 +1,5 @@ # PlayerCount -> Velocity · Feature ID `playercount` · disabled by default · authoritative local count API and periodic full-state publisher - PlayerCount captures one vanish-aware snapshot of the players known to this Velocity proxy and publishes the complete network/per-backend state through DataProvider Redis messaging. It is designed as a **latest-state broadcast**, not an event log. Every message replaces the receiver's prior snapshot for the accepted publisher epoch/sequence. @@ -32,8 +30,8 @@ Configuration is read during feature initialization. Changes require feature rel Initialization order: -1. construct `PlayerCountAPI` over the current `ProxyServer` and optional `VanishAPI` supplier; -2. register `PlayerCountAPI` as a lifecycle-owned service; +1. construct the internal `PlayerCountService` adapter over the current `ProxyServer` and optional `PresenceApi` reference; +2. register it under the public `PlayerCountApi` contract as a lifecycle-owned service; 3. attempt to register non-durable Redis `MessagingDataAccess` named `redis`; 4. when available, normalize settings, create publisher and schedule repeating publication; 5. when unavailable, warn and return. @@ -44,7 +42,7 @@ No database or DataRegistry access is required. ## Count semantics -Every `Counts` value contains: +Every `PlayerCounts` value contains: ```text online = real connected count, including vanished players @@ -99,44 +97,45 @@ Operators should treat normalized backend names as stable contract keys across V ## Vanish integration -The API resolves optional `VanishAPI` at capture time rather than caching one instance. +The implementation resolves optional `PresenceApi` at capture time through the reload-safe capability registry. When absent—or when the supplier returns null/empty—vanished count is zero and real online counts remain available. -When present, `getVanishedPlayers()` is called and converted into an immutable UUID set. Null/empty output becomes an empty set. +When present, `PresenceApi.snapshot().hiddenPlayers()` supplies the immutable hidden UUID set. -The publisher does not ask `isVanished` for each player and does not apply viewer-specific bypass logic. It publishes one canonical network view. +The publisher does not ask `isHidden` for each player and does not apply viewer-specific bypass logic. It publishes one canonical network view. -An exception from service lookup or `getVanishedPlayers()` propagates out of capture; that publication cycle is skipped and warning-throttled. +An exception from service lookup or snapshot capture propagates out of capture; that publication cycle is skipped and +warning-throttled. Because ProxyFeatures Vanish is itself a proxy-local online-state mirror, stale/missing Vanish updates can make published vanished counts temporarily inaccurate. -## Local `PlayerCountAPI` +## Public `PlayerCountApi` -Registered service class: +Registered public contract: ```java -nl.hauntedmc.proxyfeatures.features.playercount.internal.PlayerCountAPI +nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountApi ``` Methods: ```java -PlayerCountSnapshot capture() -PlayerCountSnapshot.Counts getNetworkCounts() -PlayerCountSnapshot.Counts getServerCounts(String serverName) +PlayerCountSnapshot snapshot() ``` -`getNetworkCounts` and `getServerCounts` each perform a new complete capture. They are not cheap cached getters. +Every `snapshot()` call performs a new complete capture. It is not a cached getter. `PlayerCountSnapshot` is immutable: ```text -network: Counts -servers: immutable normalized map +network: PlayerCounts(online, hidden), with derived visible() +servers: immutable map +observedAt: Instant ``` -The API is local/internal concrete service, not part of `proxyfeatures-api` and not a cross-plugin compatibility promise unless consumers intentionally depend on this implementation package. +The API and DTOs are part of `proxyfeatures-api`. The concrete `PlayerCountService` implementation remains internal and +must not be cast to or retained by consumers. ## Wire contract @@ -304,47 +303,3 @@ O(registered servers + online players + vanished players) ``` at every publication/API call. At the default two-second interval this is lightweight for normal network sizes, but direct consumers should not call API getters repeatedly in hot loops because each recaptures all state. - -## Operational limitations - -- authoritative only for players known to this Velocity process; -- multiple proxies are not automatically aggregated; -- publisher identity is operator-configured, not cryptographically authenticated; -- no durable/replayed latest value; -- no retry before next interval; -- overlapping ticks are dropped; -- failed publishes consume sequence numbers; -- no explicit receiver acknowledgement; -- no offline/tombstone snapshot on shutdown; -- wall-clock timestamps can jump; -- Vanish accuracy depends on its local mirror; -- capture is structurally immutable but not transactional; -- local API lives in an internal package; -- no proxy-side placeholders/commands. - -## Operational verification - -1. Enable without Redis; confirm local API works and publisher is absent. -2. Capture zero players and verify every registered backend appears with zeros. -3. Place players on several backends and one between servers; compare network/per-server sums. -4. Vanish/unvanish players and verify online/vanished/visible semantics. -5. Disable/break Vanish and verify fail-open zero-vanished behavior; make Vanish throw and verify publication-cycle warning. -6. Test case/whitespace normalization and duplicate normalized backend detection. -7. Inspect schema, publisher ID, UUID epoch, timestamp and increasing sequence. -8. Reload/restart and verify new epoch/sequence reset. -9. Force Redis failure/timeout and confirm no retry until next interval plus sequence gaps. -10. Hold one publish future open and confirm overlapping scheduled/manual calls are skipped. -11. Start/reconnect ServerFeatures after missed messages and confirm recovery on next full snapshot. -12. Publish reordered/duplicate old sequences in staging and verify receiver fences them. -13. Run two proxies with the same/different publisher IDs and validate intended receiver topology. -14. Disable during an in-flight publish and verify no new publication/tombstone is sent. -15. Load-test capture interval and avoid repeated direct API capture in hot paths. - -## Source reference - -- `features/playercount/PlayerCount.java` -- `features/playercount/internal/PlayerCountAPI.java` -- `features/playercount/internal/PlayerCountSnapshot.java` -- `features/playercount/internal/PlayerCountPublisher.java` -- `proxyfeatures-contracts/.../PlayerCountSnapshotMessage.java` -- `features/vanish/internal/VanishAPI.java` diff --git a/docs/features/playerinfo.md b/docs/features/playerinfo.md index 85779c4f..f3a0994f 100644 --- a/docs/features/playerinfo.md +++ b/docs/features/playerinfo.md @@ -1,8 +1,8 @@ # PlayerInfo -> Velocity · Feature ID `playerinfo` · disabled by default · canonical profile, activity, shared-IP and sanction inspection - -PlayerInfo exposes one staff command that combines a full DataRegistry player profile with live Velocity presence, optional language preference, possible alternate-account names inferred from last IP, and active sanctions read directly from the sanctions table. +PlayerInfo exposes one staff command that combines a full DataRegistry player profile with live Velocity presence, +optional language preference, possible alternate-account names inferred from last IP, and sanctions read through the +public `SanctionsApi` capability. It does not aggregate Friends, ClientInfo or ConnectionInfo despite the broader wording in the original first-pass page. @@ -70,7 +70,7 @@ Missing connection timestamps render an intrinsic em dash (`—`), not a localiz ## Language integration -PlayerInfo optionally locates `LanguageAPI`. +PlayerInfo optionally locates `PlayerLanguageApi`. - Missing API: `playerinfo.language_unknown`. - Explicit language: enum name. @@ -112,16 +112,9 @@ Because this is a second asynchronous phase, main profile output can appear firs ## Active sanctions -PlayerInfo creates its own ORM context for `SanctionEntity` and queries: - -```text -targetPlayerId = canonical player ID -active = true -expiresAt IS NULL OR expiresAt > now -order by createdAt desc -``` - -It does not require the Sanctions feature to be loaded, provided the entity/table and database access exist. +PlayerInfo resolves `SanctionsApi` for each request and calls `find(playerUuid, SanctionFilter.ACTIVE)`. When Sanctions is disabled or +the capability lookup/query fails, the section degrades to an empty result without importing its entity or ORM code. +This keeps persistence ownership inside Sanctions and makes feature reloads safe. Each item displays raw enum type, reason, expiry/permanent label and creation timestamp. Actor, sanction ID and target IP are not shown. @@ -147,13 +140,15 @@ No PlaceholderAPI expansion or public PlayerInfo service is registered. ## Threading and lifecycle -DataRegistry profile/alt operations return completion stages. Rendering callbacks are scheduled through the feature task manager. The active-sanctions ORM query is executed during the first scheduled render phase. +DataRegistry profile/alt operations and `SanctionsApi` return completion stages. Rendering callbacks are scheduled +through the feature task manager. The command captures the original `CommandSource`; it does not re-resolve a player source before the delayed alt callback. Console remains safe, while a disconnected player source may receive no visible result according to Velocity behavior. -Initialization requires DataRegistry plus ORM access and registers only the command. Disable has no custom cleanup. +Initialization requires DataRegistry and registers only the command. Sanctions persistence remains owned by the +optional Sanctions feature. -## Operational limitations and privacy +## Privacy and availability - one broad permission exposes UUID, activity timestamps, server presence, name history, alt inference and sanctions; - no field-level permissions/redaction; @@ -163,30 +158,7 @@ Initialization requires DataRegistry plus ORM access and registers only the comm - timezone invalidity silently falls back; - profile failure is mislabeled as not found; - alt failure is mislabeled as no alts; -- active sanctions are a point-in-time query independent of Sanctions feature state; +- sanctions are point-in-time public snapshots and unavailable when Sanctions is disabled; - no pagination or clickable drill-down; -- nickname/language sources are DataRegistry/LanguageAPI only; +- nickname/language sources are DataRegistry/PlayerLanguageApi only; - output tail can interleave across requests. - -## Operational verification - -1. Query online, known offline, UUID and historical-name identities. -2. Simulate DataRegistry failure and confirm current not-found presentation. -3. Verify timestamp formatting, invalid timezone and invalid pattern fallbacks. -4. Test player with no connection profile, nickname, language API or name history. -5. Test `AUTO` and explicit language output. -6. Vanish an online player and verify current visibility leak; review permission assignment. -7. Create expired, active temporary and permanent sanctions; confirm filtering/order. -8. Force alt lookup failure and confirm it appears empty. -9. Test shared household/VPN IPs and document false-positive expectations. -10. Use a large name history and concurrent command calls to inspect message volume/interleaving. -11. Run with Sanctions feature disabled but database table available. - -## Source reference - -- `features/playerinfo/PlayerInfo.java` -- `features/playerinfo/command/PlayerInfoCommand.java` -- `features/playerinfo/service/PlayerInfoService.java` -- DataRegistry `PlayerData`/`PlayerProfile` -- `features/playerlanguage/api/LanguageAPI.java` -- `features/sanctions/entity/SanctionEntity.java` diff --git a/docs/features/playerlanguage.md b/docs/features/playerlanguage.md index b3ea5be7..cbf25c24 100644 --- a/docs/features/playerlanguage.md +++ b/docs/features/playerlanguage.md @@ -1,7 +1,5 @@ # PlayerLanguage -> Velocity · Feature ID `playerlanguage` · disabled by default · persisted preference plus AUTO country detection - PlayerLanguage stores two canonical values through DataRegistry: ```text @@ -9,7 +7,7 @@ preference: AUTO | NL | EN effective: NL | EN ``` -`AUTO` resolves Dutch for configured country codes and otherwise a configured fallback. The feature registers `LanguageAPI` so localization and PlayerInfo can read the current cached result. +`AUTO` resolves Dutch for configured country codes and otherwise a configured fallback. The feature registers `PlayerLanguageApi` so localization and PlayerInfo can read the current cached result. ## Hard dependencies @@ -18,7 +16,7 @@ Initialization requires: - DataRegistry API; - DataRegistry feature flag `LANGUAGE` enabled. -Missing either fails feature initialization. Country detection is optional through AntiVPN's `CountryAPI`. +Missing either fails feature initialization. Country detection is optional through AntiVPN's `NetworkLocationApi`. ## Configuration @@ -105,7 +103,7 @@ Disconnect removes the cached state. ## AUTO resolution -Country code comes from optional `CountryAPI.getCountry(UUID)`. +Country code comes from optional `NetworkLocationApi.countryCode(UUID)`. ### Warm/default evaluation @@ -114,7 +112,7 @@ country in dutch set -> NL otherwise -> fallback ``` -Missing CountryAPI/record yields `UNKNOWN` and therefore fallback. +Missing NetworkLocationApi/record yields `UNKNOWN` and therefore fallback. ### Setting preference to AUTO @@ -156,15 +154,16 @@ Stored effective: `stateCache` is a local concurrent UUID map. -`LanguageAPI` methods: +Public `PlayerLanguageApi` methods use JDK `Locale` values and asynchronous mutation: ```java -Language get(UUID) // effective -Language getPreference(UUID) -void set(UUID, Language) // starts async write and discards result +Optional resolvedLanguage(UUID playerId) +Optional preference(UUID playerId) // empty represents AUTO +CompletionStage setPreference(UUID playerId, Optional language) ``` -`LanguageService.setAsync` is available internally for command confirmation. +`Optional.empty()` selects AUTO. `LanguageService` retains its internal enum and `setAsync` helper for command +confirmation; neither is part of the public API. ### Cache-miss behavior @@ -181,7 +180,7 @@ This is an important distinction between fast hot-path API semantics and durable ## Localization integration -The shared `LocalizationHandler` uses `LanguageAPI` when available to choose audience message files. Effective NL/EN is used; AUTO is never a localizable file code. +The shared `LocalizationHandler` uses `PlayerLanguageApi` when available to choose audience message files. Effective NL/EN is used; AUTO is never a localizable file code. When the API is absent, localization falls back according to framework behavior. PlayerLanguage itself registers no PlaceholderAPI expansion. @@ -210,12 +209,12 @@ All display values are raw enum codes rather than translated language names. - Warm load failure caches a computed default without persisting it and sends no error. - Staff target lookup/update failure is shown as `language.not_found`, conflating unavailable storage with missing identity. - Scheduling a completion failure logs a warning; the source may receive no final response. -- `LanguageAPI.set` is fire-and-forget and provides no caller-visible failure. -- CountryAPI absence/failure represented as empty falls back deterministically. +- `PlayerLanguageApi.setPreference` completes exceptionally when persistence rejects the update. +- NetworkLocationApi absence/failure represented as empty falls back deterministically. -## Lifecycle and limitations +## Lifecycle -Initialization registers command, listener and `LanguageAPI`. Disable has no custom cache clear; listener teardown/feature disposal handles lifecycle, while online map remains in the service object until discarded. +Initialization registers command, listener and `PlayerLanguageApi`. Disable has no custom cache clear; listener teardown/feature disposal handles lifecycle, while online map remains in the service object until discarded. Limitations: @@ -230,29 +229,3 @@ Limitations: - permissions have an effective self+others quirk; - no console command use; - no database update broadcast between proxies beyond shared storage; each proxy cache refreshes independently. - -## Operational verification - -1. Enable without DataRegistry LANGUAGE support and confirm fail-fast initialization. -2. Test first login creation for AUTO and explicit defaults. -3. Test every Dutch country, known non-Dutch and missing CountryAPI case. -4. Compare AUTO write with unknown country versus next-login warm recomputation. -5. Validate `/language`, aliases, self/others permissions and extra arguments. -6. Query an offline player with persisted nondefault language and confirm current cache-miss limitation. -7. Set an offline player's language and confirm DataRegistry update/cache result. -8. Change country/config while online and verify cache does not auto-refresh. -9. Change language during the five-second AUTO message delay and test fast relog stale-message behavior. -10. Simulate DataRegistry load/save/lookup failures and verify fallback/not-found presentation. -11. Test multiple proxies with different local caches over shared persistence. -12. Verify localization message file selection for AUTO/NL/EN. - -## Source reference - -- `features/playerlanguage/PlayerLanguage.java` -- `features/playerlanguage/service/LanguageService.java` -- `features/playerlanguage/listener/LanguageListener.java` -- `features/playerlanguage/command/LanguageCommand.java` -- `features/playerlanguage/command/LanguageCommandPolicy.java` -- `features/playerlanguage/api/LanguageAPI.java` -- `proxyfeatures-api/.../Language.java` -- DataRegistry language APIs diff --git a/docs/features/playerlist.md b/docs/features/playerlist.md index 72d78ba2..d944fee1 100644 --- a/docs/features/playerlist.md +++ b/docs/features/playerlist.md @@ -1,8 +1,6 @@ # PlayerList -> Velocity · Feature ID `playerlist` · disabled by default · per-backend and global visible-player lists - -PlayerList provides `/list` and `/glist` for player-facing network population views. It groups visible players by backend, separates staff by permission, optionally hides configured server rows, and excludes UUIDs reported vanished by `VanishAPI`. +PlayerList provides `/list` and `/glist` for player-facing network population views. It groups visible players by backend, separates staff by permission, optionally hides configured server rows, and excludes UUIDs reported vanished by `PresenceApi`. ## Configuration @@ -13,7 +11,7 @@ PlayerList provides `/list` and `/glist` for player-facing network population vi The blacklist is read once into each command object at initialization. Changes require feature reload. -Matching is case-sensitive and untrimmed after `CastUtils.safeCastToList`. Configure names exactly as Velocity registers them. +Entries are read as strings, matched case-sensitively, and are not trimmed. Configure server names exactly as Velocity registers them. The current server used by bare `/list` is **not** checked against the blacklist, so players on a blacklisted backend can still view that backend's local list. @@ -54,10 +52,10 @@ Shows every registered server not blacklisted. Extra arguments are ignored becau ## Vanish visibility -The handler resolves optional `VanishAPI` once per formatting operation and creates a predicate: +The handler resolves optional `PresenceApi` once per formatting operation and creates a predicate: ```text -visible = VanishAPI absent OR !api.isVanished(uuid) +visible = PresenceApi absent OR !api.isHidden(uuid) ``` When the API is missing, all players are visible. API exceptions are not caught and can fail command rendering. @@ -173,42 +171,3 @@ Initialization: 3. construct/register `/glist` with a separate blacklist snapshot. Disable has no custom cleanup. - -## Operational limitations - -- blacklist does not affect bare current-server `/list`; -- blacklisted server players remain in global headline count; -- exact case-sensitive blacklist; -- no vanish bypass or per-viewer visibility policy; -- missing Vanish fails open; -- global pings block sequentially with a very short 50 ms threshold; -- backend health and player collections are local Velocity observations; -- no pagination/maximum name length; -- row ordering ties are not defined alphabetically; -- `/glist` ignores extra arguments; -- clickable connect relies on an external `/server` command; -- staff grouping can reveal staff permission membership; -- no cross-proxy aggregation beyond players known to this Velocity instance. - -## Operational verification - -1. Run `/list` on current, specified, missing and blacklisted servers. -2. Put a player on a blacklisted server and compare `/glist` headline with visible rows. -3. Test blacklist capitalization differences and config reload requirements. -4. Vanish players/staff and confirm names/counts disappear for all viewers. -5. Disable Vanish and confirm fail-open visibility. -6. Grant/remove `proxyfeatures.feature.playerlist.staff` and verify grouping. -7. Test zero, one and multiple visible player messages. -8. Simulate slow/unreachable backends and measure `/glist` execution time/red bullets. -9. Test many servers to evaluate sequential 50 ms worst case. -10. Move players during rendering and observe snapshot inconsistencies. -11. Verify `/server` button command/permission integration. -12. Test long player lists and client chat line limits. - -## Source reference - -- `features/playerlist/PlayerList.java` -- `features/playerlist/internal/PlayerListHandler.java` -- `features/playerlist/command/ListCommand.java` -- `features/playerlist/command/GlobalListCommand.java` -- `features/vanish/internal/VanishAPI.java` diff --git a/docs/features/proxyinfo.md b/docs/features/proxyinfo.md index 534e6989..51b405e3 100644 --- a/docs/features/proxyinfo.md +++ b/docs/features/proxyinfo.md @@ -1,7 +1,5 @@ # ProxyInfo -> Velocity · Feature ID `proxyinfo` · disabled by default · live JVM/proxy diagnostics - ProxyInfo exposes one synchronous diagnostics command. It reads Velocity, JVM and operating-system metrics directly and sends a fixed list of entries to the command source. ## Configuration @@ -75,30 +73,3 @@ No PlaceholderAPI expansion or public API is registered. Initialization registers the command. Disable has no custom cleanup. `OperatingSystemMXBean` is resolved when the command object is constructed. The implementation assumes the runtime supplies `com.sun.management.OperatingSystemMXBean`; unsupported JVM implementations can fail construction or metric collection. - -## Operational limitations - -- no per-field visibility controls; -- bound address can be sensitive; -- no backend health/load information; -- memory numbers do not represent complete process RSS/native memory; -- CPU sentinel values are not normalized; -- JVM uptime, not plugin/feature uptime; -- labels are hardcoded English; -- snapshots can change between lines during command execution. - -## Operational verification - -1. Run from console and player with/without permission. -2. Add an argument and confirm usage output only. -3. Compare registered-server/player counts with Velocity state. -4. Compare JVM uptime with process monitoring after more than 24 hours. -5. Validate memory values against JVM metrics, understanding native-memory differences. -6. Test CPU output during startup/unsupported metric conditions for negative sentinels. -7. Review whether the configured permission audience may see the bind address. -8. Run on the production JVM distribution used by the proxy to confirm MXBean compatibility. - -## Source reference - -- `features/proxyinfo/ProxyInfo.java` -- `features/proxyinfo/command/ProxyInfoCommand.java` diff --git a/docs/features/queue.md b/docs/features/queue.md index fbbc769c..6b765e1b 100644 --- a/docs/features/queue.md +++ b/docs/features/queue.md @@ -1,7 +1,5 @@ # Queue -> Velocity · Feature ID `Queue` · disabled by default · Capacity-backed priority/FIFO waiting and paced dispatch - Queue is a waiting and dispatch feature, not a capacity authority. It never decides fullness from backend pings or Paper's advertised `maxPlayers`. Capacity hands Queue only an ordinary `FULL` result for a configured queueable target. Queue owns: @@ -31,7 +29,9 @@ Queue declares a hard feature dependency on: Capacity ``` -Queue cannot initialize without the `CapacityAPI` service. There is no ping-capacity fallback. +Queue cannot initialize without Capacity's runtime-only admission port. External plugins use `QueueApi` and +`AdmissionApi`; the private hand-off between Queue and Capacity is deliberately not public. There is no ping-capacity +fallback. ## Configuration @@ -190,6 +190,13 @@ A reconnect before expiry reuses the same entry. Expiry removes both the entry a | `proxyfeatures.feature.queue.command.info` | Staff queue inspection. | | `proxyfeatures.feature.queue.priority.1..3` | Ordering priority. | +## Public `QueueApi` + +The lifecycle-owned `QueueApi` exposes asynchronous `join`/`leave`, player lookup, server enablement, and immutable +queue snapshots. Join requests contain only player, target, and cause; the runtime resolves online state, permission +priority, admission, and bypass policy. Typed results distinguish joined, moved, already queued, offline, disabled, +rejected, and unavailable outcomes. Consumers retain a `CapabilityRef` across reloads, not `QueueManager`. + ## Messages and placeholders | Variable | Meaning | @@ -207,29 +214,3 @@ A reconnect before expiry reuses the same entry. Expiry removes both the entry a Queue state is process-local and in memory. Feature shutdown cancels scheduled work, releases every in-flight lease and clears all state maps. In a future multi-proxy topology, queue ordering must be coordinated together with the distributed Capacity implementation. Independent local queues cannot provide a single globally fair ordering. - -## Verification - -1. Fill an exact, group or gameplay scope and confirm only `NORMAL/FULL` attempts enter Queue. -2. Mix priorities and verify strict priority, then FIFO. -3. Put the first connected entry in backoff and confirm a later connected entry cannot jump it. -4. Disconnect the first entry and confirm an online later entry may progress while grace remains. -5. Free shared capacity across two targets and confirm sorted round-robin progress. -6. Remove reserved permission while waiting and confirm final admission uses current permissions. -7. Change the target to `DRAINING` after lease preparation and confirm connection is denied without position loss. -8. Reload Capacity between preparation and final connect and confirm a new-generation lease is acquired. -9. Force exception, null result and unsuccessful result paths; confirm front requeue and throttled feedback. -10. Disconnect or run `/queue leave` during the dequeue-to-connect transition; confirm no ghost request or leaked lease. -11. Let disconnect grace expire and confirm all admission/retry state disappears. -12. Disable Queue with pending dispatches and confirm tasks and leases are cleaned. - -## Source reference - -- `features/queue/Queue.java` -- `features/queue/QueueManager.java` -- `features/queue/model/ServerQueue.java` -- `features/queue/model/QueueEntry.java` -- `features/queue/command/QueueCommand.java` -- `features/queue/listener/ConnectionListener.java` -- `proxyfeatures-api/.../api/queue/QueueAdmissionAPI.java` -- `proxyfeatures-api/.../api/capacity/CapacityAPI.java` diff --git a/docs/features/resourcepack.md b/docs/features/resourcepack.md index e56b72cf..41d3da1a 100644 --- a/docs/features/resourcepack.md +++ b/docs/features/resourcepack.md @@ -1,7 +1,5 @@ # ResourcePack -> Velocity · Feature ID `resourcepack` · disabled by default · global/per-backend pack transitions with configuration gating - ResourcePack loads pack definitions from `local/resourcepacks.yml`, builds deterministic Velocity `ResourcePackInfo` objects, selects a global or exact backend-specific pack during server transitions, and processes client status events. Resource-pack transitions run exclusively through the Minecraft 1.20.2+ configuration phase and @@ -268,42 +266,3 @@ Initialization: 5. apply current-server state to already-online players. Disable resumes all blocked continuations and clears the continuation map. It does not remove applied packs, clear pack maps/ID history, or send client cleanup. - -## Failure semantics and limitations - -- No automatic retry exists. -- No URL scheme/HTTP reachability validation occurs before `createResourcePackBuilder`. -- Invalid hash uses zero bytes and continues. -- Initial modern transition always chooses global, even when an exact current backend pack exists. -- Switching from a mode pack to a backend without a mode pack removes the old pack and does not offer global. -- Reload alone does not update online clients. -- Send exceptions reject forced-pack configuration, resume optional-pack configuration and log a warning. -- Status `ACCEPTED`/`DOWNLOADED` keep configuration blocked until a matching terminal status or timeout. - -## Operational verification - -1. Test valid global and backend entries with exact SHA-1 hashes. -2. Test missing/invalid hash and confirm zero-hash warning behavior. -3. Join initially with an exact backend pack configured; confirm modern initial policy still offers global. -4. Switch between two configured backend packs; confirm previous removal/current offer. -5. Switch from configured to unconfigured backend; confirm removal and no global fallback. -6. Revisit a pack whose ID is already applied/pending; confirm duplicate suppression. -7. Reload after changing URL, then reapply; confirm old and new IDs are removed before offer. -8. Exercise every status and confirm continuation/message/disconnect policy. -9. Configure `force=false`, decline, and verify configuration continues without disconnecting. -10. Send a stale status after a newer offer; verify it does not unblock the newer continuation. -11. Allow the configuration timeout to expire; verify forced packs disconnect and optional packs continue. -12. Disable during configuration; confirm all continuations resume. -13. Remove a key after reload and note that its historical IDs cannot be manually removed by key. - -## Source reference - -- `features/resourcepack/ResourcePack.java` -- `features/resourcepack/internal/ResourcePackHandler.java` -- `features/resourcepack/listener/PlayerListener.java` -- `features/resourcepack/listener/ResourcePackTransitionPolicy.java` -- `features/resourcepack/listener/ResourcePackOfferPolicy.java` -- `features/resourcepack/listener/ResourcePackStatusListener.java` -- `features/resourcepack/listener/ResourcePackStatusPolicy.java` -- `features/resourcepack/command/ResourcePackCommand.java` -- `features/resourcepack/util/ResourceUtils.java` diff --git a/docs/features/restart.md b/docs/features/restart.md index 6b8a894e..e136f2c2 100644 --- a/docs/features/restart.md +++ b/docs/features/restart.md @@ -1,7 +1,5 @@ # Restart -> Velocity · Feature ID `restart` · disabled by default · proxy shutdown scheduling and backend-restart autoreconnect - Restart contains two related but independent subsystems: 1. a local proxy-restart coordinator for `/proxyrestart`, countdowns, one-off schedules, cancellation, player disconnect and `ProxyServer.shutdown()`; @@ -320,6 +318,10 @@ Autoreconnect variables: ## Lifecycle and failure semantics +The feature publishes read-only `RestartApi`. Capacity uses `isDraining(ServerId)` and +`isExpectedReturn(UUID, ServerId)` without importing Restart implementation classes. Reservation mutation and +snapshot synchronization remain behind runtime-only collaboration ports. + Disable order: 1. close durable subscription, waiting up to five seconds; @@ -339,32 +341,3 @@ Important limitations: - lifecycle delivery uses durable Redis but local candidate/tombstone state is volatile; - player ordering is UUID lexical order, not join order; - messages/status do not expose an exact final-delay remaining value. - -## Operational verification - -1. Test immediate countdown, warning milestones, final delay and shutdown. -2. Cancel during schedule, countdown and final delay; verify old generation tasks never restart later. -3. Force during an active schedule/countdown and confirm immediate shutdown. -4. Test every accepted date/time/weekday form and timezone/DST boundary. -5. Confirm schedules disappear after feature/proxy restart. -6. Confirm players can still join during proxy countdown (current behavior). -7. Publish valid PREPARE before and after backend kick; verify both ordering cases. -8. Route different players to different fallback servers and verify each is tied to their own holding server. -9. Switch/disconnect while waiting and confirm cancellation/opt-out. -10. Publish READY and verify delay, UUID order, interval and retry count. -11. Publish CANCEL before/after PREPARE and verify tombstone protection against redelivery. -12. Use `/autoreconnect cancel` while HOLDING and CONNECTING. -13. Disable Redis or use a bad provider; confirm `/proxyrestart` remains available but autoreconnect is not initialized. -14. Run multiple proxies with distinct and shared groups to verify intended fan-out semantics. - -## Source reference - -- `features/restart/Restart.java` -- `features/restart/internal/RestartHandler.java` -- `features/restart/command/ProxyRestartCommand.java` -- `features/restart/command/RestartScheduleParser.java` -- `features/restart/messaging/RestartLifecycleMessage.java` -- `features/restart/messaging/RestartLifecycleBus.java` -- `features/restart/internal/BackendReconnectManager.java` -- `features/restart/listener/BackendReconnectListener.java` -- `features/restart/command/AutoreconnectCommand.java` diff --git a/docs/features/sanctions.md b/docs/features/sanctions.md index 2608b06c..f5a7fe11 100644 --- a/docs/features/sanctions.md +++ b/docs/features/sanctions.md @@ -1,7 +1,5 @@ # Sanctions -> Velocity · Feature ID `sanctions` · disabled by default · moderation issuance, history and proxy login enforcement - Sanctions stores player/IP bans, mutes, warnings and kicks against canonical DataRegistry player IDs. ProxyFeatures exposes moderation commands, disconnects online targets, rejects active player/IP bans at proxy login, sends staff/Discord notifications and records security events. Mute records are created and cached here, but this repository currently registers no Velocity chat listener that calls `isMuted`; backend chat enforcement is expected from ServerFeatures/shared database state. @@ -230,11 +228,14 @@ Sanction screens/announcements use service-generated values including: List output additionally uses `{player}`, `{mode}`, `{count}`, `{page}`, `{pages}`, `{size}`. -No PlaceholderAPI expansion or public sanctions API is registered. +No PlaceholderAPI expansion is registered. The feature publishes read-only `SanctionsApi`, whose asynchronous +`find(UUID, activeOnly)` returns immutable, persistence-independent `SanctionSnapshot` values. -## Lifecycle and limitations +## Lifecycle -Initialization requires DataRegistry and ORM. It registers nine commands, login listener and repeating expiry sweep. `disable()` is empty; task/listener/ORM cleanup relies on feature lifecycle teardown and the in-memory mute map is not explicitly cleared. +Initialization requires DataRegistry and ORM. It registers nine commands, the login listener, repeating expiry sweep, +and `SanctionsApi`. Capability/task/listener/ORM cleanup is lifecycle-owned; the in-memory mute map is discarded with +the feature instance. Important limitations: @@ -249,32 +250,3 @@ Important limitations: - audit/webhook/broadcast failures are generally independent from core sanction transaction; - issuing a new active same-type sanction deactivates the old one instead of rejecting at service level, though commands pre-check and normally report already active; - reason data and IP/security correlation are sensitive operational data. - -## Operational verification - -1. Test temporary/permanent player bans and permanent permission gates. -2. Test IP literal and hostname normalization, duplicate active IP ban and all matching online disconnects. -3. Deny login by player ban and IP ban; inspect connection/security logs and placeholders. -4. Simulate ORM/DataRegistry outage and confirm fail-closed enforcement-unavailable result. -5. Create/revoke/recreate sanctions and verify history/deactivation semantics. -6. Test duration grammar, overflow and invalid tokens. -7. Issue mute, reconnect and verify backend enforcement separately from proxy cache. -8. Test warn/kick history and online notification/disconnect. -9. Test exempt account online and offline. -10. Exercise every sanction-list form and expiry boundary. -11. Verify sweep and read-time expiry deactivate records/cache. -12. Login from an IP shared with banned/muted accounts; confirm alerts/audits but no automatic denial. -13. Disable webhook and force webhook failure; confirm database/command outcome remains correct. -14. Reload/disable feature and inspect mute cache/lifecycle behavior. - -## Source reference - -- `features/sanctions/Sanctions.java` -- `features/sanctions/entity/SanctionEntity.java` -- `features/sanctions/entity/SanctionType.java` -- `features/sanctions/service/SanctionsService.java` -- `features/sanctions/service/ServiceLookup.java` -- `features/sanctions/service/DiscordService.java` -- `features/sanctions/listener/ConnectListener.java` -- `features/sanctions/command/*Command.java` -- `features/sanctions/audit/SanctionsSecurityAuditLogService.java` diff --git a/docs/features/serverlinks.md b/docs/features/serverlinks.md index 733b6937..63014d03 100644 --- a/docs/features/serverlinks.md +++ b/docs/features/serverlinks.md @@ -1,7 +1,5 @@ # ServerLinks -> Velocity · Feature ID `serverlinks` · disabled by default · fixed client UI link set - ServerLinks replaces a player's Velocity server-link list with seven HauntedMC links. It applies the list to every player already connected when the feature initializes and again after every successful backend connection. ## Configuration @@ -72,31 +70,6 @@ The handler list is not cleared on disable. ## Protocol/client behavior -Velocity exposes server links only to supported client/protocol versions. This feature does not perform its own protocol check, downgrade or fallback chat message. Unsupported clients simply receive whatever behavior Velocity provides for `setServerLinks`. +Velocity exposes server links only to supported client/protocol versions. This feature does not perform its own protocol check or send a chat message for unsupported clients. Unsupported clients simply receive whatever behavior Velocity provides for `setServerLinks`. All URLs are compile-time constants using HTTPS. There is no runtime URL parsing, validation, health check or remote request. - -## Operational limitations - -- Any URL/label change requires a code change and release. -- All players receive the same list. -- The list is reapplied on every backend connection. -- Existing third-party links are replaced, not preserved. -- Disable does not retract already-applied links. -- Client UI ordering is supplied in the list order, though final presentation is client-defined. - -## Operational verification - -1. Enable the feature while players are online; confirm existing players receive all seven links. -2. Join after enable and switch backends; confirm the list is applied after the connection. -3. Verify custom labels and built-in link types in a supported modern client. -4. Test an unsupported/older client and confirm graceful absence rather than errors. -5. Apply a different link set from another plugin before and after this listener to verify last-writer replacement behavior. -6. Disable the feature with players online; confirm no future reapplication and note whether the client retains the prior list. -7. Validate every hardcoded URL in a staging/client environment. - -## Source reference - -- `features/serverlinks/ServerLinks.java` -- `features/serverlinks/internal/ServerLinksHandler.java` -- `features/serverlinks/listener/JoinListener.java` diff --git a/docs/features/slashserver.md b/docs/features/slashserver.md index c767cc9c..5ee66f01 100644 --- a/docs/features/slashserver.md +++ b/docs/features/slashserver.md @@ -1,7 +1,5 @@ # SlashServer -> Velocity · Feature ID `slashserver` · disabled by default · dynamically generated backend-name commands - SlashServer creates one root command per enabled registered Velocity backend. For a backend named `survival`, the generated command is exactly `/survival`. The feature also provides `/slashserver` (`/ss`) to list, inspect, enable and disable those generated commands at runtime. @@ -147,34 +145,3 @@ Feature `disable()` itself is empty. Normal command cleanup therefore depends on | admin state messages | `{server}` | No PlaceholderAPI expansion or public service API is registered. - -## Intrinsics and limitations - -- Command root equals normalized backend name. -- All generated commands share one use permission. -- New backends default enabled. -- Removed backends are deleted from config, not retained as disabled/history. -- No availability cache exists; every command pings before connecting. -- No ping timeout is added by this feature. -- Connection failure without a reason component produces no failure message. -- The command sends success after Velocity reports a successful connection result, despite wording that the player “will be connected.” -- Name-collision behavior is delegated to the feature command manager; this class performs no explicit preflight collision validation. - -## Operational verification - -1. Start with an empty map; confirm every registered backend is added as `true` and receives `/`. -2. Configure one backend false before startup; confirm no root is registered. -3. Add a backend to Velocity and reload/restart; confirm it defaults enabled. -4. Remove a backend; confirm its config entry is deleted. -5. Run generated roots from console and player contexts. -6. Test missing, offline, already-connected, successful and reasoned-failure paths. -7. Disable and re-enable one shorthand through `/ss`; confirm immediate command removal/registration. -8. Test a backend name that collides with another command in a safe staging environment. -9. Disconnect during ping/connect and verify no stale-session side effects. -10. Confirm Maintenance/Queue can still intercept the resulting server connection. - -## Source reference - -- `features/slashserver/SlashServer.java` -- `features/slashserver/command/SlashServerCommand.java` -- `features/slashserver/command/SlashServerAdminCommand.java` diff --git a/docs/features/staffchat.md b/docs/features/staffchat.md index 08e32ce5..be572a95 100644 --- a/docs/features/staffchat.md +++ b/docs/features/staffchat.md @@ -1,7 +1,5 @@ # StaffChat -> Velocity · Feature ID `staffchat` · disabled by default · Redis subscriber and permission-filtered viewer registry - ProxyFeatures StaffChat receives `StaffChatMessage` events from Redis and displays them to locally connected viewers of three configured prefix channels. It also announces staff login, logout and backend switches to the local staff channel. It does **not** register a `/staffchat` command, `/sc` alias, chat-prefix interception or Redis publisher. Message creation/publishing is handled elsewhere, such as ServerFeatures. @@ -82,7 +80,7 @@ ignored. The incoming message is: -1. stripped of legacy formatting codes; +1. stripped of color formatting codes; 2. escaped for MiniMessage; 3. inserted into the channel's localization format; 4. auto-link URL detection enabled; @@ -146,38 +144,3 @@ The Redis subscription is non-durable pub/sub: - subscription recovery behavior is delegated to DataProvider's logical subscription implementation. Disable unsubscribes and waits up to five seconds. It does not explicitly clear channel viewer sets, though listener/feature objects become lifecycle-owned garbage after unload. - -## Operational limitations - -- Redis is a hard dependency for all initialization, including activity announcements. -- No command/publisher exists on Velocity. -- Channel prefix collisions overwrite registry entries. -- Permission gains are not dynamically added until a connection lifecycle/reload. -- Messages are not persisted or audited in a database. -- Pub/sub delivery is at-most-live, not durable. -- Unknown prefixes are silently discarded. -- Sender name/server are trusted from the payload. -- Activity messages cover staff channel only. -- Vanish state is not considered; vanished staff still receive and generate activity announcements according to permissions/events. - -## Operational verification - -1. Start without Redis; confirm the feature logs/degrades and registers no effective listener behavior. -2. Publish valid staff/team/admin payloads and verify exact prefix routing/permissions. -3. Publish null/malformed payloads and an unknown prefix; verify none are delivered. -4. Configure duplicate prefixes and confirm the overwrite behavior in staging. -5. Test legacy/MiniMessage text and URL auto-linking in message bodies. -6. Join, disconnect, relog quickly and switch servers as staff; verify generation-style revalidation. -7. Remove permission while online; confirm existing viewer no longer receives. Grant permission while online; confirm they are not added until reconnect/reload. -8. Interrupt Redis pub/sub and verify missed messages are not replayed. -9. Disable the feature and confirm unsubscribe completes or warns after timeout. -10. Confirm the actual publishing side uses channel `proxy.staffchat.message`, type `staffchat`, and configured prefix values. - -## Source reference - -- `features/staffchat/StaffChat.java` -- `features/staffchat/internal/ChatChannelHandler.java` -- `features/staffchat/internal/ChatChannel.java` -- `features/staffchat/internal/messaging/EventBusHandler.java` -- `features/staffchat/listener/ConnectListener.java` -- `proxyfeatures-contracts/.../StaffChatMessage.java` diff --git a/docs/features/textcommands.md b/docs/features/textcommands.md index 75a91e0a..d505a6dc 100644 --- a/docs/features/textcommands.md +++ b/docs/features/textcommands.md @@ -1,7 +1,5 @@ # TextCommands -> Velocity · Feature ID `textcommands` · disabled by default · configuration-defined player-only message commands - TextCommands turns each valid entry under `commands` into one Velocity root command. Running that root sends one localized message with a fixed configured placeholder map. It is intentionally simple: there are no aliases, permissions, arguments, console support, per-server rules or runtime reload command. @@ -122,34 +120,3 @@ placeholders: provides `{url}` and `{label}`. No PlaceholderAPI expansion or public API is registered. - -## Operational limitations - -- player-only; -- universally permitted; -- one message per root; -- arguments ignored; -- no aliases; -- no per-command configuration beyond message key/static placeholders; -- no runtime add/remove/reload command; -- no explicit name/collision validation; -- unordered registration due to `HashMap`; -- malformed/missing localization keys follow localization-handler behavior; -- trusted formatting/click content can be dangerous when misconfigured. - -## Operational verification - -1. Configure valid roots and verify exact command registration/output. -2. Test console and extra arguments. -3. Omit/blank `message-key` and confirm skip warning. -4. Configure an empty command map and confirm feature remains loaded with warning. -5. Test clickable URL/hover formatting and unsafe malformed values in staging. -6. Try invalid names, whitespace, uppercase and command collisions to understand command-manager behavior. -7. Verify all players can execute every root regardless of permission system. -8. Change config/localization and verify reload requirements. -9. Disable/re-enable and confirm lifecycle-owned command cleanup. - -## Source reference - -- `features/textcommands/TextCommands.java` -- `features/textcommands/command/TextCommand.java` diff --git a/docs/features/twofactor.md b/docs/features/twofactor.md index 6d32f8d1..e27adf61 100644 --- a/docs/features/twofactor.md +++ b/docs/features/twofactor.md @@ -1,7 +1,5 @@ # TwoFactor -> Velocity · Feature ID `twofactor` · disabled by default · mandatory TOTP gate for permission-marked accounts - TwoFactor protects privileged accounts with RFC 6238-style TOTP. Accounts holding the required permission must either enroll or authenticate before they may leave a dedicated lock backend or use configured proxy-side actions. The secure operating model depends on a registered, heavily restricted lock server. Velocity cannot freeze movement or backend-native behavior on arbitrary gameplay servers, so the feature redirects locked players and fails closed when configured to do so. @@ -228,7 +226,7 @@ Operational consequences: - both master key and salt file are required to decrypt existing accounts; - changing/losing either makes stored secrets unusable; - backup/restore must preserve the salt securely alongside the database while keeping the master key separately controlled; -- there is no key rotation/migration facility; +- key rotation is unavailable; - decryption failure propagates as an operational error rather than automatically resetting accounts. ## Lock-state calculation @@ -293,7 +291,7 @@ Available command roots are removed in place except allowed aliases. Tab-complet These controls can reveal `/2fa` and explicitly allowed roots only, but backend/client cached commands and non-command protocol behavior remain outside this feature. -## Verification failures and cooldown +## Failed verification and cooldown Only failures while the session is currently locked increment `failedAttempts`. @@ -307,7 +305,7 @@ Cooldown survives reconnect on the same running proxy but not feature/proxy rest The cooldown map is pruned opportunistically on service operations, not by a scheduled sweep. -## Trusted-login caveats +## Trusted login - IP comparison is exact string equality after remote-address normalization. - Trusted IP is stored in plaintext in the account row. @@ -329,54 +327,5 @@ The cooldown map is pruned opportunistically on service operations, not by a sch | `{seconds}` | Remaining cooldown. | | `{target}` | Reset target identifier. | -No PlaceholderAPI expansion or public TwoFactor API is registered. - -## Lifecycle and operational limitations - -Initialization applies lock state to players already online and starts trust-expiry monitors. Post-login initially creates/recomputes session, then waits for DataRegistry identity readiness to audit/log/refresh trust monitor. - -Disable cancels trust-expiry tasks. It does not explicitly clear service sessions, cooldowns, transfer set or decrypted crypto key; object disposal relies on feature lifecycle/GC. It also does not move/unlock currently held players or delete accounts. - -Additional limitations: - -- no setup expiry, recovery code or user disable flow; -- no QR code; -- master-key rotation unsupported; -- cooldown is local and volatile across multiple proxies/restarts; -- trust state is shared in DB, but session/cooldown/return-server state is local; -- code acceptance depends on proxy clock accuracy; -- return-server reconnect is fire-and-forget; -- lock-server availability is checked only as registered-server presence at initialization, not ping health; -- plugin-message/chat/command blocking cannot replace a hardened lock backend; -- staff reset is the only implemented recovery path. - -## Operational verification - -1. Attempt enable without key, short key, blank lock server and unregistered lock server; verify initialization fails. -2. Enroll a required account, inspect `PENDING`, verify code and inspect `ENABLED` encrypted row. -3. Reuse the same TOTP step and verify replay rejection. -4. Test drift window boundaries and synchronized/incorrect proxy clock. -5. Trigger failure limit, reconnect during cooldown and restart proxy to verify volatile cooldown semantics. -6. Test same-IP trust, changed IP, trust expiry task and `duration_days=0`. -7. Verify every locked action toggle: command, chat, plugin message, command tree, tab completion and server switch. -8. Add an allowed command only after confirming it is safe unauthenticated. -9. Test initial redirect, failed transfer, lock-server kick and post-connect forced return. -10. Set `block_server_switch=false`; confirm post-connect still returns player to lock server. -11. Authenticate and verify original-server return, including missing/offline target behavior. -12. Reset required and non-required online/offline accounts; inspect audits and lock transitions. -13. Run concurrent verification of the same code from multiple proxy paths and verify optimistic replay handling. -14. Back up/restore DB plus salt with the same master key; then test loss/change scenarios in staging. -15. Disable/reload while players are locked and inspect lifecycle behavior. - -## Source reference - -- `features/twofactor/TwoFactor.java` -- `features/twofactor/config/TwoFactorConfig.java` -- `features/twofactor/service/TwoFactorService.java` -- `features/twofactor/listener/TwoFactorListener.java` -- `features/twofactor/command/TwoFactorCommand.java` -- `features/twofactor/crypto/TwoFactorCrypto.java` -- `features/twofactor/crypto/TotpService.java` -- `features/twofactor/persistence/PlayerTwoFactorEntity.java` -- `features/twofactor/persistence/OrmTwoFactorAccountStore.java` -- `features/twofactor/audit/TwoFactorAuditLogService.java` +No PlaceholderAPI expansion is registered. The read-only `TwoFactorApi` publishes player lock and authentication +server checks for admission/security integrations without exposing accounts, secrets, or persistence. diff --git a/docs/features/vanish.md b/docs/features/vanish.md index 05605fa2..133f3cda 100644 --- a/docs/features/vanish.md +++ b/docs/features/vanish.md @@ -1,10 +1,8 @@ # Vanish -> Velocity · Feature ID `vanish` · disabled by default · local online-state mirror fed by durable backend updates - ProxyFeatures Vanish does not provide a `/vanish` command and does not persist authoritative vanish state. ServerFeatures publishes state transitions; this feature consumes them into a proxy-local registry containing only players who are currently online on this proxy and vanished according to the newest accepted revision. -Other ProxyFeatures modules consume the registered `VanishAPI` instead of inferring visibility from connection presence. +Other ProxyFeatures modules consume the registered `PresenceApi` instead of inferring visibility from connection presence. ## Configuration @@ -29,7 +27,7 @@ The feature warns when the default group is used because every proxy that must r Initialization order: 1. create `VanishRegistry`; -2. create/register `VanishAPI` as a feature service; +2. create/register `PresenceApi` as a feature service; 3. attempt to register Redis messaging provider `redis`; 4. when available, start durable consumption; 5. register disconnect cleanup listener. @@ -153,25 +151,23 @@ Registry methods return snapshots derived from current Velocity players: Names stored in the map are convenience metadata; API list/count operations use UUID/player objects. -## Public `VanishAPI` +## Public `PresenceApi` -Registered service class: +Registered public contract: ```java -nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI +nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi ``` Methods: ```java -int getAdjustedPlayerCount() -List getAdjustedOnlinePlayers() -List getVanishedPlayers() -int getVanishedCount() -boolean isVanished(UUID uuid) +boolean isHidden(UUID playerId) +PresenceSnapshot snapshot() ``` -Returned lists are snapshots, not live collections. `isVanished` is true only for a UUID currently present in the local online map. +`PresenceSnapshot` contains immutable online/hidden UUID sets, derived visible players, counts, and an observation +timestamp. `isHidden` is true only for a UUID currently present in the local online map. Known consumers include MOTD, Friends, Messenger, PlayerList and PlayerCount. @@ -203,7 +199,7 @@ ServerFeatures is expected to own player/staff vanish commands and publish state The source tree contains tab-completion filtering classes, but `Vanish.initialize()` currently registers only `ConnectListener`; tab-completion filtering is therefore not active through this feature entry point. -## Lifecycle and limitations +## Lifecycle Disable: @@ -224,29 +220,3 @@ Important limitations: - application succeeds before asynchronous acknowledgement, so redelivery is expected and safely rejected by revision; - missing Redis means stale/empty state can make vanished players visible to consumers; - no tab suggestion filtering is currently registered here. - -## Operational verification - -1. Publish a valid vanished update for an online player; verify API count/list/isVanished and one transition event. -2. Publish the same state with a newer revision; verify registry revision advances without another transition event. -3. Publish the same or lower revision; verify it is ignored. -4. Publish an unvanish with a higher revision; verify removal and one visible transition event. -5. Publish while the player is offline, then log in without a newer update; verify the player is not considered vanished under current semantics. -6. Disconnect a vanished player; verify online entry clears but delayed lower revisions remain rejected and no transition event is fired. -7. Hot-reload the feature; verify online state/revisions restore without transition events. -8. Restart the proxy process; verify no state exists until durable updates arrive. -9. Run two proxies with distinct groups and verify both receive updates; repeat with one shared group to demonstrate load balancing. -10. Disable Redis and verify the API remains available but does not update. -11. Test null/invalid UUID/key/revision payloads and verify acknowledgement/discard behavior. -12. Vanish and unvanish a publicly online friend; verify exactly one offline and one online friend notification. -13. Confirm every visibility-aware consumer uses `VanishAPI` or the applied transition event and handles degraded state intentionally. - -## Source reference - -- `features/vanish/Vanish.java` -- `features/vanish/event/VanishStateChangeEvent.java` -- `features/vanish/internal/VanishRegistry.java` -- `features/vanish/internal/VanishAPI.java` -- `features/vanish/internal/messaging/EventBusHandler.java` -- `features/vanish/listener/ConnectListener.java` -- `proxyfeatures-contracts/.../VanishStateMessage.java` diff --git a/docs/features/versioncheck.md b/docs/features/versioncheck.md index a2ab0bb3..8f1b188c 100644 --- a/docs/features/versioncheck.md +++ b/docs/features/versioncheck.md @@ -1,7 +1,5 @@ # VersionCheck -> Velocity · Feature ID `versioncheck` · disabled by default · minimum Minecraft client protocol enforcement - VersionCheck rejects proxy logins whose negotiated Minecraft protocol integer is lower than a configured minimum. It is not a plugin/update checker and performs no remote release lookup. It also exposes the minimum/friendly name to MOTD so unsupported server-list pings can show a compatible-version label. @@ -14,11 +12,7 @@ It also exposes the minimum/friendly name to MOTD so unsupported server-list pin | `minimum_protocol_version` | `763` | Lowest accepted Velocity protocol integer. | | `friendly_protocol_name` | `1.21` | Human-facing minimum version label in denial/MOTD. Blank becomes `unsupported`. | -The numeric threshold is read without validation or clamping. - -- negative/zero values effectively allow every normal protocol; -- an accidentally high value rejects every lower client; -- the friendly name is not checked against the protocol integer. +The numeric threshold must be zero or greater. A negative value fails feature initialization with a precise configuration error instead of silently changing admission behavior. Zero permits every normal protocol; an accidentally high value still rejects every lower client. The friendly name is not inferred from or checked against the protocol integer. Both values are captured when `VersionHandler` is constructed. Config changes require feature reload. @@ -39,16 +33,11 @@ client protocol < minimum -> denied client protocol >= minimum -> allowed ``` -Only a lower bound exists. Newer client protocols are permitted as far as this feature is concerned; actual Velocity/backend compatibility remains external. +Only a lower bound exists. Protocols at or above the configured minimum pass this feature's check; Velocity and the backend determine end-to-end compatibility. ### Denial component -A denied player receives one component joined with a space: - -```text -red literal “Verbinding verbroken:” -localized versioncheck.unsupported_version -``` +A denied player receives the fully localized `versioncheck.unsupported_version` component. No language-specific prefix is hardcoded in the runtime. The localized message receives: @@ -56,8 +45,6 @@ The localized message receives: {friendly_protocol_name} ``` -The literal prefix is hardcoded Dutch rather than localization-configurable. - The feature sets `LoginEvent.ComponentResult.denied`; later listeners can still interact according to Velocity event ordering/result rules. ## Audit logging @@ -120,7 +107,7 @@ Allowed clients are only version-audited, not written as denied connection logs. ## MOTD integration -When MOTD is loaded, it accesses `VersionHandler` directly. +When MOTD is loaded, it resolves the public `VersionApi` capability. For an incoming server-list ping protocol below the minimum, MOTD replaces the displayed ping version with: @@ -131,19 +118,19 @@ name = friendly_protocol_name + "+" This is presentation only. The actual login event independently enforces the numeric threshold. -Because MOTD checks whether the `VersionCheck` feature is loaded, disabling VersionCheck removes both enforcement and this display override. +Because MOTD resolves the optional `VersionApi` capability, disabling VersionCheck removes both enforcement and this display override without a direct implementation dependency. ## Public/internal methods -`VersionHandler` exposes: +The registered `VersionApi` exposes: ```java -boolean isUnsupportedVersion(int protocolVersion) -int getMinimumProtcolVersion() -String getFriendlyProtocolName() +int minimumProtocolVersion() +String minimumVersionName() +boolean isSupported(int protocolVersion) ``` -Note the source method name contains the typo `Protcol`. This is an internal concrete-class integration, not a registered public service API. +Consumers resolve this contract through `ProxyFeaturesApi.capabilities()` and do not depend on `VersionHandler`. ## Commands, permissions and PAPI @@ -175,59 +162,9 @@ Initialization: 1. attempt optional ORM context; 2. require DataRegistry and construct player resolver; 3. construct audit service; -4. construct immutable version handler; +4. validate the minimum protocol and construct the immutable version handler; 5. register login listener. Disable has no custom cleanup. No cache, remote task or scheduler exists. - -## Operational limitations - -- threshold/friendly name can be inconsistent; -- lower-bound only, no maximum/supported-range matrix; -- no per-server/client-brand exceptions; -- no bypass permission; -- hardcoded Dutch denial prefix; -- audit stores IP for every allowed/denied login; -- DataRegistry required even when DB logging is unavailable; -- audit retention/deletion is not configured here; -- invalid threshold is not validated; -- newer protocol allowed here may still fail elsewhere; -- direct concrete integration with MOTD rather than a formal service; -- feature name “VersionCheck” can be mistaken for software update checking. - -## Protocol configuration guidance - -Use Velocity's protocol constants/documentation/tests when changing the integer. Do not infer protocol numbers from display version strings. - -Before raising the minimum: - -1. confirm all intended client versions' protocol IDs; -2. ensure backend/ViaVersion compatibility; -3. update `friendly_protocol_name` consistently; -4. test MOTD and login paths separately; -5. review audit volume and privacy retention. - -## Operational verification - -1. Test protocol exactly below, equal to and above the configured minimum. -2. Set a blank friendly name and verify `unsupported` fallback. -3. Intentionally mismatch friendly label and threshold to confirm no validation. -4. Test negative/very high thresholds in staging. -5. Disable ORM and verify enforcement continues with warning/no audit rows. -6. Disable DataRegistry and confirm current fail-fast initialization. -7. Inspect allowed/denied audit rows, including normalized IP/protocol fields. -8. Confirm denied structured connection logs include minimum metadata. -9. Ping with unsupported/supported protocols while MOTD is loaded and compare displayed version. -10. Disable VersionCheck and confirm both login gate and MOTD version override disappear. -11. Test interaction/order with other priority-10/async login listeners such as sanctions/maintenance. - -## Source reference - -- `features/versioncheck/VersionCheck.java` -- `features/versioncheck/internal/VersionHandler.java` -- `features/versioncheck/listener/ConnectionListener.java` -- `features/versioncheck/audit/VersionAuditLogService.java` -- `features/versioncheck/audit/PlayerVersionLogEntity.java` -- `features/motd/internal/MotdHandler.java` diff --git a/docs/features/votifier-diagnostics.md b/docs/features/votifier-diagnostics.md index cf94b4b4..6d392d96 100644 --- a/docs/features/votifier-diagnostics.md +++ b/docs/features/votifier-diagnostics.md @@ -1,8 +1,6 @@ # Votifier Diagnostics and Test Votes -> ProxyFeatures Votifier 1.6.0 · structured delivery logs · targeted backend test injection - -Votifier now exposes each important producer-side transition in the console and supports testing one reward backend without duplicating the test across the whole network. +Votifier reports each important producer-side transition in the console and supports testing one reward backend without duplicating the test across the whole network. ## Logging configuration @@ -74,12 +72,3 @@ A targeted test: The command reports success only after Redis accepts the selected durable stream event. It reports unknown targets, invalid input and exhausted publication failures back to the command sender. Because the event is durable, the selected ServerFeatures consumer may process it later when that consumer is temporarily offline. This targeted mode is intended for backend reward-pipeline testing; use the two-argument test to verify proxy-side offline detection and local outbox replay. - -## Recommended verification - -1. Enable routine delivery logs and leave debug logging off. -2. Run a two-argument test while every backend is online; verify one stream-forwarding log per configured target and an empty pending snapshot. -3. Stop one backend and run the same test; verify the online streams still forward and the stopped backend appears in the pending snapshot. -4. Restore the backend and verify the queued replay log. -5. Run the three-argument command for one backend and confirm only that backend receives a reward. -6. Temporarily enable `debug_delivery` when investigating routing or retry timing, then disable it after diagnosis. diff --git a/docs/features/votifier-reliable-delivery.md b/docs/features/votifier-reliable-delivery.md index 8dda4d10..dbd2b02a 100644 --- a/docs/features/votifier-reliable-delivery.md +++ b/docs/features/votifier-reliable-delivery.md @@ -1,8 +1,6 @@ # Votifier Reliable Backend Delivery -> ProxyFeatures Votifier 1.5.0 · persistent per-backend outbox · maximum 24-hour retention · paced recovery - -ProxyFeatures Votifier always delivers votes through a persistent per-backend outbox. There is no shared broadcast stream, legacy channel, compatibility mode, mode selector, dual publication, or empty-target fallback. +ProxyFeatures Votifier delivers votes through a persistent per-backend outbox. Each configured reward backend receives an independent delivery obligation on its private durable stream. The proxy validates and records an incoming vote once, then tracks one independent delivery obligation for every configured reward backend. @@ -172,7 +170,7 @@ No partial records are trusted. The queue is quarantined with a `.corrupt- Velocity · Feature ID `votifier` · version 1.6.0 · disabled by default · RSA Votifier v1 ingress · mandatory durable per-backend delivery - -ProxyFeatures Votifier accepts legacy RSA Votifier v1 packets, validates and optionally records the vote once, then creates one durable delivery obligation for every configured reward backend. It also provides vote links, monthly statistics, reminders, operational diagnostics and full-network or single-backend test votes. +ProxyFeatures Votifier accepts RSA Votifier v1 packets, validates and optionally records the vote once, then creates one durable delivery obligation for every configured reward backend. It also provides vote links, monthly statistics, reminders, operational diagnostics and full-network or single-backend test votes. Detailed delivery and diagnostic references: @@ -11,7 +9,7 @@ Detailed delivery and diagnostic references: ## Required configuration -Votifier requires a durable Redis messaging provider and a nonempty list of exact registered Velocity backend names. Invalid or missing delivery configuration prevents the feature from starting; there is no broadcast fallback or legacy transport. +Votifier requires a durable Redis messaging provider and a nonempty list of exact registered Velocity backend names. Invalid or missing delivery configuration prevents the feature from starting. ```yaml Votifier: @@ -92,7 +90,7 @@ VOTE ``` -Votifier v2 token/HMAC framing is not implemented. The v1 sender receives no end-to-end reward acknowledgement. +The sender receives no end-to-end reward acknowledgement. ### Socket limits @@ -275,4 +273,4 @@ Disable: 4. close the persistent delivery manager; 5. stop the worker. -There is no legacy transport or backwards-compatible delivery mode. +Delivery uses the durable per-backend outbox described in [Reliable backend delivery](votifier-reliable-delivery.md). diff --git a/pom.xml b/pom.xml index 27e0865c..d6f9b86a 100644 --- a/pom.xml +++ b/pom.xml @@ -58,12 +58,13 @@ proxyfeatures-testkit proxyfeatures-api + proxyfeatures-toolkit proxyfeatures-contracts proxyfeatures-platform-velocity - 3.2.0 + 3.3.0 UTF-8 UTF-8 2026-07-26T00:00:00Z @@ -83,7 +84,6 @@ 1.0.0-SNAPSHOT - 4.8.184 3.2.4 4.2.0 5.5 diff --git a/proxyfeatures-api/pom.xml b/proxyfeatures-api/pom.xml index 55d3f56e..ad804029 100644 --- a/proxyfeatures-api/pom.xml +++ b/proxyfeatures-api/pom.xml @@ -9,99 +9,15 @@ proxyfeatures-api ProxyFeatures API - 0.95 + 0.80 - - net.kyori - adventure-api - provided - - - net.kyori - adventure-key - provided - - - net.kyori - adventure-text-minimessage - provided - - - net.kyori - adventure-text-serializer-gson - provided - - - net.kyori - adventure-text-serializer-legacy - provided - - - net.kyori - adventure-text-serializer-plain - provided - - - net.kyori - adventure-text-logger-slf4j - provided - - - com.velocitypowered - velocity-api - ${velocity.version} - provided - - - com.velocitypowered - velocity-brigadier - ${velocity.brigadier.version} - provided - - - org.slf4j - slf4j-api - provided - - - org.spongepowered - configurate-yaml - ${configurate.version} - - - org.spongepowered - configurate-core - ${configurate.version} - - - com.google.code.gson - gson - - - org.jetbrains - annotations - ${jetbrains.annotations.version} - provided - org.junit.jupiter junit-jupiter ${junit.version} test - - ${project.groupId} - proxyfeatures-testkit - ${project.version} - test - - - org.mockito - mockito-core - ${mockito.version} - test - diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ApiFailureCode.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ApiFailureCode.java new file mode 100644 index 00000000..ff6effa5 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ApiFailureCode.java @@ -0,0 +1,7 @@ +package nl.hauntedmc.proxyfeatures.api; + +/** Stable machine-readable reasons for failed public API operations. */ +public enum ApiFailureCode { + FEATURE_UNAVAILABLE, PROVIDER_RELOADED, REQUEST_INVALID, PLAYER_OFFLINE, + PERSISTENCE_UNAVAILABLE, TIMEOUT, CANCELLED, PERMISSION_DENIED, INTERNAL_FAILURE +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ApiOperationException.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ApiOperationException.java new file mode 100644 index 00000000..fac5e749 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ApiOperationException.java @@ -0,0 +1,18 @@ +package nl.hauntedmc.proxyfeatures.api; + +import java.util.Objects; + +/** Typed exception used to complete public asynchronous operations exceptionally. */ +public final class ApiOperationException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final ApiFailureCode code; + public ApiOperationException(ApiFailureCode code, String message) { + super(message); + this.code = Objects.requireNonNull(code, "code"); + } + public ApiOperationException(ApiFailureCode code, String message, Throwable cause) { + super(message, cause); + this.code = Objects.requireNonNull(code, "code"); + } + public ApiFailureCode code() { return code; } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/AsyncContract.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/AsyncContract.java new file mode 100644 index 00000000..11727fbb --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/AsyncContract.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.proxyfeatures.api; + +/** + * Shared contract for public API methods. Synchronous queries are thread-safe and must not do + * blocking I/O. Completion stages may finish on a provider-managed worker or a Velocity thread, + * so callbacks must be non-blocking and integrations must choose their own executor for work. + * Cancellation is best-effort and does not cancel already-submitted persistence work. Providers + * complete outstanding operations exceptionally with a documented API failure when they unload; + * callers are responsible for their own timeouts. + */ +public final class AsyncContract { + private AsyncContract() { } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesApi.java new file mode 100644 index 00000000..3a9f9fea --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesApi.java @@ -0,0 +1,36 @@ +package nl.hauntedmc.proxyfeatures.api; + +import nl.hauntedmc.proxyfeatures.api.feature.FeatureCatalog; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRegistry; + +import java.util.concurrent.CompletionStage; + +/** + * Stable entry point published by the running ProxyFeatures platform. + * + *

The entry point remains valid for the lifetime of the plugin. Feature reloads change the + * availability reported by {@link #capabilities()} and {@link #features()}, not this object.

+ * + *

See {@link AsyncContract} for threading, callback, cancellation, timeout, and unload rules + * that apply to all public capabilities.

+ */ +public interface ProxyFeaturesApi { + + /** Returns the version of the API and runtime implementation. */ + ProxyFeaturesApiVersion version(); + + /** Current root-runtime lifecycle state. */ + RuntimeState state(); + + /** + * Completes once the initial feature graph is ready. It completes exceptionally if startup + * cannot produce a usable graph; callers must not block Velocity event threads on it. + */ + CompletionStage whenReady(); + + /** Returns the live, read-only capability catalog. */ + CapabilityRegistry capabilities(); + + /** Returns the live, read-only feature catalog. */ + FeatureCatalog features(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesApiVersion.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesApiVersion.java new file mode 100644 index 00000000..6570a2be --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesApiVersion.java @@ -0,0 +1,28 @@ +package nl.hauntedmc.proxyfeatures.api; + +import java.util.Objects; + +/** Semantic API and implementation versions exposed by a running platform. */ +public record ProxyFeaturesApiVersion(String apiVersion, String implementationVersion) { + + public static final String CURRENT = "3.3.0"; + + public ProxyFeaturesApiVersion { + apiVersion = requireVersion(apiVersion, "apiVersion"); + implementationVersion = requireVersion(implementationVersion, "implementationVersion"); + } + + /** Creates a version pair for the current API. */ + public static ProxyFeaturesApiVersion current(String implementationVersion) { + return new ProxyFeaturesApiVersion(CURRENT, implementationVersion); + } + + private static String requireVersion(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesContext.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesContext.java deleted file mode 100644 index 25fad920..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/ProxyFeaturesContext.java +++ /dev/null @@ -1,22 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api; - -import org.slf4j.Logger; - -import java.nio.file.Path; - -/** - * Minimal host contract required by reusable ProxyFeatures API utilities. - * - *

Keeping this contract in the API module prevents general configuration and resource helpers - * from depending on the Velocity plugin entry point.

- */ -public interface ProxyFeaturesContext { - - Path getDataDirectory(); - - Logger getLogger(); - - default ClassLoader getResourceClassLoader() { - return getClass().getClassLoader(); - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/RuntimeState.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/RuntimeState.java new file mode 100644 index 00000000..8ad37f10 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/RuntimeState.java @@ -0,0 +1,11 @@ +package nl.hauntedmc.proxyfeatures.api; + +/** Lifecycle state of the ProxyFeatures runtime as a whole. */ +public enum RuntimeState { + STARTING, + READY, + RELOADING, + DEGRADED, + STOPPING, + STOPPED +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionApi.java new file mode 100644 index 00000000..e513a5c2 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionApi.java @@ -0,0 +1,11 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +/** Public admission-control capability. Implementations derive all trusted policy inputs. */ +public interface AdmissionApi { + + /** Evaluates and reserves one admission attempt atomically. */ + AdmissionDecision tryAcquire(AdmissionRequest request); + + /** Returns an immutable operational snapshot. */ + AdmissionSnapshot snapshot(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionDecision.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionDecision.java new file mode 100644 index 00000000..82ef27e6 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionDecision.java @@ -0,0 +1,41 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +import java.util.Objects; +import java.util.Optional; + +/** Typed result of an atomic admission attempt. */ +public record AdmissionDecision( + AdmissionDenialReason denialReason, + Optional blockingPolicy, + Optional lease +) { + public AdmissionDecision { + denialReason = Objects.requireNonNull(denialReason, "denialReason"); + blockingPolicy = blockingPolicy == null + ? Optional.empty() + : blockingPolicy.map(String::trim).filter(value -> !value.isEmpty()); + lease = lease == null ? Optional.empty() : lease; + if ((denialReason == AdmissionDenialReason.NONE) != lease.isPresent()) { + throw new IllegalArgumentException("Only allowed decisions contain a lease"); + } + } + + public static AdmissionDecision allow(AdmissionLease lease) { + return new AdmissionDecision( + AdmissionDenialReason.NONE, + Optional.empty(), + Optional.of(Objects.requireNonNull(lease, "lease")) + ); + } + + public static AdmissionDecision deny(AdmissionDenialReason reason, String blockingPolicy) { + if (reason == AdmissionDenialReason.NONE) { + throw new IllegalArgumentException("A denial requires a denial reason"); + } + return new AdmissionDecision(reason, Optional.ofNullable(blockingPolicy), Optional.empty()); + } + + public boolean allowed() { + return denialReason == AdmissionDenialReason.NONE; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionDenialReason.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionDenialReason.java new file mode 100644 index 00000000..977bc7a5 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionDenialReason.java @@ -0,0 +1,16 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +/** Stable reason for an expected admission rejection. */ +public enum AdmissionDenialReason { + NONE, + CAPACITY, + SERVER_STATE, + MAINTENANCE, + RESTART, + TWO_FACTOR, + PROXY_HARD_LIMIT, + PLAYER_OFFLINE, + UNKNOWN_TARGET, + INVALID_REQUEST, + INTERNAL_ERROR +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionIntent.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionIntent.java new file mode 100644 index 00000000..49598a5d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionIntent.java @@ -0,0 +1,21 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +/** Describes the purpose of an admission request without granting any bypass privileges. */ +public enum AdmissionIntent { + NORMAL(true), + QUEUE_ADVANCE(false), + RESTART_RETURN(false), + MAINTENANCE_EVACUATION(false), + SECURITY_ROUTE(false), + PLUGIN(false); + + private final boolean queueable; + + AdmissionIntent(boolean queueable) { + this.queueable = queueable; + } + + public boolean isQueueable() { + return queueable; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionLease.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionLease.java new file mode 100644 index 00000000..daa3b214 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionLease.java @@ -0,0 +1,34 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.UUID; + +/** Short-lived, idempotent reservation for one exact backend connection. */ +public interface AdmissionLease extends AutoCloseable { + UUID id(); + + UUID playerId(); + + ServerId targetServer(); + + AdmissionIntent intent(); + + Instant expiresAt(); + + boolean isActive(); + + LeaseState state(); + + long providerGeneration(); + + LeaseTerminalResult commit(); + + LeaseTerminalResult release(); + + @Override + default void close() { + release(); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionRequest.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionRequest.java new file mode 100644 index 00000000..162d5d22 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionRequest.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** Untrusted request to evaluate one player connection to a backend. */ +public record AdmissionRequest( + UUID playerId, + Optional previousServer, + ServerId targetServer, + AdmissionIntent intent +) { + public AdmissionRequest { + Objects.requireNonNull(playerId, "playerId"); + previousServer = previousServer == null ? Optional.empty() : previousServer; + Objects.requireNonNull(targetServer, "targetServer"); + intent = intent == null ? AdmissionIntent.PLUGIN : intent; + } + + public static AdmissionRequest normal(UUID playerId, ServerId targetServer) { + return new AdmissionRequest(playerId, Optional.empty(), targetServer, AdmissionIntent.NORMAL); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionScopeSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionScopeSnapshot.java new file mode 100644 index 00000000..44a8969c --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionScopeSnapshot.java @@ -0,0 +1,39 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +import java.util.Objects; + +/** Immutable operational view of one configured admission scope. */ +public record AdmissionScopeSnapshot( + String name, + int capacity, + int reservedSlots, + int occupied, + int pending, + int restorationReserved, + AdmissionState state +) { + public AdmissionScopeSnapshot { + name = Objects.requireNonNull(name, "name").trim(); + if (name.isEmpty()) { + throw new IllegalArgumentException("name must not be blank"); + } + capacity = Math.max(0, capacity); + reservedSlots = Math.max(0, reservedSlots); + occupied = Math.max(0, occupied); + pending = Math.max(0, pending); + restorationReserved = Math.max(0, restorationReserved); + state = Objects.requireNonNull(state, "state"); + } + + public int effectiveUsed() { + return occupied + pending + restorationReserved; + } + + public int normalAvailable() { + return Math.max(0, capacity - reservedSlots - effectiveUsed()); + } + + public int absoluteAvailable() { + return Math.max(0, capacity - effectiveUsed()); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionSnapshot.java new file mode 100644 index 00000000..116fac31 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionSnapshot.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** Point-in-time operational admission snapshot. */ +public record AdmissionSnapshot( + AdmissionScopeSnapshot proxy, + AdmissionScopeSnapshot gameplay, + Map groups, + Map servers, + int activeLeases, + Instant observedAt +) { + public AdmissionSnapshot { + Objects.requireNonNull(proxy, "proxy"); + Objects.requireNonNull(gameplay, "gameplay"); + groups = groups == null ? Map.of() : Map.copyOf(groups); + servers = servers == null ? Map.of() : Map.copyOf(servers); + activeLeases = Math.max(0, activeLeases); + observedAt = Objects.requireNonNull(observedAt, "observedAt"); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionState.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionState.java new file mode 100644 index 00000000..f7a71692 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/AdmissionState.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +/** Runtime admission state of a backend. */ +public enum AdmissionState { + OPEN, + DRAINING, + CLOSED, + OFFLINE; + + public boolean acceptsNormalAdmissions() { + return this == OPEN; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/LeaseState.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/LeaseState.java new file mode 100644 index 00000000..df5bc05d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/LeaseState.java @@ -0,0 +1,4 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +/** Terminal and non-terminal state of an admission lease. */ +public enum LeaseState { ACTIVE, COMMITTED, RELEASED, EXPIRED, INVALIDATED } diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/LeaseTerminalResult.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/LeaseTerminalResult.java new file mode 100644 index 00000000..dad60d35 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/admission/LeaseTerminalResult.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.proxyfeatures.api.capability.admission; + +/** Result of attempting to commit or release a lease. */ +public record LeaseTerminalResult(LeaseState state, long providerGeneration) { + public LeaseTerminalResult { + if (state == null || state == LeaseState.ACTIVE) { + throw new IllegalArgumentException("Terminal result requires a terminal lease state"); + } + if (providerGeneration < 0) throw new IllegalArgumentException("providerGeneration must be non-negative"); + } + public boolean committed() { return state == LeaseState.COMMITTED; } + public boolean released() { return state == LeaseState.RELEASED; } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionFilter.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionFilter.java new file mode 100644 index 00000000..00b81def --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionFilter.java @@ -0,0 +1,7 @@ +package nl.hauntedmc.proxyfeatures.api.capability.moderation; + +/** Selects which sanctions are included in a history query. */ +public enum SanctionFilter { + ACTIVE, + ALL +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionSnapshot.java new file mode 100644 index 00000000..6ba5e044 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionSnapshot.java @@ -0,0 +1,27 @@ +package nl.hauntedmc.proxyfeatures.api.capability.moderation; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** Persistence-independent public view of one sanction. */ +public record SanctionSnapshot( + long id, + UUID playerId, + SanctionType type, + String reason, + String actor, + Instant createdAt, + Optional expiresAt, + boolean active +) { + public SanctionSnapshot { + Objects.requireNonNull(playerId, "playerId"); + type = Objects.requireNonNull(type, "type"); + reason = Objects.requireNonNull(reason, "reason"); + actor = Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(createdAt, "createdAt"); + expiresAt = expiresAt == null ? Optional.empty() : expiresAt; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionType.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionType.java new file mode 100644 index 00000000..301ac149 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionType.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.api.capability.moderation; + +/** Public sanction category. */ +public enum SanctionType { + BAN, + IP_BAN, + MUTE, + WARNING, + KICK +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionsApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionsApi.java new file mode 100644 index 00000000..b470c30a --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/moderation/SanctionsApi.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.proxyfeatures.api.capability.moderation; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** Read-only moderation history capability. */ +public interface SanctionsApi { + + /** + * Returns immutable sanction snapshots matching the requested filter. + * + *

The query may perform persistence I/O. Callers must not block a platform event thread while + * waiting for the returned stage. Neither argument may be {@code null}.

+ * + * @throws NullPointerException when {@code playerId} or {@code filter} is {@code null} + */ + CompletionStage> find(UUID playerId, SanctionFilter filter); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountApi.java new file mode 100644 index 00000000..d3e80b10 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountApi.java @@ -0,0 +1,6 @@ +package nl.hauntedmc.proxyfeatures.api.capability.network; + +/** Public, reload-scoped player count capability. */ +public interface PlayerCountApi { + PlayerCountSnapshot snapshot(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountSnapshot.java new file mode 100644 index 00000000..73e7f452 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountSnapshot.java @@ -0,0 +1,24 @@ +package nl.hauntedmc.proxyfeatures.api.capability.network; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** Immutable network and per-backend player-count view. */ +public record PlayerCountSnapshot( + PlayerCounts network, + Map servers, + Instant observedAt +) { + public PlayerCountSnapshot { + network = Objects.requireNonNull(network, "network"); + servers = servers == null ? Map.of() : Map.copyOf(servers); + observedAt = Objects.requireNonNull(observedAt, "observedAt"); + } + + public PlayerCounts server(ServerId server) { + return servers.getOrDefault(Objects.requireNonNull(server, "server"), PlayerCounts.empty()); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCounts.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCounts.java new file mode 100644 index 00000000..cb48c1ac --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCounts.java @@ -0,0 +1,18 @@ +package nl.hauntedmc.proxyfeatures.api.capability.network; + +/** Vanish-aware counts for one network scope. */ +public record PlayerCounts(int online, int hidden) { + public PlayerCounts { + if (online < 0 || hidden < 0 || hidden > online) { + throw new IllegalArgumentException("invalid player counts"); + } + } + + public static PlayerCounts empty() { + return new PlayerCounts(0, 0); + } + + public int visible() { + return online - hidden; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceApi.java new file mode 100644 index 00000000..4a281752 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceApi.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.proxyfeatures.api.capability.operations; + +import java.util.UUID; + +/** Read-only maintenance capability used by admission and external integrations. */ +public interface MaintenanceApi { + boolean isActive(MaintenanceScope scope); + + boolean mayBypass(UUID playerId, MaintenanceScope scope); + + MaintenanceSnapshot snapshot(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceScope.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceScope.java new file mode 100644 index 00000000..70f5f07e --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceScope.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.proxyfeatures.api.capability.operations; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.Objects; +import java.util.Optional; + +/** Global or backend-specific maintenance scope. */ +public record MaintenanceScope(Optional server) { + public MaintenanceScope { + server = server == null ? Optional.empty() : server; + } + + public static MaintenanceScope global() { + return new MaintenanceScope(Optional.empty()); + } + + public static MaintenanceScope server(ServerId server) { + return new MaintenanceScope(Optional.of(Objects.requireNonNull(server, "server"))); + } + + public boolean isGlobal() { + return server.isEmpty(); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceSnapshot.java new file mode 100644 index 00000000..b2c4ee5c --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/MaintenanceSnapshot.java @@ -0,0 +1,15 @@ +package nl.hauntedmc.proxyfeatures.api.capability.operations; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.Objects; +import java.util.Set; + +/** Point-in-time maintenance state. */ +public record MaintenanceSnapshot(boolean global, Set servers, Instant observedAt) { + public MaintenanceSnapshot { + servers = servers == null ? Set.of() : Set.copyOf(servers); + observedAt = Objects.requireNonNull(observedAt, "observedAt"); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/RestartApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/RestartApi.java new file mode 100644 index 00000000..471b53ae --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/RestartApi.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.proxyfeatures.api.capability.operations; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.UUID; + +/** Read-only restart lifecycle capability. */ +public interface RestartApi { + boolean isExpectedReturn(UUID playerId, ServerId server); + + boolean isDraining(ServerId server); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/TwoFactorApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/TwoFactorApi.java new file mode 100644 index 00000000..e0d25f3f --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/TwoFactorApi.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.proxyfeatures.api.capability.operations; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.UUID; + +/** Read-only two-factor authentication session capability. */ +public interface TwoFactorApi { + boolean isLocked(UUID playerId); + + boolean isAuthenticationServer(ServerId server); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/VersionApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/VersionApi.java new file mode 100644 index 00000000..57bcccbf --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/operations/VersionApi.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.proxyfeatures.api.capability.operations; + +/** Supported client-protocol information. */ +public interface VersionApi { + int minimumProtocolVersion(); + + String minimumVersionName(); + + default boolean isSupported(int protocolVersion) { + return protocolVersion >= minimumProtocolVersion(); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/player/NetworkLocationApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/player/NetworkLocationApi.java new file mode 100644 index 00000000..63fb81a0 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/player/NetworkLocationApi.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.proxyfeatures.api.capability.player; + +import nl.hauntedmc.proxyfeatures.api.model.CountryCode; + +import java.util.Optional; +import java.util.UUID; + +/** Resolved network-location information known for a player session. */ +public interface NetworkLocationApi { + /** Returns a normalized country code, or empty when no reliable country is known. */ + Optional countryCode(UUID playerId); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/player/PlayerLanguageApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/player/PlayerLanguageApi.java new file mode 100644 index 00000000..4f83f001 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/player/PlayerLanguageApi.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.proxyfeatures.api.capability.player; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** Player language preference and resolved-language capability. */ +public interface PlayerLanguageApi { + Optional resolvedLanguage(UUID playerId); + + Optional preference(UUID playerId); + + /** Stores an explicit language preference. */ + CompletionStage setPreference(UUID playerId, Locale language); + + /** Clears the explicit preference so automatic language resolution applies. */ + CompletionStage clearPreference(UUID playerId); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceApi.java new file mode 100644 index 00000000..6d8a21cd --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceApi.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.api.capability.presence; + +import java.util.UUID; + +/** Authoritative player-presence and visibility capability. */ +public interface PresenceApi { + boolean isHidden(UUID playerId); + + PresenceSnapshot snapshot(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceChangedEvent.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceChangedEvent.java new file mode 100644 index 00000000..0513365f --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceChangedEvent.java @@ -0,0 +1,22 @@ +package nl.hauntedmc.proxyfeatures.api.capability.presence; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Fired after a real online player's visibility state changes. + * + *

The Velocity platform publishes this through Velocity's event manager on the thread that + * applies the visibility update. Events are ordered for one update source, but integrations must + * tolerate duplicate and missed notifications across feature reloads and must re-read state from + * {@link PresenceApi}. Event handlers must be non-blocking; no delivery is attempted while the + * provider is unavailable or reloading.

+ */ +public record PresenceChangedEvent(UUID playerId, String playerName, boolean hidden, Instant changedAt) { + public PresenceChangedEvent { + Objects.requireNonNull(playerId, "playerId"); + playerName = playerName == null ? "" : playerName; + Objects.requireNonNull(changedAt, "changedAt"); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceSnapshot.java new file mode 100644 index 00000000..587ed913 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/presence/PresenceSnapshot.java @@ -0,0 +1,36 @@ +package nl.hauntedmc.proxyfeatures.api.capability.presence; + +import java.time.Instant; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Point-in-time view of online and hidden players. */ +public record PresenceSnapshot(Set onlinePlayers, Set hiddenPlayers, Instant observedAt) { + public PresenceSnapshot { + onlinePlayers = onlinePlayers == null ? Set.of() : Set.copyOf(onlinePlayers); + hiddenPlayers = hiddenPlayers == null ? Set.of() : Set.copyOf(hiddenPlayers); + if (!onlinePlayers.containsAll(hiddenPlayers)) { + throw new IllegalArgumentException("hiddenPlayers must be a subset of onlinePlayers"); + } + observedAt = Objects.requireNonNull(observedAt, "observedAt"); + } + + public int onlineCount() { + return onlinePlayers.size(); + } + + public int hiddenCount() { + return hiddenPlayers.size(); + } + + public int visibleCount() { + return onlineCount() - hiddenCount(); + } + + public Set visiblePlayers() { + java.util.HashSet visible = new java.util.HashSet<>(onlinePlayers); + visible.removeAll(hiddenPlayers); + return Set.copyOf(visible); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueApi.java new file mode 100644 index 00000000..6b686366 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueApi.java @@ -0,0 +1,20 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** Public, reload-scoped Queue capability. */ +public interface QueueApi { + CompletionStage join(QueueJoinRequest request); + + CompletionStage leave(UUID playerId); + + Optional find(UUID playerId); + + QueueSnapshot snapshot(); + + boolean isEnabled(ServerId server); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinCause.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinCause.java new file mode 100644 index 00000000..b0ff453d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinCause.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +/** Public reason for asking the Queue capability to enqueue a player. */ +public enum QueueJoinCause { + CAPACITY, + MANUAL, + PLUGIN +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinRequest.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinRequest.java new file mode 100644 index 00000000..2f9fc520 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinRequest.java @@ -0,0 +1,15 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.Objects; +import java.util.UUID; + +/** Untrusted request to enter a configured server queue. */ +public record QueueJoinRequest(UUID playerId, ServerId targetServer, QueueJoinCause cause) { + public QueueJoinRequest { + Objects.requireNonNull(playerId, "playerId"); + Objects.requireNonNull(targetServer, "targetServer"); + cause = cause == null ? QueueJoinCause.PLUGIN : cause; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinResult.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinResult.java new file mode 100644 index 00000000..2acd7c9b --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinResult.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +import java.util.Objects; +import java.util.Optional; + +/** Typed result of a queue join request. */ +public record QueueJoinResult(QueueJoinStatus status, Optional entry) { + public QueueJoinResult { + status = Objects.requireNonNull(status, "status"); + entry = entry == null ? Optional.empty() : entry; + boolean joined = status == QueueJoinStatus.JOINED + || status == QueueJoinStatus.MOVED + || status == QueueJoinStatus.ALREADY_QUEUED; + if (joined != entry.isPresent()) { + throw new IllegalArgumentException("Successful queue results require an entry"); + } + } + + public static QueueJoinResult success(QueueJoinStatus status, QueuedPlayerSnapshot entry) { + return new QueueJoinResult(status, Optional.of(Objects.requireNonNull(entry, "entry"))); + } + + public static QueueJoinResult failure(QueueJoinStatus status) { + return new QueueJoinResult(status, Optional.empty()); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinStatus.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinStatus.java new file mode 100644 index 00000000..78557116 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueJoinStatus.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +/** Outcome of a queue join request. */ +public enum QueueJoinStatus { + JOINED, + MOVED, + ALREADY_QUEUED, + PLAYER_OFFLINE, + QUEUE_DISABLED, + REJECTED, + UNAVAILABLE +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueLeaveStatus.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueLeaveStatus.java new file mode 100644 index 00000000..885d50e4 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueLeaveStatus.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +/** Outcome of a queue leave request. */ +public enum QueueLeaveStatus { + LEFT, + NOT_QUEUED, + UNAVAILABLE +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueServerSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueServerSnapshot.java new file mode 100644 index 00000000..fc8aa85d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueServerSnapshot.java @@ -0,0 +1,28 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Immutable Queue snapshot for one backend. */ +public record QueueServerSnapshot( + ServerId server, + int waiting, + int connected, + int grace, + int inFlight, + int blocked, + Optional oldestEnqueuedAt +) { + public QueueServerSnapshot { + Objects.requireNonNull(server, "server"); + waiting = Math.max(0, waiting); + connected = Math.max(0, connected); + grace = Math.max(0, grace); + inFlight = Math.max(0, inFlight); + blocked = Math.max(0, blocked); + oldestEnqueuedAt = oldestEnqueuedAt == null ? Optional.empty() : oldestEnqueuedAt; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueSnapshot.java new file mode 100644 index 00000000..48edb812 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueueSnapshot.java @@ -0,0 +1,23 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** Immutable process-local Queue observability snapshot. */ +public record QueueSnapshot(Map servers, Instant observedAt) { + public QueueSnapshot { + servers = servers == null ? Map.of() : Map.copyOf(servers); + observedAt = Objects.requireNonNull(observedAt, "observedAt"); + } + + public int totalWaiting() { + return servers.values().stream().mapToInt(QueueServerSnapshot::waiting).sum(); + } + + public int totalInFlight() { + return servers.values().stream().mapToInt(QueueServerSnapshot::inFlight).sum(); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueuedPlayerSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueuedPlayerSnapshot.java new file mode 100644 index 00000000..4a0bf304 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/queue/QueuedPlayerSnapshot.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.proxyfeatures.api.capability.queue; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Immutable view of one queued player. Position is one-based. */ +public record QueuedPlayerSnapshot( + UUID playerId, + ServerId targetServer, + int position, + int priority, + Instant enqueuedAt, + boolean inFlight +) { + public QueuedPlayerSnapshot { + Objects.requireNonNull(playerId, "playerId"); + Objects.requireNonNull(targetServer, "targetServer"); + if (position < 1) { + throw new IllegalArgumentException("position must be positive"); + } + Objects.requireNonNull(enqueuedAt, "enqueuedAt"); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/social/FriendshipApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/social/FriendshipApi.java new file mode 100644 index 00000000..36dcb30f --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capability/social/FriendshipApi.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.api.capability.social; + +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** Asynchronous, persistence-independent friendship capability. */ +public interface FriendshipApi { + /** Completes with whether the two players are friends. */ + CompletionStage areFriends(UUID firstPlayer, UUID secondPlayer); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/RestartAdmissionAPI.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/RestartAdmissionAPI.java deleted file mode 100644 index 0c6ce8a6..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/RestartAdmissionAPI.java +++ /dev/null @@ -1,8 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; - -import java.util.UUID; - -/** Optional Restart feature bridge used to classify an expected backend return. */ -public interface RestartAdmissionAPI { - boolean isRestartReturn(UUID playerId, String serverName); -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/ExtensionRegistration.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/ExtensionRegistration.java new file mode 100644 index 00000000..3aa3231c --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/ExtensionRegistration.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.api.extension; + +/** Idempotent ownership handle returned for every public extension registration. */ +@FunctionalInterface +public interface ExtensionRegistration extends AutoCloseable { + @Override + void close(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContext.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContext.java new file mode 100644 index 00000000..bb904c01 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContext.java @@ -0,0 +1,11 @@ +package nl.hauntedmc.proxyfeatures.api.extension; + +import java.net.InetAddress; +import java.util.Objects; + +/** Stable information made available to MOTD contributors. */ +public record MotdContext(InetAddress remoteAddress, int protocolVersion) { + public MotdContext { + Objects.requireNonNull(remoteAddress, "remoteAddress"); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContribution.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContribution.java new file mode 100644 index 00000000..ccd12a7d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContribution.java @@ -0,0 +1,20 @@ +package nl.hauntedmc.proxyfeatures.api.extension; + +import java.util.Objects; +import java.util.Optional; + +/** Optional text overrides supplied by one MOTD contributor. */ +public record MotdContribution(Optional firstLine, Optional secondLine) { + public MotdContribution { + firstLine = clean(firstLine); + secondLine = clean(secondLine); + } + + public static MotdContribution secondLine(String value) { + return new MotdContribution(Optional.empty(), Optional.of(Objects.requireNonNull(value, "value"))); + } + + private static Optional clean(Optional value) { + return value == null ? Optional.empty() : value.map(String::trim).filter(text -> !text.isEmpty()); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContributor.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContributor.java new file mode 100644 index 00000000..05a5529e --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdContributor.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.proxyfeatures.api.extension; + +import java.util.Optional; + +/** Computes optional MOTD overrides for one status request. */ +@FunctionalInterface +public interface MotdContributor { + Optional contribute(MotdContext context); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdExtensions.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdExtensions.java new file mode 100644 index 00000000..1c9a55ca --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/extension/MotdExtensions.java @@ -0,0 +1,6 @@ +package nl.hauntedmc.proxyfeatures.api.extension; + +/** Lifecycle-safe registry for public MOTD contributors. */ +public interface MotdExtensions { + ExtensionRegistration register(String owner, int priority, MotdContributor contributor); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureCatalog.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureCatalog.java new file mode 100644 index 00000000..83b743df --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureCatalog.java @@ -0,0 +1,15 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +import java.util.List; +import java.util.Optional; + +/** Read-only catalog of all known built-in features. */ +public interface FeatureCatalog { + + Optional find(FeatureId id); + + List snapshot(); + + /** Registers a listener that is notified after each feature-state change. */ + AutoCloseable subscribe(FeatureCatalogListener listener); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureCatalogListener.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureCatalogListener.java new file mode 100644 index 00000000..706ced9a --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureCatalogListener.java @@ -0,0 +1,7 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +/** Callbacks invoked after a public feature projection changes. Callbacks must be non-blocking. */ +@FunctionalInterface +public interface FeatureCatalogListener { + void stateChanged(FeatureSnapshot snapshot); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureClassification.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureClassification.java new file mode 100644 index 00000000..302f0292 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureClassification.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +/** Defines how a built-in feature participates in the supported public architecture. */ +public enum FeatureClassification { + CAPABILITY_PROVIDER, + EXTENSION_PROVIDER, + CAPABILITY_CONSUMER, + INTERNAL +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureDescriptor.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureDescriptor.java new file mode 100644 index 00000000..59c2d8f9 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureDescriptor.java @@ -0,0 +1,50 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +import java.util.Objects; +import java.util.Set; + +/** Immutable public metadata for a built-in feature. */ +public record FeatureDescriptor( + FeatureId id, + String displayName, + String version, + FeatureClassification classification, + Set requiredFeatures, + Set providedCapabilities, + Set roles +) { + public FeatureDescriptor { + Objects.requireNonNull(id, "id"); + displayName = requireText(displayName, "displayName"); + version = requireText(version, "version"); + classification = Objects.requireNonNull(classification, "classification"); + requiredFeatures = requiredFeatures == null ? Set.of() : Set.copyOf(requiredFeatures); + providedCapabilities = providedCapabilities == null ? Set.of() : Set.copyOf(providedCapabilities); + roles = roles == null ? Set.of() : Set.copyOf(roles); + } + + /** Compatibility constructor; use {@link #roles()} to determine independent feature responsibilities. */ + public FeatureDescriptor(FeatureId id, String displayName, String version, FeatureClassification classification, + Set requiredFeatures, Set providedCapabilities) { + this(id, displayName, version, classification, requiredFeatures, providedCapabilities, + deriveRoles(classification, requiredFeatures, providedCapabilities)); + } + + private static Set deriveRoles(FeatureClassification classification, Set requiredFeatures, + Set providedCapabilities) { + java.util.EnumSet derived = java.util.EnumSet.noneOf(FeatureRole.class); + if (providedCapabilities != null && !providedCapabilities.isEmpty()) derived.add(FeatureRole.CAPABILITY_PROVIDER); + if (requiredFeatures != null && !requiredFeatures.isEmpty()) derived.add(FeatureRole.CAPABILITY_CONSUMER); + if (classification == FeatureClassification.EXTENSION_PROVIDER) derived.add(FeatureRole.EXTENSION_PROVIDER); + return Set.copyOf(derived); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureFailure.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureFailure.java new file mode 100644 index 00000000..2314d50a --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureFailure.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +import java.util.Objects; +import java.util.Optional; + +/** Sanitized failure information safe to expose to external integrations. */ +public record FeatureFailure(String phase, String code, Optional message) { + public FeatureFailure { + phase = text(phase, "phase"); + code = text(code, "code"); + message = message == null ? Optional.empty() : message.map(value -> text(value, "message")); + } + private static String text(String value, String name) { + Objects.requireNonNull(value, name); + String normalized = value.trim(); + if (normalized.isEmpty() || normalized.length() > 160) throw new IllegalArgumentException(name + " is invalid"); + return normalized; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureId.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureId.java new file mode 100644 index 00000000..cda0d493 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureId.java @@ -0,0 +1,50 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +import java.util.Locale; +import java.util.Objects; + +/** Stable, normalized identity of a ProxyFeatures feature. */ +public record FeatureId(String value) implements Comparable { + public static final int MAX_LENGTH = 64; + + public FeatureId { + Objects.requireNonNull(value, "value"); + value = value.trim().toLowerCase(Locale.ROOT); + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException("Feature id exceeds " + MAX_LENGTH + " characters"); + } + if (!isValid(value)) { + throw new IllegalArgumentException("Invalid feature id: " + value); + } + } + + public static FeatureId of(String value) { + return new FeatureId(value); + } + + @Override + public int compareTo(FeatureId other) { + return value.compareTo(Objects.requireNonNull(other, "other").value); + } + + @Override + public String toString() { + return value; + } + + private static boolean isValid(String value) { + if (value.isEmpty() || !Character.isLetterOrDigit(value.charAt(0))) { + return false; + } + for (int index = 1; index < value.length(); index++) { + char character = value.charAt(index); + if (!Character.isLetterOrDigit(character) + && character != '-' + && character != '_' + && character != '.') { + return false; + } + } + return true; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureRole.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureRole.java new file mode 100644 index 00000000..d7b6a8da --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureRole.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +/** Independent roles a feature can hold at the same time. */ +public enum FeatureRole { + CAPABILITY_PROVIDER, + CAPABILITY_CONSUMER, + EXTENSION_PROVIDER, + OPERATOR_FACING +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureSnapshot.java new file mode 100644 index 00000000..2cf46964 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureSnapshot.java @@ -0,0 +1,37 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** Point-in-time public view of one feature and its lifecycle state. */ +public record FeatureSnapshot( + FeatureDescriptor descriptor, + boolean configuredEnabled, + FeatureState state, + Optional failure, + Optional failureDetail, + Set unavailableDependencies, + Instant lastTransitionAt, + Optional lastSuccessfulActivationAt, + long generation, + Instant observedAt +) { + public FeatureSnapshot { + Objects.requireNonNull(descriptor, "descriptor"); + state = Objects.requireNonNull(state, "state"); + failure = failure == null ? Optional.empty() : failure.filter(value -> !value.isBlank()); + failureDetail = failureDetail == null ? Optional.empty() : failureDetail; + unavailableDependencies = unavailableDependencies == null ? Set.of() : Set.copyOf(unavailableDependencies); + lastTransitionAt = Objects.requireNonNull(lastTransitionAt, "lastTransitionAt"); + lastSuccessfulActivationAt = lastSuccessfulActivationAt == null ? Optional.empty() : lastSuccessfulActivationAt; + if (generation < 0) throw new IllegalArgumentException("generation must be non-negative"); + observedAt = Objects.requireNonNull(observedAt, "observedAt"); + } + + /** Compatibility constructor for callers that only consume the original projection. */ + public FeatureSnapshot(FeatureDescriptor descriptor, FeatureState state, Optional failure, Instant observedAt) { + this(descriptor, false, state, failure, Optional.empty(), Set.of(), observedAt, Optional.empty(), 0L, observedAt); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureState.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureState.java new file mode 100644 index 00000000..6d34d507 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/FeatureState.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.api.feature; + +/** Observable lifecycle state of a built-in feature. */ +public enum FeatureState { + DISABLED, + STARTING, + ACTIVE, + STOPPING, + FAILED +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/meta/BaseMeta.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/meta/BaseMeta.java deleted file mode 100644 index 0d746f86..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/meta/BaseMeta.java +++ /dev/null @@ -1,20 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.feature.meta; - -import java.util.List; - -public interface BaseMeta { - String DATA_PROVIDER = "dataprovider"; - String DATA_REGISTRY = "dataregistry"; - - String getFeatureName(); - - String getFeatureVersion(); - - default List getDependencies() { - return List.of(); - } - - default List getPluginDependencies() { - return List.of(); - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/friends/FriendshipApi.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/friends/FriendshipApi.java deleted file mode 100644 index bba342ce..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/friends/FriendshipApi.java +++ /dev/null @@ -1,23 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.friends; - -import java.util.UUID; -import java.util.concurrent.CompletionStage; - -/** - * Read-only friendship relationship API published by the Friends feature. - * - *

The asynchronous contract keeps callers independent from the underlying - * persistence or network implementation. Implementations must complete - * exceptionally when relationship data cannot be determined reliably.

- */ -public interface FriendshipApi { - - /** - * Determines whether both players have an accepted friendship relation. - * - * @param firstPlayer first player UUID - * @param secondPlayer second player UUID - * @return a stage containing {@code true} only for an accepted friendship - */ - CompletionStage areFriends(UUID firstPlayer, UUID secondPlayer); -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheType.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheType.java deleted file mode 100644 index b9865ba7..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheType.java +++ /dev/null @@ -1,9 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; - -/** - * Supported cache back-ends. - */ -public enum CacheType { - JSON, - SQLITE -} \ No newline at end of file diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/SqliteCacheFile.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/SqliteCacheFile.java deleted file mode 100644 index a27533fe..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/SqliteCacheFile.java +++ /dev/null @@ -1,51 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache.impl; - -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheStore; - -import java.io.File; -import java.io.IOException; -import java.sql.Connection; -import java.sql.DriverManager; - -/** - * Stub SQLite-backed cache file. Records TTL cleanup at application layer. - */ -public class SqliteCacheFile implements CacheStore { - private final File file; - - public SqliteCacheFile(File file) { - this.file = file; - try { - file.getParentFile().mkdirs(); - file.createNewFile(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public File getUnderlyingFile() { - return file; - } - - public Connection getConnection() { - try { - return DriverManager.getConnection("jdbc:sqlite:" + file.getAbsolutePath()); - } catch (Exception ex) { - throw new RuntimeException(ex); - } - } - - @Override - public void cleanupExpired() { /* no-op */ } - - @Override - public void delete() { - if (!file.delete()) file.deleteOnExit(); - } - - @Override - public boolean isEmpty() { - return false; - } -} \ No newline at end of file diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/YamlFile.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/YamlFile.java deleted file mode 100644 index 1acc39e5..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/YamlFile.java +++ /dev/null @@ -1,127 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; - -import org.slf4j.Logger; -import org.spongepowered.configurate.CommentedConfigurationNode; -import org.spongepowered.configurate.ConfigurationOptions; -import org.spongepowered.configurate.yaml.NodeStyle; -import org.spongepowered.configurate.yaml.YamlConfigurationLoader; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.function.Consumer; - -/** - * Owns a single YAML file (Configurate) + its in-memory root node + a read/write lock. - */ -public final class YamlFile { - private final Path path; - private final Logger logger; - private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); - private final YamlConfigurationLoader loader; - private volatile CommentedConfigurationNode root; - - public YamlFile(Path path, Logger logger) { - this.path = path; - this.logger = logger; - - this.loader = YamlConfigurationLoader.builder() - .path(path) - .nodeStyle(NodeStyle.BLOCK) - .defaultOptions(ConfigurationOptions.defaults()) - .build(); - - reload(); // initial load - } - - public ReentrantReadWriteLock lock() { return rw; } - - /** Load from disk. */ - public void reload() { - rw.writeLock().lock(); - try { - this.root = loader.load(); - } catch (IOException e) { - logger.error("[ProxyFeatures] Could not load YAML '{}': {}", path, e.getMessage()); - this.root = CommentedConfigurationNode.root(); - } finally { - rw.writeLock().unlock(); - } - } - - /** Persist to disk (caller holds write-intent via higher APIs). */ - void saveNow() { - try { - loader.save(root); - } catch (IOException e) { - logger.error("[ProxyFeatures] Could not save YAML '{}': {}", path, e.getMessage()); - } - } - - /** Direct raw mutation with automatic save under write lock. */ - public void mutateAndSave(Consumer mutator) { - rw.writeLock().lock(); - try { - mutator.accept(root); - saveNow(); - } finally { - rw.writeLock().unlock(); - } - } - - // -------- Low-level access used by ConfigView -------- - - Object getRaw(String absolutePath) { - rw.readLock().lock(); - try { - if (absolutePath == null || absolutePath.isBlank()) { - return root.get(Object.class); - } - return root.node(splitPath(absolutePath)).get(Object.class); - } catch (Exception e) { - return null; - } finally { - rw.readLock().unlock(); - } - } - - boolean contains(String absolutePath) { - rw.readLock().lock(); - try { - if (absolutePath == null || absolutePath.isBlank()) { - return !root.virtual(); - } - return !root.node(splitPath(absolutePath)).virtual(); - } finally { - rw.readLock().unlock(); - } - } - - void setRawAndSave(String absolutePath, Object value) { - rw.writeLock().lock(); - try { - if (absolutePath == null || absolutePath.isBlank()) { - root.set(value); - } else { - root.node(splitPath(absolutePath)).set(value); - } - saveNow(); - } catch (Exception e) { - logger.error("[ProxyFeatures] Failed setting '{}': {}", absolutePath, e.getMessage()); - } finally { - rw.writeLock().unlock(); - } - } - - CommentedConfigurationNode snapshotUnsafe() { // guarded by external lock in ConfigView when used - return root; - } - - static Object[] splitPath(String dotted) { - if (dotted == null || dotted.isBlank()) return new Object[0]; - String[] parts = dotted.split("\\."); - Object[] out = new Object[parts.length]; - System.arraycopy(parts, 0, out, 0, parts.length); - return out; - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/packet/Packet.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/packet/Packet.java deleted file mode 100644 index 14e86331..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/packet/Packet.java +++ /dev/null @@ -1,10 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.packet; - -import com.velocitypowered.api.proxy.Player; - -/** - * Interface for all packet types, enabling abstraction from PacketEvents or other libraries. - */ -public interface Packet { - void sendTo(Player player); -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/packet/PacketManager.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/packet/PacketManager.java deleted file mode 100644 index 8fa6c696..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/packet/PacketManager.java +++ /dev/null @@ -1,59 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.packet; - -import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.ProxyServer; - -import java.util.List; - -/** - * Manages sending packets to players, allowing unicast, multicast, and broadcast. - */ -public class PacketManager { - - /** - * Sends packets to a single player. - * - * @param player The recipient player. - * @param packets The packets to send. - */ - public static void sendUnicast(Player player, Packet... packets) { - for (Packet packet : packets) { - packet.sendTo(player); - } - } - - /** - * Sends packets to multiple players in range of a specific player. - * - * @param players The center player. - * @param packets The packets to send. - */ - public static void sendMulticast(List players, Packet... packets) { - for (Player target : players) { - for (Packet packet : packets) { - packet.sendTo(target); - } - } - } - - /** - * Sends packets to all online players. - * - * @param packets The packets to send. - */ - public static void sendBroadcast(ProxyServer proxy, Packet... packets) { - sendBroadcast(proxy.getAllPlayers(), packets); - } - - /** - * Sends packets to a provided player collection. - * Useful for tests and isolated call paths that already have a target set. - */ - public static void sendBroadcast(Iterable players, Packet... packets) { - for (Player player : players) { - for (Packet packet : packets) { - packet.sendTo(player); - } - } - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/resource/ResourceHandler.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/resource/ResourceHandler.java deleted file mode 100644 index d552b6e4..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/resource/ResourceHandler.java +++ /dev/null @@ -1,108 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.resource; - -import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesContext; -import org.spongepowered.configurate.CommentedConfigurationNode; -import org.spongepowered.configurate.loader.ConfigurationLoader; -import org.spongepowered.configurate.yaml.NodeStyle; -import org.spongepowered.configurate.yaml.YamlConfigurationLoader; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -public class ResourceHandler { - private final ProxyFeaturesContext plugin; - private final String resourcePath; - private final Path file; - private final ConfigurationLoader loader; - private CommentedConfigurationNode config; - - public ResourceHandler(ProxyFeaturesContext plugin, String fileName) { - this.plugin = plugin; - this.resourcePath = fileName; - Path dataDir = plugin.getDataDirectory().toAbsolutePath().normalize(); - Path resolved = dataDir.resolve(fileName).normalize(); - if (!resolved.startsWith(dataDir)) { - throw new IllegalArgumentException("Resource path escapes data directory: " + fileName); - } - this.file = resolved; - this.loader = YamlConfigurationLoader.builder() - .path(file) - .nodeStyle(NodeStyle.BLOCK) - .build(); - ensureFileExists(); - load(); - } - - /** - * Ensures that the file exists in the data directory. - * If it does not exist, attempts to copy the default from the plugin resources. - */ - private void ensureFileExists() { - try { - Files.createDirectories(file.getParent()); - if (!Files.exists(file)) { - try (InputStream in = openResourceStream()) { - if (in != null) { - Files.copy(in, file); - return; - } else { - plugin.getLogger().warn("Default '{}' not found in resources. Creating empty file '{}'.", resourcePath, file); - } - } - Files.createFile(file); - } - } catch (IOException e) { - plugin.getLogger().error("Error ensuring file exists: {}", file, e); - } - } - - /** - * Loads the configuration from disk. - */ - private void load() { - try { - this.config = loader.load(); - } catch (IOException e) { - plugin.getLogger().error("Error loading file: {}", file, e); - this.config = CommentedConfigurationNode.root(); - } - } - - /** - * Reloads the configuration from disk. - */ - public void reload() { - load(); - } - - /** - * Returns the current configuration node. - */ - public CommentedConfigurationNode getConfig() { - return config; - } - - /** - * Saves any changes to the file. - */ - public void save() { - try { - loader.save(config); - } catch (IOException e) { - plugin.getLogger().error("Error saving file: {}", file, e); - } - } - - private InputStream openResourceStream() { - ClassLoader classLoader = plugin.getResourceClassLoader(); - InputStream in = classLoader.getResourceAsStream(resourcePath); - if (in != null) { - return in; - } - - String fallback = file.getFileName().toString(); - return classLoader.getResourceAsStream(fallback); - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/model/CountryCode.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/model/CountryCode.java new file mode 100644 index 00000000..28bbed6d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/model/CountryCode.java @@ -0,0 +1,24 @@ +package nl.hauntedmc.proxyfeatures.api.model; + +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Normalized ISO 3166-1 alpha-2 country code. Absence represents an unknown location. */ +public record CountryCode(String value) { + public CountryCode { + Objects.requireNonNull(value, "value"); + value = value.trim().toUpperCase(Locale.ROOT); + if (!value.matches("[A-Z]{2}")) { + throw new IllegalArgumentException("Country code must be ISO 3166-1 alpha-2"); + } + } + + public static CountryCode of(String value) { + return new CountryCode(value); + } + + public static Optional optional(String value) { + return value == null || value.isBlank() ? Optional.empty() : Optional.of(of(value)); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/model/ServerId.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/model/ServerId.java new file mode 100644 index 00000000..4a6ca1e6 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/model/ServerId.java @@ -0,0 +1,48 @@ +package nl.hauntedmc.proxyfeatures.api.model; + +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Validated, case-normalized backend server identity. */ +public record ServerId(String value) implements Comparable { + public static final int MAX_LENGTH = 64; + + public ServerId { + Objects.requireNonNull(value, "value"); + value = value.trim().toLowerCase(Locale.ROOT); + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException("Server id exceeds " + MAX_LENGTH + " characters"); + } + if (value.isEmpty()) { + throw new IllegalArgumentException("server id must not be blank"); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (!Character.isLetterOrDigit(character) + && character != '-' + && character != '_' + && character != '.') { + throw new IllegalArgumentException("Invalid server id: " + value); + } + } + } + + public static ServerId of(String value) { + return new ServerId(value); + } + + public static Optional optional(String value) { + return value == null || value.isBlank() ? Optional.empty() : Optional.of(new ServerId(value)); + } + + @Override + public int compareTo(ServerId other) { + return value.compareTo(Objects.requireNonNull(other, "other").value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueAdmissionAPI.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueAdmissionAPI.java deleted file mode 100644 index b579fdbf..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueAdmissionAPI.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.queue; - -import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDenialReason; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityRequest; - -import java.util.UUID; - -/** Optional bridge used by Capacity when a normal target is full. */ -public interface QueueAdmissionAPI { - boolean isQueueEnabled(String serverName); - - boolean enqueue(Player player, String serverName, CapacityDenialReason reason, CapacityRequest admissionContext); - - void wake(String serverName); - - /** - * Consumes a short-lived fence for a Queue connection request cancelled after it entered - * Velocity's connection pipeline. Implementations must make the match one-shot and target-specific. - */ - default boolean consumeCancelledAdvance(UUID playerId, String targetServer) { - return false; - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueObservabilityAPI.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueObservabilityAPI.java deleted file mode 100644 index 15e45ea2..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueObservabilityAPI.java +++ /dev/null @@ -1,6 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.queue; - -/** Read-only Queue telemetry used by Capacity persistence and staff tooling. */ -public interface QueueObservabilityAPI { - QueueSnapshot snapshot(); -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueServerSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueServerSnapshot.java deleted file mode 100644 index fa586e38..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueServerSnapshot.java +++ /dev/null @@ -1,27 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.queue; - -import java.time.Instant; - -/** Immutable operational Queue snapshot for one target backend. */ -public record QueueServerSnapshot( - String server, - int waiting, - int connected, - int grace, - int inFlight, - int blocked, - Instant oldestEnqueuedAt -) { - public QueueServerSnapshot { - server = normalize(server); - waiting = Math.max(0, waiting); - connected = Math.max(0, connected); - grace = Math.max(0, grace); - inFlight = Math.max(0, inFlight); - blocked = Math.max(0, blocked); - } - - private static String normalize(String value) { - return value == null ? "" : value.trim().toLowerCase(java.util.Locale.ROOT); - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueSnapshot.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueSnapshot.java deleted file mode 100644 index d842a86f..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/queue/QueueSnapshot.java +++ /dev/null @@ -1,26 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.queue; - -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.Map; - -/** Immutable process-local Queue observability snapshot. */ -public record QueueSnapshot( - Map servers, - Instant observedAt -) { - public QueueSnapshot { - servers = servers == null - ? Map.of() - : java.util.Collections.unmodifiableMap(new LinkedHashMap<>(servers)); - observedAt = observedAt == null ? Instant.now() : observedAt; - } - - public int totalWaiting() { - return servers.values().stream().mapToInt(QueueServerSnapshot::waiting).sum(); - } - - public int totalInFlight() { - return servers.values().stream().mapToInt(QueueServerSnapshot::inFlight).sum(); - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityListener.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityListener.java new file mode 100644 index 00000000..4d223913 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityListener.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.api.service; + +/** Lifecycle callbacks invoked after a capability registry change. Callbacks must be non-blocking. */ +public interface CapabilityListener { + default void available(Class type, long generation) {} + default void unavailable(Class type, long generation) {} + default void replaced(Class type, long previousGeneration, long nextGeneration) {} +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityRef.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityRef.java new file mode 100644 index 00000000..36daa276 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityRef.java @@ -0,0 +1,38 @@ +package nl.hauntedmc.proxyfeatures.api.service; + +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Reload-safe reference to an optional capability. + * + *

Callers should resolve {@link #get()} for each operation instead of retaining an implementation + * returned before a feature reload. Implementations may return a stable invocation proxy that resolves + * and leases the active provider for each method call.

+ */ +public interface CapabilityRef { + + /** The public contract represented by this reference. */ + Class type(); + + /** Returns the currently active implementation, if its provider is enabled. */ + Optional get(); + + /** + * Returns the generation of the currently active provider. A new generation is assigned whenever + * a provider is republished after reload. + */ + default OptionalLong generation() { + return isAvailable() ? OptionalLong.of(1L) : OptionalLong.empty(); + } + + /** Returns whether the capability currently has an active provider. */ + default boolean isAvailable() { + return get().isPresent(); + } + + /** Resolves the current implementation or fails with a descriptive exception. */ + default T require() { + return get().orElseThrow(() -> new CapabilityUnavailableException(type())); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityRegistry.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityRegistry.java new file mode 100644 index 00000000..6098bb0d --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityRegistry.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.proxyfeatures.api.service; + +import java.util.Set; + +/** Read-only catalog of feature capabilities provided by the current runtime. */ +public interface CapabilityRegistry { + + /** Returns a stable reference for the requested public contract. */ + CapabilityRef reference(Class type); + + /** Returns the contracts that currently have an active provider. */ + Set> availableTypes(); + + /** + * Registers a lifecycle listener. Close the returned subscription when the owning plugin + * unloads; platform adapters must do this automatically for plugin-owned registrations. + */ + AutoCloseable subscribe(CapabilityListener listener); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityUnavailableException.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityUnavailableException.java new file mode 100644 index 00000000..27c338f5 --- /dev/null +++ b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/service/CapabilityUnavailableException.java @@ -0,0 +1,21 @@ +package nl.hauntedmc.proxyfeatures.api.service; + +import java.util.Objects; + +/** Thrown when a required optional capability has no active provider. */ +public final class CapabilityUnavailableException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + private final Class capabilityType; + + public CapabilityUnavailableException(Class capabilityType) { + super("ProxyFeatures capability is unavailable: " + + Objects.requireNonNull(capabilityType, "capabilityType").getName()); + this.capabilityType = capabilityType; + } + + public Class capabilityType() { + return capabilityType; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/http/DiscordUtils.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/http/DiscordUtils.java deleted file mode 100644 index 057b259c..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/http/DiscordUtils.java +++ /dev/null @@ -1,72 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.util.http; - -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; - -public class DiscordUtils { - - - /** - * Sends the provided JSON payload to the specified Discord webhook URL. - * - * @param webhookUrl The webhook URL. - * @param payload The JSON payload. - */ - public static boolean sendPayload(String webhookUrl, String payload) { - HttpURLConnection connection = null; - try { - if (webhookUrl == null || webhookUrl.isBlank()) { - return false; - } - - URI uri = URI.create(webhookUrl.trim()); - String scheme = uri.getScheme(); - if (scheme == null || !scheme.equalsIgnoreCase("https")) { - return false; - } - - URL url = uri.toURL(); - connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setConnectTimeout(5000); - connection.setReadTimeout(5000); - connection.setDoOutput(true); - - try (OutputStream os = connection.getOutputStream()) { - String data = payload == null ? "" : payload; - os.write(data.getBytes(StandardCharsets.UTF_8)); - os.flush(); - } - - int responseCode = connection.getResponseCode(); - boolean success = responseCode == HttpURLConnection.HTTP_OK - || responseCode == HttpURLConnection.HTTP_NO_CONTENT; - - InputStream stream = success ? connection.getInputStream() : connection.getErrorStream(); - if (stream != null) { - try (BufferedReader ignored = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { - while (ignored.readLine() != null) { - // drain stream so keep-alive connections remain reusable - } - } - } - - return success; - } catch (Exception ex) { - return false; - } finally { - if (connection != null) { - connection.disconnect(); - } - } - } - - -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/type/CastUtils.java b/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/type/CastUtils.java deleted file mode 100644 index 6554ae31..00000000 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/type/CastUtils.java +++ /dev/null @@ -1,29 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.util.type; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class CastUtils { - - private CastUtils() { - } - - public static List safeCastToList(Object obj, Class clazz) { - if (obj instanceof List rawList) { - List result = new ArrayList<>(); - for (Object item : rawList) { - if (item == null) { - throw new ClassCastException("Expected a " + clazz.getName() + ", but found: null"); - } - if (clazz.isInstance(item)) { - result.add(clazz.cast(item)); - } else { - throw new ClassCastException("Expected a " + clazz.getName() + ", but found: " + item.getClass().getName()); - } - } - return result; - } - return Collections.emptyList(); - } -} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/AdmissionAndQueueContractsTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/AdmissionAndQueueContractsTest.java new file mode 100644 index 00000000..3494bbf5 --- /dev/null +++ b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/AdmissionAndQueueContractsTest.java @@ -0,0 +1,170 @@ +package nl.hauntedmc.proxyfeatures.api; + +import nl.hauntedmc.proxyfeatures.api.capability.admission.*; +import nl.hauntedmc.proxyfeatures.api.capability.queue.*; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class AdmissionAndQueueContractsTest { + + @Test + void admissionRequestsNormalizeOptionalAndIntentInputs() { + UUID playerId = UUID.randomUUID(); + ServerId server = ServerId.of("survival"); + + AdmissionRequest request = new AdmissionRequest(playerId, null, server, null); + assertEquals(Optional.empty(), request.previousServer()); + assertEquals(AdmissionIntent.PLUGIN, request.intent()); + assertEquals(AdmissionIntent.NORMAL, AdmissionRequest.normal(playerId, server).intent()); + assertThrows(NullPointerException.class, + () -> new AdmissionRequest(null, Optional.empty(), server, AdmissionIntent.NORMAL)); + assertThrows(NullPointerException.class, + () -> new AdmissionRequest(playerId, Optional.empty(), null, AdmissionIntent.NORMAL)); + } + + @Test + void admissionDecisionsEnforceTheLeaseInvariant() { + TestLease lease = new TestLease(); + AdmissionDecision allowed = AdmissionDecision.allow(lease); + + assertTrue(allowed.allowed()); + assertSame(lease, allowed.lease().orElseThrow()); + + AdmissionDecision denied = AdmissionDecision.deny(AdmissionDenialReason.CAPACITY, " server:survival "); + assertFalse(denied.allowed()); + assertEquals(Optional.of("server:survival"), denied.blockingPolicy()); + assertEquals(Optional.empty(), AdmissionDecision.deny(AdmissionDenialReason.MAINTENANCE, " ").blockingPolicy()); + assertThrows(IllegalArgumentException.class, + () -> AdmissionDecision.deny(AdmissionDenialReason.NONE, "none")); + assertThrows(NullPointerException.class, () -> AdmissionDecision.allow(null)); + assertThrows(IllegalArgumentException.class, + () -> new AdmissionDecision(AdmissionDenialReason.NONE, null, Optional.empty())); + assertThrows(IllegalArgumentException.class, + () -> new AdmissionDecision(AdmissionDenialReason.CAPACITY, null, Optional.of(lease))); + } + + @Test + void admissionSnapshotsAreImmutableAndExposeAvailabilityMath() { + AdmissionScopeSnapshot scope = new AdmissionScopeSnapshot( + " survival ", 20, 3, 10, 2, 1, AdmissionState.OPEN + ); + assertEquals("survival", scope.name()); + assertEquals(13, scope.effectiveUsed()); + assertEquals(4, scope.normalAvailable()); + assertEquals(7, scope.absoluteAvailable()); + + AdmissionScopeSnapshot normalized = new AdmissionScopeSnapshot( + "proxy", -1, -1, -1, -1, -1, AdmissionState.DRAINING + ); + assertEquals(0, normalized.capacity()); + assertEquals(0, normalized.effectiveUsed()); + assertThrows(IllegalArgumentException.class, + () -> new AdmissionScopeSnapshot(" ", 1, 0, 0, 0, 0, AdmissionState.OPEN)); + assertThrows(NullPointerException.class, + () -> new AdmissionScopeSnapshot("x", 1, 0, 0, 0, 0, null)); + + Map groups = new LinkedHashMap<>(); + groups.put("game", scope); + AdmissionSnapshot snapshot = new AdmissionSnapshot( + scope, scope, groups, null, -2, Instant.EPOCH + ); + groups.clear(); + assertEquals(scope, snapshot.groups().get("game")); + assertTrue(snapshot.servers().isEmpty()); + assertEquals(0, snapshot.activeLeases()); + assertThrows(NullPointerException.class, + () -> new AdmissionSnapshot(null, scope, null, null, 0, Instant.EPOCH)); + assertThrows(NullPointerException.class, + () -> new AdmissionSnapshot(scope, scope, null, null, 0, null)); + } + + @Test + void queueJoinResultsEnforceTypedSuccessAndFailureOutcomes() { + QueuedPlayerSnapshot entry = queuedPlayer(); + + for (QueueJoinStatus status : new QueueJoinStatus[] { + QueueJoinStatus.JOINED, + QueueJoinStatus.MOVED, + QueueJoinStatus.ALREADY_QUEUED + }) { + assertEquals(entry, QueueJoinResult.success(status, entry).entry().orElseThrow()); + } + assertTrue(QueueJoinResult.failure(QueueJoinStatus.REJECTED).entry().isEmpty()); + assertThrows(IllegalArgumentException.class, + () -> QueueJoinResult.failure(QueueJoinStatus.JOINED)); + assertThrows(IllegalArgumentException.class, + () -> QueueJoinResult.success(QueueJoinStatus.REJECTED, entry)); + assertThrows(NullPointerException.class, + () -> new QueueJoinResult(null, Optional.empty())); + assertThrows(NullPointerException.class, + () -> QueueJoinResult.success(QueueJoinStatus.JOINED, null)); + } + + @Test + void queueRequestsAndSnapshotsAreValidatedAndImmutable() { + UUID playerId = UUID.randomUUID(); + ServerId server = ServerId.of("survival"); + QueueJoinRequest request = new QueueJoinRequest(playerId, server, null); + assertEquals(QueueJoinCause.PLUGIN, request.cause()); + assertThrows(NullPointerException.class, + () -> new QueueJoinRequest(null, server, QueueJoinCause.MANUAL)); + assertThrows(NullPointerException.class, + () -> new QueueJoinRequest(playerId, null, QueueJoinCause.MANUAL)); + + QueuedPlayerSnapshot queued = queuedPlayer(); + assertEquals(1, queued.position()); + assertThrows(IllegalArgumentException.class, + () -> new QueuedPlayerSnapshot(playerId, server, 0, 0, Instant.EPOCH, false)); + assertThrows(NullPointerException.class, + () -> new QueuedPlayerSnapshot(playerId, server, 1, 0, null, false)); + + QueueServerSnapshot serverSnapshot = new QueueServerSnapshot( + server, -1, -1, 2, 3, -1, null + ); + assertEquals(0, serverSnapshot.waiting()); + assertEquals(0, serverSnapshot.connected()); + assertEquals(2, serverSnapshot.grace()); + assertEquals(3, serverSnapshot.inFlight()); + assertTrue(serverSnapshot.oldestEnqueuedAt().isEmpty()); + + Map values = new LinkedHashMap<>(); + values.put(server, new QueueServerSnapshot(server, 4, 0, 0, 2, 0, Optional.of(Instant.EPOCH))); + QueueSnapshot snapshot = new QueueSnapshot(values, Instant.EPOCH); + values.clear(); + assertEquals(4, snapshot.totalWaiting()); + assertEquals(2, snapshot.totalInFlight()); + assertThrows(UnsupportedOperationException.class, () -> snapshot.servers().clear()); + assertTrue(new QueueSnapshot(null, Instant.EPOCH).servers().isEmpty()); + assertThrows(NullPointerException.class, () -> new QueueSnapshot(Map.of(), null)); + } + + private static QueuedPlayerSnapshot queuedPlayer() { + return new QueuedPlayerSnapshot( + UUID.randomUUID(), ServerId.of("survival"), 1, 10, Instant.EPOCH, false + ); + } + + private static final class TestLease implements AdmissionLease { + private final UUID id = UUID.randomUUID(); + private boolean active = true; + + @Override public UUID id() { return id; } + @Override public UUID playerId() { return id; } + @Override public ServerId targetServer() { return ServerId.of("survival"); } + @Override public AdmissionIntent intent() { return AdmissionIntent.NORMAL; } + @Override public Instant expiresAt() { return Instant.MAX; } + @Override public boolean isActive() { return active; } + @Override public LeaseState state() { return active ? LeaseState.ACTIVE : LeaseState.RELEASED; } + @Override public long providerGeneration() { return 1L; } + @Override public LeaseTerminalResult commit() { active = false; return new LeaseTerminalResult(LeaseState.COMMITTED, 1L); } + @Override public LeaseTerminalResult release() { active = false; return new LeaseTerminalResult(LeaseState.RELEASED, 1L); } + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/DomainContractsTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/DomainContractsTest.java new file mode 100644 index 00000000..83f00c2e --- /dev/null +++ b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/DomainContractsTest.java @@ -0,0 +1,196 @@ +package nl.hauntedmc.proxyfeatures.api; + +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCounts; +import nl.hauntedmc.proxyfeatures.api.capability.operations.*; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceChangedEvent; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContext; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContribution; +import nl.hauntedmc.proxyfeatures.api.feature.*; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +class DomainContractsTest { + + @Test + void playerCountsAndSnapshotsRemainConsistentAndImmutable() { + PlayerCounts counts = new PlayerCounts(8, 3); + assertEquals(5, counts.visible()); + assertEquals(new PlayerCounts(0, 0), PlayerCounts.empty()); + assertThrows(IllegalArgumentException.class, () -> new PlayerCounts(-1, 0)); + assertThrows(IllegalArgumentException.class, () -> new PlayerCounts(1, -1)); + assertThrows(IllegalArgumentException.class, () -> new PlayerCounts(1, 2)); + + ServerId lobby = ServerId.of("lobby"); + Map servers = new LinkedHashMap<>(); + servers.put(lobby, counts); + PlayerCountSnapshot snapshot = new PlayerCountSnapshot(counts, servers, Instant.EPOCH); + servers.clear(); + assertEquals(counts, snapshot.server(lobby)); + assertEquals(PlayerCounts.empty(), snapshot.server(ServerId.of("missing"))); + assertThrows(NullPointerException.class, () -> snapshot.server(null)); + assertTrue(new PlayerCountSnapshot(counts, null, Instant.EPOCH).servers().isEmpty()); + assertThrows(NullPointerException.class, + () -> new PlayerCountSnapshot(null, Map.of(), Instant.EPOCH)); + assertThrows(NullPointerException.class, + () -> new PlayerCountSnapshot(counts, Map.of(), null)); + } + + @Test + void presenceSnapshotsValidateVisibilityAndDeriveVisiblePlayers() { + UUID visible = UUID.randomUUID(); + UUID hidden = UUID.randomUUID(); + Set online = new LinkedHashSet<>(List.of(visible, hidden)); + PresenceSnapshot snapshot = new PresenceSnapshot(online, Set.of(hidden), Instant.EPOCH); + online.clear(); + + assertEquals(2, snapshot.onlineCount()); + assertEquals(1, snapshot.hiddenCount()); + assertEquals(1, snapshot.visibleCount()); + assertEquals(Set.of(visible), snapshot.visiblePlayers()); + assertTrue(new PresenceSnapshot(null, null, Instant.EPOCH).onlinePlayers().isEmpty()); + assertThrows(IllegalArgumentException.class, + () -> new PresenceSnapshot(Set.of(visible), Set.of(hidden), Instant.EPOCH)); + assertThrows(NullPointerException.class, + () -> new PresenceSnapshot(Set.of(), Set.of(), null)); + + PresenceChangedEvent event = new PresenceChangedEvent(hidden, null, true, Instant.EPOCH); + assertEquals("", event.playerName()); + assertThrows(NullPointerException.class, + () -> new PresenceChangedEvent(null, "player", true, Instant.EPOCH)); + assertThrows(NullPointerException.class, + () -> new PresenceChangedEvent(hidden, "player", true, null)); + } + + @Test + void maintenanceValueObjectsRepresentGlobalAndServerScopes() { + MaintenanceScope global = MaintenanceScope.global(); + MaintenanceScope server = MaintenanceScope.server(ServerId.of("survival")); + assertTrue(global.isGlobal()); + assertFalse(server.isGlobal()); + assertTrue(new MaintenanceScope(null).isGlobal()); + assertThrows(NullPointerException.class, () -> MaintenanceScope.server(null)); + + Set servers = new LinkedHashSet<>(Set.of(ServerId.of("survival"))); + MaintenanceSnapshot snapshot = new MaintenanceSnapshot(true, servers, Instant.EPOCH); + servers.clear(); + assertEquals(1, snapshot.servers().size()); + assertTrue(new MaintenanceSnapshot(false, null, Instant.EPOCH).servers().isEmpty()); + assertThrows(NullPointerException.class, + () -> new MaintenanceSnapshot(false, Set.of(), null)); + } + + @Test + void versionAndFriendshipDefaultMethodsProvideUsefulDerivedDecisions() { + VersionApi versions = new VersionApi() { + @Override public int minimumProtocolVersion() { return 765; } + @Override public String minimumVersionName() { return "1.20.4"; } + }; + assertTrue(versions.isSupported(765)); + assertFalse(versions.isSupported(764)); + + FriendshipApi friends = (first, second) -> CompletableFuture.completedFuture(first.equals(second)); + UUID player = UUID.randomUUID(); + assertTrue(friends.areFriends(player, player).toCompletableFuture().join()); + assertFalse(friends.areFriends(player, UUID.randomUUID()).toCompletableFuture().join()); + } + + @Test + void sanctionsArePersistenceIndependentImmutableViews() { + UUID player = UUID.randomUUID(); + SanctionSnapshot snapshot = new SanctionSnapshot( + 10L, + player, + SanctionType.BAN, + "reason", + "actor", + Instant.EPOCH, + null, + true + ); + assertTrue(snapshot.expiresAt().isEmpty()); + assertEquals(player, snapshot.playerId()); + assertThrows(NullPointerException.class, + () -> new SanctionSnapshot(1, null, SanctionType.BAN, "r", "a", Instant.EPOCH, null, true)); + assertThrows(NullPointerException.class, + () -> new SanctionSnapshot(1, player, null, "r", "a", Instant.EPOCH, null, true)); + assertThrows(NullPointerException.class, + () -> new SanctionSnapshot(1, player, SanctionType.BAN, null, "a", Instant.EPOCH, null, true)); + assertThrows(NullPointerException.class, + () -> new SanctionSnapshot(1, player, SanctionType.BAN, "r", null, Instant.EPOCH, null, true)); + assertThrows(NullPointerException.class, + () -> new SanctionSnapshot(1, player, SanctionType.BAN, "r", "a", null, null, true)); + } + + @Test + void motdExtensionValuesNormalizeOptionalOverrides() throws Exception { + MotdContribution contribution = new MotdContribution( + Optional.of(" line one "), + Optional.of(" ") + ); + assertEquals(Optional.of("line one"), contribution.firstLine()); + assertTrue(contribution.secondLine().isEmpty()); + assertEquals(Optional.of("status"), MotdContribution.secondLine("status").secondLine()); + assertTrue(new MotdContribution(null, null).firstLine().isEmpty()); + assertThrows(NullPointerException.class, () -> MotdContribution.secondLine(null)); + + MotdContext context = new MotdContext(InetAddress.getLoopbackAddress(), 765); + assertEquals(765, context.protocolVersion()); + assertThrows(NullPointerException.class, () -> new MotdContext(null, 765)); + } + + @Test + void featureDescriptorsAndSnapshotsAreNormalizedImmutableContracts() { + FeatureId queue = FeatureId.of("queue"); + Set dependencies = new LinkedHashSet<>(Set.of(FeatureId.of("capacity"))); + Set capabilities = new LinkedHashSet<>(Set.of("QueueApi")); + FeatureDescriptor descriptor = new FeatureDescriptor( + queue, + " Queue ", + " 1.0.0 ", + FeatureClassification.CAPABILITY_PROVIDER, + dependencies, + capabilities + ); + dependencies.clear(); + capabilities.clear(); + assertEquals("Queue", descriptor.displayName()); + assertEquals("1.0.0", descriptor.version()); + assertEquals(Set.of(FeatureId.of("capacity")), descriptor.requiredFeatures()); + assertEquals(Set.of("QueueApi"), descriptor.providedCapabilities()); + assertThrows(NullPointerException.class, + () -> new FeatureDescriptor(null, "x", "1", FeatureClassification.INTERNAL, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new FeatureDescriptor(queue, " ", "1", FeatureClassification.INTERNAL, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new FeatureDescriptor(queue, "x", " ", FeatureClassification.INTERNAL, null, null)); + assertThrows(NullPointerException.class, + () -> new FeatureDescriptor(queue, "x", "1", null, null, null)); + + FeatureSnapshot active = new FeatureSnapshot(descriptor, FeatureState.ACTIVE, null, Instant.EPOCH); + assertTrue(active.failure().isEmpty()); + FeatureSnapshot failed = new FeatureSnapshot( + descriptor, FeatureState.FAILED, Optional.of("boom"), Instant.EPOCH + ); + assertEquals(Optional.of("boom"), failed.failure()); + assertTrue(new FeatureSnapshot(descriptor, FeatureState.FAILED, Optional.of(" "), Instant.EPOCH) + .failure().isEmpty()); + assertThrows(NullPointerException.class, + () -> new FeatureSnapshot(null, FeatureState.ACTIVE, null, Instant.EPOCH)); + assertThrows(NullPointerException.class, + () -> new FeatureSnapshot(descriptor, null, null, Instant.EPOCH)); + assertThrows(NullPointerException.class, + () -> new FeatureSnapshot(descriptor, FeatureState.ACTIVE, null, null)); + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/IdentityAndServiceContractsTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/IdentityAndServiceContractsTest.java new file mode 100644 index 00000000..2f67d971 --- /dev/null +++ b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/IdentityAndServiceContractsTest.java @@ -0,0 +1,90 @@ +package nl.hauntedmc.proxyfeatures.api; + +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRef; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityUnavailableException; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class IdentityAndServiceContractsTest { + + @Test + void versionsAreNormalizedAndCurrentFactoryKeepsTheApiVersion() { + ProxyFeaturesApiVersion version = new ProxyFeaturesApiVersion(" 3.3.0 ", " build-12 "); + + assertEquals("3.3.0", version.apiVersion()); + assertEquals("build-12", version.implementationVersion()); + assertEquals(ProxyFeaturesApiVersion.CURRENT, + ProxyFeaturesApiVersion.current("runtime").apiVersion()); + assertThrows(NullPointerException.class, () -> new ProxyFeaturesApiVersion(null, "runtime")); + assertThrows(IllegalArgumentException.class, () -> new ProxyFeaturesApiVersion(" ", "runtime")); + assertThrows(IllegalArgumentException.class, () -> new ProxyFeaturesApiVersion("3.3.0", " ")); + } + + @Test + void featureIdsAreStableNormalizedComparableValues() { + FeatureId id = FeatureId.of(" Queue.Main_1 "); + + assertEquals("queue.main_1", id.value()); + assertEquals("queue.main_1", id.toString()); + assertTrue(id.compareTo(FeatureId.of("vanish")) < 0); + assertThrows(NullPointerException.class, () -> id.compareTo(null)); + assertThrows(NullPointerException.class, () -> FeatureId.of(null)); + assertThrows(IllegalArgumentException.class, () -> FeatureId.of("")); + assertThrows(IllegalArgumentException.class, () -> FeatureId.of("-queue")); + assertThrows(IllegalArgumentException.class, () -> FeatureId.of("queue/name")); + } + + @Test + void serverIdsNormalizeAndSupportOptionalParsing() { + ServerId id = ServerId.of(" Lobby.One_1 "); + + assertEquals("lobby.one_1", id.value()); + assertEquals("lobby.one_1", id.toString()); + assertEquals(Optional.of(id), ServerId.optional("LOBBY.ONE_1")); + assertTrue(ServerId.optional(null).isEmpty()); + assertTrue(ServerId.optional(" ").isEmpty()); + assertTrue(id.compareTo(ServerId.of("survival")) < 0); + assertThrows(NullPointerException.class, () -> id.compareTo(null)); + assertThrows(NullPointerException.class, () -> ServerId.of(null)); + assertThrows(IllegalArgumentException.class, () -> ServerId.of(" ")); + assertThrows(IllegalArgumentException.class, () -> ServerId.of("bad/server")); + } + + @Test + void capabilityReferencesRemainStableAcrossAvailabilityChanges() { + AtomicReference current = new AtomicReference<>(); + CapabilityRef reference = new CapabilityRef<>() { + @Override + public Class type() { + return Runnable.class; + } + + @Override + public Optional get() { + return Optional.ofNullable(current.get()); + } + }; + + assertFalse(reference.isAvailable()); + assertTrue(reference.generation().isEmpty()); + CapabilityUnavailableException unavailable = assertThrows( + CapabilityUnavailableException.class, + reference::require + ); + assertEquals(Runnable.class, unavailable.capabilityType()); + assertTrue(unavailable.getMessage().contains(Runnable.class.getName())); + + Runnable provider = () -> { }; + current.set(provider); + assertTrue(reference.isAvailable()); + assertEquals(1L, reference.generation().orElseThrow()); + assertSame(provider, reference.require()); + assertThrows(NullPointerException.class, () -> new CapabilityUnavailableException(null)); + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountSnapshotTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountSnapshotTest.java new file mode 100644 index 00000000..8c8feb9a --- /dev/null +++ b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/capability/network/PlayerCountSnapshotTest.java @@ -0,0 +1,33 @@ +package nl.hauntedmc.proxyfeatures.api.capability.network; + +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PlayerCountSnapshotTest { + + @Test + void rejectsMissingNetworkAndServerCounts() { + assertThrows( + NullPointerException.class, + () -> new PlayerCountSnapshot(null, Map.of(), Instant.now()) + ); + + Map servers = new java.util.HashMap<>(); + servers.put(ServerId.of("survival"), null); + assertThrows( + NullPointerException.class, + () -> new PlayerCountSnapshot(PlayerCounts.empty(), servers, Instant.now()) + ); + } + + @Test + void rejectsInvalidCounts() { + assertThrows(IllegalArgumentException.class, () -> new PlayerCounts(1, 2)); + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityApiTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityApiTest.java deleted file mode 100644 index 04d2c909..00000000 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityApiTest.java +++ /dev/null @@ -1,242 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; - -import nl.hauntedmc.proxyfeatures.api.queue.QueueAdmissionAPI; -import nl.hauntedmc.proxyfeatures.api.queue.QueueObservabilityAPI; -import nl.hauntedmc.proxyfeatures.api.queue.QueueServerSnapshot; -import nl.hauntedmc.proxyfeatures.api.queue.QueueSnapshot; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class CapacityApiTest { - - @Test - void admissionIntentsExposeOnlyNormalAsQueueable() { - assertTrue(AdmissionIntent.NORMAL.isQueueable()); - for (AdmissionIntent intent : AdmissionIntent.values()) { - if (intent != AdmissionIntent.NORMAL) assertFalse(intent.isQueueable()); - } - } - - @Test - void capacityStatesExposeOpenAdmissionContract() { - assertTrue(CapacityState.OPEN.acceptsNormalAdmissions()); - assertFalse(CapacityState.DRAINING.acceptsNormalAdmissions()); - assertFalse(CapacityState.CLOSED.acceptsNormalAdmissions()); - assertFalse(CapacityState.OFFLINE.acceptsNormalAdmissions()); - } - - @Test - void capacityRequestNormalizesAndDefaultsIntent() { - UUID playerId = UUID.randomUUID(); - CapacityRequest request = new CapacityRequest( - playerId, - " Lobby ", - " Survival ", - null, - true, - false, - true - ); - - assertEquals(playerId, request.playerId()); - assertEquals("lobby", request.previousServer()); - assertEquals("survival", request.targetServer()); - assertEquals(AdmissionIntent.NORMAL, request.intent()); - assertTrue(request.reservedEligible()); - assertFalse(request.capacityBypass()); - assertTrue(request.stateBypass()); - assertEquals("", CapacityRequest.normalize(null)); - } - - @Test - void capacityRequestFactoryAndValidationAreStrict() { - UUID playerId = UUID.randomUUID(); - CapacityRequest request = CapacityRequest.normal(playerId, null, "SURVIVAL", true, true, false); - assertEquals("", request.previousServer()); - assertEquals("survival", request.targetServer()); - assertEquals(AdmissionIntent.NORMAL, request.intent()); - assertThrows(NullPointerException.class, () -> CapacityRequest.normal( - null, "", "survival", false, false, false)); - assertThrows(IllegalArgumentException.class, () -> CapacityRequest.normal( - playerId, "", " ", false, false, false)); - } - - @Test - void decisionsRequireLeaseForAllowedResults() { - TestLease lease = new TestLease(); - CapacityDecision allowed = CapacityDecision.allow(lease); - assertTrue(allowed.allowed()); - assertEquals(CapacityDenialReason.NONE, allowed.reason()); - assertEquals("", allowed.blockingScope()); - assertEquals(lease, allowed.lease()); - - CapacityDecision denied = CapacityDecision.deny(CapacityDenialReason.FULL, null); - assertFalse(denied.allowed()); - assertEquals(CapacityDenialReason.FULL, denied.reason()); - assertEquals("", denied.blockingScope()); - assertNull(denied.lease()); - - CapacityDecision normalized = new CapacityDecision(false, null, null, null); - assertEquals(CapacityDenialReason.INVALID_REQUEST, normalized.reason()); - assertThrows(NullPointerException.class, - () -> new CapacityDecision(true, CapacityDenialReason.NONE, "", null)); - } - - @Test - void scopeSnapshotCalculatesBothNormalAndAbsoluteHeadroom() { - CapacityScopeSnapshot scope = new CapacityScopeSnapshot( - "survival", 100, 10, 80, 4, 3, CapacityState.OPEN); - assertEquals(87, scope.effectiveUsed()); - assertEquals(90, scope.normalCapacity()); - assertEquals(3, scope.normalAvailable()); - assertEquals(13, scope.absoluteAvailable()); - - CapacityScopeSnapshot overfull = new CapacityScopeSnapshot( - "survival", 10, 2, 12, 3, 1, CapacityState.CLOSED); - assertEquals(16, overfull.effectiveUsed()); - assertEquals(0, overfull.normalAvailable()); - assertEquals(0, overfull.absoluteAvailable()); - } - - @Test - void snapshotsDefensivelyCopyMapsAndHandleNull() { - Map groups = new HashMap<>(); - groups.put("game", new CapacityScopeSnapshot("game", 10, 0, 1, 0, 0, CapacityState.OPEN)); - CapacitySnapshot snapshot = new CapacitySnapshot(null, null, groups, null, 2); - groups.clear(); - - assertEquals(1, snapshot.groups().size()); - assertTrue(snapshot.servers().isEmpty()); - assertThrows(UnsupportedOperationException.class, - () -> snapshot.groups().put("other", snapshot.groups().get("game"))); - - CapacitySnapshot empty = new CapacitySnapshot(null, null, null, null, 0); - assertNotSame(snapshot.groups(), empty.groups()); - assertTrue(empty.groups().isEmpty()); - assertTrue(empty.servers().isEmpty()); - } - - @Test - void queueCancellationFenceDefaultsToUnsupported() { - QueueAdmissionAPI queue = new QueueAdmissionAPI() { - @Override - public boolean isQueueEnabled(String serverName) { - return false; - } - - @Override - public boolean enqueue(com.velocitypowered.api.proxy.Player player, String serverName, - CapacityDenialReason reason, CapacityRequest admissionContext) { - return false; - } - - @Override - public void wake(String serverName) { - } - }; - - assertFalse(queue.consumeCancelledAdvance(UUID.randomUUID(), "survival")); - } - - @Test - void queueObservabilitySnapshotsNormalizeAndDefensivelyCopy() { - Instant oldest = Instant.parse("2026-08-03T10:00:00Z"); - QueueServerSnapshot server = new QueueServerSnapshot( - " Survival ", -1, 3, 2, 1, 4, oldest); - assertEquals("survival", server.server()); - assertEquals(0, server.waiting()); - assertEquals(3, server.connected()); - assertEquals(oldest, server.oldestEnqueuedAt()); - - Map values = new HashMap<>(); - values.put("survival", new QueueServerSnapshot("survival", 5, 3, 1, 2, 1, oldest)); - QueueSnapshot snapshot = new QueueSnapshot(values, null); - values.clear(); - - assertEquals(5, snapshot.totalWaiting()); - assertEquals(2, snapshot.totalInFlight()); - assertEquals(1, snapshot.servers().size()); - assertThrows(UnsupportedOperationException.class, - () -> snapshot.servers().put("other", server)); - - QueueObservabilityAPI observability = () -> snapshot; - assertEquals(snapshot, observability.snapshot()); - assertTrue(new QueueSnapshot(null, oldest).servers().isEmpty()); - } - - @Test - void leaseCloseDelegatesToRelease() { - TestLease lease = new TestLease(); - assertTrue(lease.isActive()); - lease.close(); - assertFalse(lease.isActive()); - assertTrue(lease.released.get()); - lease.commit(); - assertTrue(lease.committed.get()); - } - - @Test - void denialReasonEnumRemainsComplete() { - assertEquals(6, CapacityDenialReason.values().length); - assertEquals(CapacityDenialReason.FULL, CapacityDenialReason.valueOf("FULL")); - } - - private static final class TestLease implements CapacityLease { - private final UUID id = UUID.randomUUID(); - private final UUID playerId = UUID.randomUUID(); - private final AtomicBoolean released = new AtomicBoolean(); - private final AtomicBoolean committed = new AtomicBoolean(); - - @Override - public UUID id() { - return id; - } - - @Override - public UUID playerId() { - return playerId; - } - - @Override - public String targetServer() { - return "survival"; - } - - @Override - public AdmissionIntent intent() { - return AdmissionIntent.NORMAL; - } - - @Override - public Instant expiresAt() { - return Instant.MAX; - } - - @Override - public boolean isActive() { - return !released.get(); - } - - @Override - public void commit() { - committed.set(true); - } - - @Override - public void release() { - released.set(true); - } - } -} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/SqliteCacheFileTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/SqliteCacheFileTest.java deleted file mode 100644 index ceaa5578..00000000 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/SqliteCacheFileTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache.impl; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.MockedStatic; - -import java.io.File; -import java.io.Serial; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.DriverManager; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mockStatic; - -class SqliteCacheFileTest { - - @TempDir - Path tempDir; - - @Test - void createsUnderlyingFileAndSupportsDeleteAndNoopMethods() { - Path db = tempDir.resolve("nested/cache.db"); - SqliteCacheFile file = new SqliteCacheFile(db.toFile()); - assertTrue(file.getUnderlyingFile().exists()); - assertFalse(file.isEmpty()); - - file.cleanupExpired(); - file.delete(); - assertFalse(file.getUnderlyingFile().exists()); - } - - @Test - void getConnectionEitherReturnsConnectionOrWrapsFailure() { - SqliteCacheFile file = new SqliteCacheFile(tempDir.resolve("c.db").toFile()); - try { - assertNotNull(file.getConnection()); - file.getConnection().close(); - } catch (RuntimeException ex) { - assertNotNull(ex.getCause()); - } catch (Exception ex) { - fail(ex); - } - } - - @Test - void constructorAndDeleteFailurePathsAreCovered() throws Exception { - Path parentFile = tempDir.resolve("not-a-dir"); - java.nio.file.Files.writeString(parentFile, "x"); - assertThrows(RuntimeException.class, () -> new SqliteCacheFile(parentFile.resolve("cache.db").toFile())); - - FailingDeleteFile failingDelete = new FailingDeleteFile(tempDir.resolve("fail-delete.db").toString()); - SqliteCacheFile file = new SqliteCacheFile(failingDelete); - file.delete(); - assertTrue(file.getUnderlyingFile().exists()); - } - - @Test - void getConnectionWrapsDriverManagerFailure() { - SqliteCacheFile file = new SqliteCacheFile(tempDir.resolve("driver-fail.db").toFile()); - try (MockedStatic mocked = mockStatic(DriverManager.class)) { - mocked.when(() -> DriverManager.getConnection(anyString())).thenThrow(new IllegalStateException("boom")); - RuntimeException ex = assertThrows(RuntimeException.class, file::getConnection); - assertNotNull(ex.getCause()); - } - } - - @Test - void getConnectionReturnsDriverManagerConnectionWhenAvailable() throws Exception { - SqliteCacheFile file = new SqliteCacheFile(tempDir.resolve("driver-ok.db").toFile()); - Connection connection = org.mockito.Mockito.mock(Connection.class); - try (MockedStatic mocked = mockStatic(DriverManager.class)) { - mocked.when(() -> DriverManager.getConnection(anyString())).thenReturn(connection); - assertSame(connection, file.getConnection()); - } - } - - private static final class FailingDeleteFile extends File { - @Serial - private static final long serialVersionUID = 1L; - - FailingDeleteFile(String pathname) { - super(pathname); - } - - @Override - public boolean delete() { - return false; - } - - @Override - public boolean exists() { - return true; - } - } -} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/packet/PacketManagerTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/packet/PacketManagerTest.java deleted file mode 100644 index 01afbd1d..00000000 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/packet/PacketManagerTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.packet; - -import com.velocitypowered.api.proxy.Player; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.mockito.Mockito.*; - -class PacketManagerTest { - - @Test - void sendUnicastSendsAllPacketsToSinglePlayer() { - Player player = mock(Player.class); - Packet first = mock(Packet.class); - Packet second = mock(Packet.class); - - PacketManager.sendUnicast(player, first, second); - - verify(first).sendTo(player); - verify(second).sendTo(player); - } - - @Test - void sendMulticastSendsAllPacketsToAllTargets() { - Player a = mock(Player.class); - Player b = mock(Player.class); - Packet packet = mock(Packet.class); - - PacketManager.sendMulticast(List.of(a, b), packet); - - verify(packet).sendTo(a); - verify(packet).sendTo(b); - } - - @Test - void sendBroadcastUsesProvidedPlayers() { - Player a = mock(Player.class); - Player b = mock(Player.class); - Packet packet = mock(Packet.class); - - PacketManager.sendBroadcast(List.of(a, b), packet); - - verify(packet).sendTo(a); - verify(packet).sendTo(b); - } - - @Test - void sendMethodsHandleEmptyInputsWithoutInteraction() { - Player player = mock(Player.class); - Packet packet = mock(Packet.class); - PacketManager.sendUnicast(player); - PacketManager.sendMulticast(List.of(), packet); - PacketManager.sendBroadcast(List.of(), packet); - verifyNoInteractions(packet); - } -} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/resource/ResourceHandlerTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/resource/ResourceHandlerTest.java deleted file mode 100644 index 10a5685a..00000000 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/resource/ResourceHandlerTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.io.resource; - -import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesContext; -import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class ResourceHandlerTest { - - @TempDir - Path tempDir; - - @Test - void loadsSavesAndReloadsExistingResourceFile() throws Exception { - Path file = tempDir.resolve("lang/messages.yml"); - Files.createDirectories(file.getParent()); - Files.writeString(file, "general:\n usage: \"hello\"\n"); - - ProxyFeaturesContext plugin = mockPlugin(); - ResourceHandler handler = new ResourceHandler(plugin, "lang/messages.yml"); - assertNotNull(handler.getConfig()); - assertEquals("hello", handler.getConfig().node("general", "usage").getString()); - - handler.getConfig().node("general", "usage").raw("updated"); - handler.save(); - handler.reload(); - assertEquals("updated", handler.getConfig().node("general", "usage").getString()); - } - - @Test - void rejectsPathsThatEscapeDataDirectory() { - ProxyFeaturesContext plugin = mockPlugin(); - assertThrows(IllegalArgumentException.class, () -> new ResourceHandler(plugin, "../outside.yml")); - } - - @Test - void copiesDefaultResourceWhenMissing() throws Exception { - ProxyFeaturesContext plugin = mockPlugin(); - Path copied = tempDir.resolve("resource-default.yml"); - assertFalse(Files.exists(copied)); - - ResourceHandler handler = new ResourceHandler(plugin, "resource-default.yml"); - - assertNotNull(handler.getConfig()); - assertTrue(Files.exists(copied)); - assertTrue(Files.readString(copied).contains("from-test-resource")); - } - - @Test - void copiesNestedDefaultResourceWhenMissing() throws Exception { - ProxyFeaturesContext plugin = mockPlugin(); - Path copied = tempDir.resolve("nested/resource-default.yml"); - assertFalse(Files.exists(copied)); - - ResourceHandler handler = new ResourceHandler(plugin, "nested/resource-default.yml"); - - assertNotNull(handler.getConfig()); - assertTrue(Files.exists(copied)); - assertTrue(Files.readString(copied).contains("from-nested-test-resource")); - } - - @Test - void createsEmptyFileWhenDefaultResourceIsMissing() throws Exception { - ProxyFeaturesContext plugin = mockPlugin(); - Path copied = tempDir.resolve("missing-resource.yml"); - assertFalse(Files.exists(copied)); - - ResourceHandler handler = new ResourceHandler(plugin, "missing-resource.yml"); - - assertNotNull(handler.getConfig()); - assertTrue(Files.exists(copied)); - assertEquals("", Files.readString(copied)); - } - - @Test - void fallsBackToBasenameResourceWhenScopedPathIsMissing() throws Exception { - ProxyFeaturesContext plugin = mockPlugin(); - Path copied = tempDir.resolve("scoped/resource-default.yml"); - assertFalse(Files.exists(copied)); - - ResourceHandler handler = new ResourceHandler(plugin, "scoped/resource-default.yml"); - - assertNotNull(handler.getConfig()); - assertTrue(Files.exists(copied)); - assertTrue(Files.readString(copied).contains("from-test-resource")); - } - - @Test - void ensureAndSaveErrorBranchesAreHandled() throws Exception { - ProxyFeaturesContext plugin = mockPlugin(); - - Path blockedParent = tempDir.resolve("blocked"); - Files.writeString(blockedParent, "x"); - ResourceHandler blocked = new ResourceHandler(plugin, "blocked/child.yml"); - assertNotNull(blocked.getConfig()); - assertFalse(Files.exists(tempDir.resolve("blocked/child.yml"))); - - Path path = tempDir.resolve("save-fail.yml"); - Files.writeString(path, "v: 1\n"); - ResourceHandler handler = new ResourceHandler(plugin, "save-fail.yml"); - Files.delete(path); - Files.createDirectory(path); - - handler.save(); - assertEquals(1, handler.getConfig().node("v").getInt()); - } - - private ProxyFeaturesContext mockPlugin() { - ProxyFeaturesContext plugin = mock(ProxyFeaturesContext.class); - when(plugin.getDataDirectory()).thenReturn(tempDir); - when(plugin.getLogger()).thenReturn(ComponentLogger.logger("ResourceHandlerTest")); - when(plugin.getResourceClassLoader()).thenReturn(getClass().getClassLoader()); - return plugin; - } -} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/http/DiscordUtilsTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/http/DiscordUtilsTest.java deleted file mode 100644 index a0ee6a51..00000000 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/http/DiscordUtilsTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.util.http; - -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class DiscordUtilsTest { - - @Test - void sendPayloadReturnsFalseForInvalidOrUnsupportedWebhookUrls() { - assertFalse(DiscordUtils.sendPayload(null, "{}")); - assertFalse(DiscordUtils.sendPayload(" ", "{}")); - assertFalse(DiscordUtils.sendPayload("http://localhost/webhook", "{}")); - assertFalse(DiscordUtils.sendPayload("not-a-url", "{}")); - } - - @Test - void sendPayloadHandlesRuntimeFailuresByReturningFalse() { - // HTTPS scheme passes validation, but connection should fail in tests. - assertFalse(DiscordUtils.sendPayload("https://127.0.0.1:1/webhook", "{\"a\":1}")); - } - - @Test - void sendPayloadHandlesSuccessNoContentAndErrorResponses() throws Exception { - URI uri = mock(URI.class); - URL url = mock(URL.class); - HttpURLConnection connection = mock(HttpURLConnection.class); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - when(uri.getScheme()).thenReturn("https"); - when(uri.toURL()).thenReturn(url); - when(url.openConnection()).thenReturn(connection); - when(connection.getOutputStream()).thenReturn(output); - when(connection.getInputStream()).thenReturn(new ByteArrayInputStream("ok".getBytes(StandardCharsets.UTF_8))); - when(connection.getErrorStream()).thenReturn(new ByteArrayInputStream("err".getBytes(StandardCharsets.UTF_8))); - - try (MockedStatic mockedUri = mockStatic(URI.class)) { - mockedUri.when(() -> URI.create("https://example.test/hook")).thenReturn(uri); - - when(connection.getResponseCode()) - .thenReturn(HttpURLConnection.HTTP_OK) - .thenReturn(HttpURLConnection.HTTP_NO_CONTENT) - .thenReturn(HttpURLConnection.HTTP_BAD_REQUEST); - - assertTrue(DiscordUtils.sendPayload("https://example.test/hook", "{\"a\":1}")); - assertTrue(DiscordUtils.sendPayload("https://example.test/hook", null)); - assertFalse(DiscordUtils.sendPayload("https://example.test/hook", "{\"a\":1}")); - } - - String written = output.toString(StandardCharsets.UTF_8); - assertTrue(written.contains("{\"a\":1}")); - verify(connection, atLeastOnce()).disconnect(); - } -} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/type/CastUtilsTest.java b/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/type/CastUtilsTest.java deleted file mode 100644 index ef9ed851..00000000 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/type/CastUtilsTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package nl.hauntedmc.proxyfeatures.api.util.type; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class CastUtilsTest { - - @Test - void safeCastToListReturnsTypedListWhenAllItemsMatch() { - List result = CastUtils.safeCastToList(List.of("a", "b"), String.class); - assertEquals(List.of("a", "b"), result); - } - - @Test - void safeCastToListThrowsWhenItemTypeMismatches() { - assertThrows(ClassCastException.class, () -> CastUtils.safeCastToList(List.of("a", 1), String.class)); - } - - @Test - void safeCastToListThrowsClassCastForNullItems() { - ClassCastException ex = assertThrows(ClassCastException.class, - () -> CastUtils.safeCastToList(java.util.Arrays.asList("a", null), String.class)); - assertEquals("Expected a java.lang.String, but found: null", ex.getMessage()); - } - - @Test - void safeCastToListReturnsEmptyWhenNotAList() { - assertEquals(List.of(), CastUtils.safeCastToList("not-a-list", String.class)); - } -} diff --git a/proxyfeatures-api/src/test/resources/nested/resource-default.yml b/proxyfeatures-api/src/test/resources/nested/resource-default.yml deleted file mode 100644 index 46db1bda..00000000 --- a/proxyfeatures-api/src/test/resources/nested/resource-default.yml +++ /dev/null @@ -1 +0,0 @@ -message: from-nested-test-resource diff --git a/proxyfeatures-api/src/test/resources/resource-default.yml b/proxyfeatures-api/src/test/resources/resource-default.yml deleted file mode 100644 index 209a77a0..00000000 --- a/proxyfeatures-api/src/test/resources/resource-default.yml +++ /dev/null @@ -1 +0,0 @@ -message: from-test-resource diff --git a/proxyfeatures-contracts/pom.xml b/proxyfeatures-contracts/pom.xml index a9259561..c175f791 100644 --- a/proxyfeatures-contracts/pom.xml +++ b/proxyfeatures-contracts/pom.xml @@ -11,24 +11,14 @@ 0.09 - Platform-neutral persistence and messaging contracts shared with downstream server plugins. + Versioned messaging contracts shared with downstream server plugins. - - nl.hauntedmc.dataregistry - dataregistry-api - ${dataregistry.version} - nl.hauntedmc.dataprovider dataprovider-api ${dataprovider.version} provided - - jakarta.persistence - jakarta.persistence-api - ${jakarta.persistence.version} - org.junit.jupiter junit-jupiter diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleMessage.java b/proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/contracts/messaging/RestartLifecycleMessage.java similarity index 98% rename from proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleMessage.java rename to proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/contracts/messaging/RestartLifecycleMessage.java index 924b08bd..1d76ba70 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleMessage.java +++ b/proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/contracts/messaging/RestartLifecycleMessage.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.features.restart.messaging; +package nl.hauntedmc.proxyfeatures.contracts.messaging; import nl.hauntedmc.dataprovider.database.messaging.api.AbstractEventMessage; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleMessageTest.java b/proxyfeatures-contracts/src/test/java/nl/hauntedmc/proxyfeatures/contracts/messaging/RestartLifecycleMessageTest.java similarity index 98% rename from proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleMessageTest.java rename to proxyfeatures-contracts/src/test/java/nl/hauntedmc/proxyfeatures/contracts/messaging/RestartLifecycleMessageTest.java index 1328126e..8143c6ca 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleMessageTest.java +++ b/proxyfeatures-contracts/src/test/java/nl/hauntedmc/proxyfeatures/contracts/messaging/RestartLifecycleMessageTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.features.restart.messaging; +package nl.hauntedmc.proxyfeatures.contracts.messaging; import com.google.gson.Gson; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-platform-acceptance/consumer/src/main/java/nl/hauntedmc/proxyfeatures/acceptance/ProxyFeaturesAcceptanceConsumer.java b/proxyfeatures-platform-acceptance/consumer/src/main/java/nl/hauntedmc/proxyfeatures/acceptance/ProxyFeaturesAcceptanceConsumer.java index 11fd29ea..cae37f89 100644 --- a/proxyfeatures-platform-acceptance/consumer/src/main/java/nl/hauntedmc/proxyfeatures/acceptance/ProxyFeaturesAcceptanceConsumer.java +++ b/proxyfeatures-platform-acceptance/consumer/src/main/java/nl/hauntedmc/proxyfeatures/acceptance/ProxyFeaturesAcceptanceConsumer.java @@ -7,13 +7,28 @@ import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.dataregistry.api.DataRegistryApiProvider; -import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesContext; +import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesApi; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionApi; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueApi; +import nl.hauntedmc.proxyfeatures.api.extension.ExtensionRegistration; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContribution; +import nl.hauntedmc.proxyfeatures.api.extension.MotdExtensions; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureState; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRef; import org.slf4j.Logger; -/** Verifies the bundled ProxyFeatures artifact against the current shared platform APIs. */ +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Verifies the bundled runtime through only its published API artifact. */ @Plugin(id = "proxyfeatures-acceptance", name = "ProxyFeatures Acceptance", version = "1.0.0", dependencies = {@Dependency(id = "dataprovider"), @Dependency(id = "dataregistry"), @Dependency(id = "proxyfeatures")}) public final class ProxyFeaturesAcceptanceConsumer { + private static final FeatureId CAPACITY = FeatureId.of("capacity"); + private static final int EXPECTED_FEATURES = 33; + private final ProxyServer proxy; private final Logger logger; @@ -25,29 +40,101 @@ public ProxyFeaturesAcceptanceConsumer(ProxyServer proxy, Logger logger) { @Subscribe public void onProxyInitialize(ProxyInitializeEvent event) { + proxy.getScheduler().buildTask(this, this::verifyInitialState).schedule(); + } + + private void verifyInitialState() { + try { + DataRegistryApiProvider provider = proxy.getPluginManager().getPlugin("dataregistry") + .flatMap(container -> container.getInstance()) + .filter(DataRegistryApiProvider.class::isInstance) + .map(DataRegistryApiProvider.class::cast) + .orElseThrow(() -> new IllegalStateException("DataRegistry does not expose its public API.")); + awaitReady(provider); + + ProxyFeaturesApi api = proxy.getPluginManager().getPlugin("proxyfeatures") + .flatMap(container -> container.getInstance()) + .filter(ProxyFeaturesApi.class::isInstance) + .map(ProxyFeaturesApi.class::cast) + .orElseThrow(() -> new IllegalStateException("ProxyFeatures does not expose its public API.")); + require(api.version().apiVersion().equals("3.3.0"), + "Unexpected ProxyFeatures API version: " + api.version()); + require(api.features().snapshot().size() == EXPECTED_FEATURES, + "Expected all 33 built-in features in the public catalog."); + require(api.features().find(CAPACITY) + .filter(snapshot -> snapshot.state() == FeatureState.ACTIVE) + .isPresent(), + "Capacity is not active in the public feature catalog."); + + CapabilityRef admission = api.capabilities().reference(AdmissionApi.class); + admission.require(); + long initialGeneration = admission.generation().orElseThrow(); + require(!api.capabilities().reference(QueueApi.class).isAvailable(), + "Disabled Queue capability was unexpectedly available."); + verifyExtensionLifecycle(api); + + logger.info("PROXYFEATURES_ACCEPTANCE_READY platform=velocity"); + verifyReloadTransition(api, admission, initialGeneration); + } catch (Exception exception) { + logger.error("PROXYFEATURES_ACCEPTANCE_FAIL platform=velocity", exception); + } + } + + private void verifyExtensionLifecycle(ProxyFeaturesApi api) { + MotdExtensions extensions = api.capabilities().reference(MotdExtensions.class).require(); + ExtensionRegistration registration = extensions.register( + "proxyfeatures-acceptance", + Integer.MAX_VALUE, + context -> Optional.of(MotdContribution.secondLine("acceptance")) + ); + registration.close(); + registration.close(); + } + + private void verifyReloadTransition( + ProxyFeaturesApi api, + CapabilityRef admission, + long initialGeneration + ) { + long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos(); + AtomicBoolean complete = new AtomicBoolean(); proxy.getScheduler().buildTask(this, () -> { + if (complete.get()) { + return; + } try { - Object registry = proxy.getPluginManager().getPlugin("dataregistry").flatMap(container -> container.getInstance()) - .orElseThrow(() -> new IllegalStateException("DataRegistry plugin instance is unavailable.")); - if (!(registry instanceof DataRegistryApiProvider provider)) { - throw new IllegalStateException("DataRegistry does not expose its public API."); + Optional current = admission.get(); + boolean replacementAvailable = current.isPresent() + && admission.generation().stream().anyMatch(generation -> generation > initialGeneration); + boolean featureActive = api.features().find(CAPACITY) + .filter(snapshot -> snapshot.state() == FeatureState.ACTIVE) + .isPresent(); + if (replacementAvailable && featureActive) { + complete.set(true); + logger.info("PROXYFEATURES_ACCEPTANCE_PASS platform=velocity"); + return; } - awaitReady(provider); - Object proxyFeatures = proxy.getPluginManager().getPlugin("proxyfeatures") - .flatMap(container -> container.getInstance()) - .orElseThrow(() -> new IllegalStateException("ProxyFeatures did not remain enabled.")); - if (!(proxyFeatures instanceof ProxyFeaturesContext)) { - throw new IllegalStateException("ProxyFeatures does not expose its public host contract."); + if (System.nanoTime() >= deadline) { + throw new IllegalStateException( + "Capacity reload did not complete: replacementAvailable=" + replacementAvailable + + ", featureActive=" + featureActive + ); } - logger.info("PROXYFEATURES_ACCEPTANCE_PASS platform=velocity"); } catch (Exception exception) { + complete.set(true); logger.error("PROXYFEATURES_ACCEPTANCE_FAIL platform=velocity", exception); } - }).schedule(); + }).repeat(Duration.ofMillis(100)).schedule(); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } } private static void awaitReady(DataRegistryApiProvider provider) throws InterruptedException { - long deadline = System.nanoTime() + 60_000_000_000L; + long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos(); while (System.nanoTime() < deadline) { try { if (provider.getDataRegistry().isReady()) { diff --git a/proxyfeatures-platform-acceptance/run-platform-acceptance.sh b/proxyfeatures-platform-acceptance/run-platform-acceptance.sh old mode 100644 new mode 100755 index 10a31525..4db48a08 --- a/proxyfeatures-platform-acceptance/run-platform-acceptance.sh +++ b/proxyfeatures-platform-acceptance/run-platform-acceptance.sh @@ -169,7 +169,7 @@ mkfifo "$work_directory/velocity/console.in" velocity_pid=$! exec {velocity_input_fd}>"$work_directory/velocity/console.in" capacity_config="$work_directory/velocity/plugins/proxyfeatures/features/Capacity/config.yml" -wait_for_log "$work_directory/velocity/velocity.log" 'PROXYFEATURES_ACCEPTANCE_PASS platform=velocity' +wait_for_log "$work_directory/velocity/velocity.log" 'PROXYFEATURES_ACCEPTANCE_READY platform=velocity' wait_for_log "$work_directory/velocity/velocity.log" 'Capacity started with config.yml as its only persistent storage' [[ "$(mysql_scalar 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = "minecraft" AND TABLE_NAME LIKE "capacity_%"')" == "0" ]] \ @@ -193,8 +193,14 @@ wait_for_file_pattern "$capacity_config" '^[[:space:]]*state:[[:space:]]*DRAININ wait_for_file_pattern "$capacity_config" 'acceptance-manual-state' \ 'Capacity manual state reason was not written to config.yml' +# Capacity's command is a soft, in-place config reload. Exercise it first to verify +# feature-owned state restoration, then perform a framework lifecycle reload so the +# stable CapabilityRef must resolve the newly registered AdmissionApi provider. printf 'capacity reload\n' >&"$velocity_input_fd" wait_for_log "$work_directory/velocity/velocity.log" 'handmatige states zijn opnieuw uit config.yml geladen' +printf 'proxyfeatures reload Capacity\n' >&"$velocity_input_fd" +wait_for_log "$work_directory/velocity/velocity.log" "Feature graph rooted at 'Capacity' reloaded: \[Capacity\]" +wait_for_log "$work_directory/velocity/velocity.log" 'PROXYFEATURES_ACCEPTANCE_PASS platform=velocity' printf 'capacity state survival clear\n' >&"$velocity_input_fd" wait_for_file_absence "$capacity_config" 'acceptance-manual-state' \ diff --git a/proxyfeatures-platform-velocity/pom.xml b/proxyfeatures-platform-velocity/pom.xml index bfbba5f6..982237bb 100644 --- a/proxyfeatures-platform-velocity/pom.xml +++ b/proxyfeatures-platform-velocity/pom.xml @@ -20,13 +20,13 @@ ${project.groupId} - proxyfeatures-contracts + proxyfeatures-toolkit ${project.version} - io.github.classgraph - classgraph - ${classgraph.version} + ${project.groupId} + proxyfeatures-contracts + ${project.version} com.github.ben-manes.caffeine @@ -181,14 +181,6 @@ false - - io.github.classgraph - nl.hauntedmc.proxyfeatures.shaded.classgraph - - - nonapi.io.github.classgraph - nl.hauntedmc.proxyfeatures.shaded.nonapi.classgraph - com.github.benmanes.caffeine nl.hauntedmc.proxyfeatures.shaded.caffeine @@ -247,18 +239,18 @@ property="distribution.has.license"/> - - + - + - - - ready = new CompletableFuture<>(); private final ProxyServer proxy; private final ComponentLogger logger; private final Path dataDirectory; + private final DefaultCapabilityRegistry capabilityRegistry = new DefaultCapabilityRegistry(); + private final DefaultFeatureCatalog featureCatalog = new DefaultFeatureCatalog(); + private final InternalServiceRegistry internalServiceRegistry = new InternalServiceRegistry(); + private final CommandOwnershipRegistry commandOwnershipRegistry = new CommandOwnershipRegistry(); + private final LifecycleCoordinator lifecycleCoordinator = new LifecycleCoordinator(); + private final DefaultMotdExtensions motdExtensions = new DefaultMotdExtensions(); + @SuppressWarnings("FieldCanBeLocal") + private final nl.hauntedmc.proxyfeatures.framework.service.CapabilityRegistration motdExtensionRegistration = + capabilityRegistry.register(FeatureId.of("core"), MotdExtensions.class, motdExtensions); @Inject public ProxyFeatures(ProxyServer proxy, @@ -63,15 +96,8 @@ public ProxyFeatures(ProxyServer proxy, logger.info("ProxyFeatures is loading..."); } - /** - * SYNC - *

- * This event is fired by the proxy after plugins have been - * loaded but before the proxy starts accepting connections. - */ @Subscribe public void onProxyInitialization(ProxyInitializeEvent event) { - // General plugin initialization configService = new ConfigService(this); mainConfigHandler = new MainConfigHandler(logger, configService); localizationHandler = new LocalizationHandler(logger, getClass().getClassLoader(), configService); @@ -80,44 +106,114 @@ public void onProxyInitialization(ProxyInitializeEvent event) { featureLoadManager = new FeatureLoadManager(this, featureScopeFactory); registerBaseCommand(); registerCommonListeners(); - - // Feature specific initialization - featureLoadManager.initializeFeatures(); + try { + featureLoadManager.initializeFeatures(); + runtimeState = RuntimeState.READY; + ready.complete(null); + } catch (Throwable failure) { + runtimeState = RuntimeState.DEGRADED; + ready.completeExceptionally(failure); + throw failure; + } } /** - * This event is fired when the proxy is - * reloaded by the user using /velocity reload. + * Validates shared configuration before touching the active graph, then reloads each graph through + * the transactional per-feature path so state is captured and failures roll back locally. */ @Subscribe public void onProxyReload(final ProxyReloadEvent event) { + lifecycleCoordinator.runExclusive(() -> reloadProxyFeatures(event)); + } + + private void reloadProxyFeatures(ProxyReloadEvent event) { if (mainConfigHandler == null || localizationHandler == null || featureLoadManager == null) { return; } - getLogger().info("Reloading ProxyFeatures..."); - featureLoadManager.unloadAllFeatures(); - mainConfigHandler.reloadConfig(); - localizationHandler.reloadLocalization(); - featureLifecycleFactory = new FeatureLifecycleFactory(this); - featureScopeFactory = new FeatureScopeFactory(this, mainConfigHandler, localizationHandler, featureLifecycleFactory); - featureLoadManager = new FeatureLoadManager(this, featureScopeFactory); - featureLoadManager.initializeFeatures(); - getLogger().info("ProxyFeatures reloaded."); + runtimeState = RuntimeState.RELOADING; + getLogger().info("Reloading ProxyFeatures transactionally..."); + try { + mainConfigHandler.reloadConfig(); + localizationHandler.reloadLocalization(); + } catch (Throwable validationFailure) { + getLogger().error("ProxyFeatures reload aborted before runtime changes: configuration is invalid.", validationFailure); + runtimeState = RuntimeState.DEGRADED; + return; + } + + playerReferenceResolver = null; + Set loadedBefore = new LinkedHashSet<>( + featureLoadManager.getFeatureRegistry().getLoadedFeatureNames() + ); + Set processed = new LinkedHashSet<>(); + + for (String featureName : loadedBefore) { + if (!featureLoadManager.getFeatureRegistry().isFeatureLoaded(featureName)) { + continue; + } + if (!mainConfigHandler.isFeatureEnabled(featureName)) { + var response = featureLoadManager.disableFeature(featureName); + processed.add(featureName); + processed.addAll(response.alsoDisabledDependents()); + if (!response.success()) { + getLogger().error("ProxyFeatures reload stopped: failed disabling feature '{}'.", featureName); + runtimeState = RuntimeState.DEGRADED; + return; + } + } + } + + for (String featureName : loadedBefore) { + if (processed.contains(featureName) + || !featureLoadManager.getFeatureRegistry().isFeatureLoaded(featureName)) { + continue; + } + FeatureReloadResponse response = featureLoadManager.reloadFeature(featureName); + if (!response.success()) { + getLogger().error( + "ProxyFeatures reload stopped after transactional rollback of feature graph '{}'.", + featureName + ); + runtimeState = RuntimeState.DEGRADED; + return; + } + processed.add(featureName); + processed.addAll(response.reloadedDependents()); + } + + enableConfiguredFeatures(); + runtimeState = RuntimeState.READY; + getLogger().info("ProxyFeatures reloaded transactionally with state preservation."); + } + + private void enableConfiguredFeatures() { + boolean progress; + do { + progress = false; + for (String featureName : featureLoadManager.getFeatureRegistry().getAvailableFeatures().keySet()) { + if (!mainConfigHandler.isFeatureEnabled(featureName) + || featureLoadManager.getFeatureRegistry().isFeatureLoaded(featureName)) { + continue; + } + if (featureLoadManager.enableFeature(featureName).success()) { + progress = true; + } + } + } while (progress); } - /** - * SYNC - *

- * This event is fired by the proxy after the proxy has stopped - * accepting connections but before the proxy process exits - */ @Subscribe public void onProxyShutdown(final ProxyShutdownEvent event) { - if (featureLoadManager != null) { - featureLoadManager.unloadAllFeatures(); - } - getLogger().info("proxyfeatures is shutting down..."); + lifecycleCoordinator.runExclusive(() -> { + runtimeState = RuntimeState.STOPPING; + if (featureLoadManager != null) { + featureLoadManager.unloadAllFeatures(); + } + playerReferenceResolver = null; + getLogger().info("proxyfeatures is shutting down..."); + runtimeState = RuntimeState.STOPPED; + }); } public ComponentLogger getLogger() { @@ -126,16 +222,12 @@ public ComponentLogger getLogger() { private void registerBaseCommand() { CommandManager commandManager = proxy.getCommandManager(); - - // Build Brigadier tree and register via Velocity's BrigadierCommand wrapper ProxyFeaturesCommand root = new ProxyFeaturesCommand(this); com.velocitypowered.api.command.BrigadierCommand brigadier = new com.velocitypowered.api.command.BrigadierCommand(root.buildTree()); - CommandMeta meta = commandManager.metaBuilder(brigadier) .plugin(this) .build(); - commandManager.register(meta, brigadier); } @@ -166,6 +258,7 @@ public FeatureScopeFactory getFeatureScopeFactory() { return featureScopeFactory; } + @Override public Path getDataDirectory() { return dataDirectory; } @@ -198,4 +291,71 @@ public Optional getDataRegistry() { .map(DataRegistryApiProvider.class::cast) .map(DataRegistryApiProvider::getDataRegistry); } + + /** Shared immutable player-reference resolver used by all feature persistence adapters. */ + public PlayerReferenceResolver getPlayerReferenceResolver() { + PlayerReferenceResolver current = playerReferenceResolver; + if (current != null) return current; + synchronized (this) { + current = playerReferenceResolver; + if (current == null) { + current = new PlayerReferenceResolver(getDataRegistry().orElseThrow( + () -> new IllegalStateException("DataRegistryApi is required for player persistence."))); + playerReferenceResolver = current; + } + return current; + } + } + + @Override + public ProxyFeaturesApiVersion version() { + String implementationVersion = Optional.ofNullable(getClass().getPackage().getImplementationVersion()) + .filter(version -> !version.isBlank()) + .orElse(FALLBACK_IMPLEMENTATION_VERSION); + return ProxyFeaturesApiVersion.current(implementationVersion); + } + + @Override + public RuntimeState state() { + return runtimeState; + } + + @Override + public CompletionStage whenReady() { + return ready.minimalCompletionStage(); + } + + @Override + public CapabilityRegistry capabilities() { + return capabilityRegistry; + } + + @Override + public FeatureCatalog features() { + return featureCatalog; + } + + public DefaultCapabilityRegistry getCapabilityRegistry() { + return capabilityRegistry; + } + + public DefaultFeatureCatalog getFeatureCatalog() { + return featureCatalog; + } + + public InternalServiceRegistry getInternalServiceRegistry() { + return internalServiceRegistry; + } + + public CommandOwnershipRegistry getCommandOwnershipRegistry() { + return commandOwnershipRegistry; + } + + public LifecycleCoordinator getLifecycleCoordinator() { + return lifecycleCoordinator; + } + + public DefaultMotdExtensions getMotdExtensions() { + return motdExtensions; + } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/FeatureContext.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/FeatureContext.java deleted file mode 100644 index f093e73e..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/FeatureContext.java +++ /dev/null @@ -1,60 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features; - -import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; -import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; -import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; -import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; - -import java.util.Objects; - -public final class FeatureContext { - - private final ProxyFeatures plugin; - private final T meta; - private final FeatureConfigHandler configHandler; - private final FeatureLifecycleManager lifecycleManager; - private final FeatureLogger logger; - private final LocalizationHandler localizationHandler; - - public FeatureContext( - ProxyFeatures plugin, - T meta, - FeatureConfigHandler configHandler, - FeatureLifecycleManager lifecycleManager, - FeatureLogger logger, - LocalizationHandler localizationHandler - ) { - this.plugin = Objects.requireNonNull(plugin, "plugin"); - this.meta = Objects.requireNonNull(meta, "meta"); - this.configHandler = Objects.requireNonNull(configHandler, "configHandler"); - this.lifecycleManager = Objects.requireNonNull(lifecycleManager, "lifecycleManager"); - this.logger = Objects.requireNonNull(logger, "logger"); - this.localizationHandler = Objects.requireNonNull(localizationHandler, "localizationHandler"); - } - - public ProxyFeatures plugin() { - return plugin; - } - - public T meta() { - return meta; - } - - public FeatureConfigHandler configHandler() { - return configHandler; - } - - public FeatureLifecycleManager lifecycleManager() { - return lifecycleManager; - } - - public FeatureLogger logger() { - return logger; - } - - public LocalizationHandler localizationHandler() { - return localizationHandler; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/FeatureFactory.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/FeatureFactory.java deleted file mode 100644 index 7130c00a..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/FeatureFactory.java +++ /dev/null @@ -1,32 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features; - -import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class FeatureFactory { - - public static VelocityBaseFeature createFeature(String featureClassName, FeatureContext context) { - if (featureClassName == null || featureClassName.isBlank()) { - context.plugin().getLogger().error("Failed to instantiate feature: missing feature class name."); - return null; - } - - try { - ProxyFeatures plugin = context.plugin(); - Class rawClass = Class.forName(featureClassName, true, plugin.getClass().getClassLoader()); - if (!VelocityBaseFeature.class.isAssignableFrom(rawClass)) { - plugin.getLogger().error("Feature class does not extend VelocityBaseFeature: {}", featureClassName); - return null; - } - - @SuppressWarnings("unchecked") - Class> featureClass = (Class>) rawClass; - var ctor = featureClass.getDeclaredConstructor(FeatureContext.class); - ctor.setAccessible(true); - return ctor.newInstance(context); - } catch (ReflectiveOperationException | LinkageError t) { - context.plugin().getLogger().error("Failed to instantiate feature class: {}", featureClassName, t); - return null; - } - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/Announcer.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/Announcer.java index 29bebfff..7f464816 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/Announcer.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/Announcer.java @@ -1,22 +1,21 @@ package nl.hauntedmc.proxyfeatures.features.announcer; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.announcer.command.AnnouncerCommand; import nl.hauntedmc.proxyfeatures.features.announcer.entity.PlayerAnnouncerSettingsEntity; import nl.hauntedmc.proxyfeatures.features.announcer.internal.AnnouncerHandler; import nl.hauntedmc.proxyfeatures.features.announcer.internal.AnnouncerSettingsService; import nl.hauntedmc.proxyfeatures.features.announcer.listener.AnnouncerPlayerListener; -import nl.hauntedmc.proxyfeatures.features.announcer.meta.Meta; import nl.hauntedmc.proxyfeatures.framework.persistence.DataRegistryIdentityGate; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import java.util.List; -public class Announcer extends VelocityBaseFeature { +public class Announcer extends VelocityBaseFeature { public static final String ADMIN_PERMISSION = "proxyfeatures.feature.announcer.command"; public static final String TOGGLE_PERMISSION = ADMIN_PERMISSION + ".toggle"; @@ -24,7 +23,7 @@ public class Announcer extends VelocityBaseFeature { private AnnouncerSettingsService settingsService; private AnnouncerHandler handler; - public Announcer(FeatureContext context) { + public Announcer(FeatureContext context) { super(context); } @@ -61,10 +60,7 @@ public void initialize() { PlayerAnnouncerSettingsEntity.class ).orElseThrow(); - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Announcer.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); settingsService = new AnnouncerSettingsService(ormContext, playerResolver); handler = new AnnouncerHandler(this, settingsService); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/command/AnnouncerCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/command/AnnouncerCommand.java index 1e67fbe6..11e2d758 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/command/AnnouncerCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/command/AnnouncerCommand.java @@ -10,23 +10,18 @@ import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.announcer.Announcer; import nl.hauntedmc.proxyfeatures.features.announcer.internal.AnnouncementDefinition; import nl.hauntedmc.proxyfeatures.features.announcer.internal.AnnouncementEvaluation; import nl.hauntedmc.proxyfeatures.features.announcer.internal.AnnouncerHandler; import nl.hauntedmc.proxyfeatures.features.announcer.internal.AnnouncerRegistry; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import org.jetbrains.annotations.NotNull; import java.time.DayOfWeek; import java.time.LocalTime; import java.time.ZoneId; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.concurrent.CompletableFuture; public final class AnnouncerCommand implements BrigadierCommand { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerHandler.java index 618dbb44..6226eac0 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerHandler.java @@ -7,9 +7,9 @@ import com.velocitypowered.api.scheduler.ScheduledTask; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.announcer.Announcer; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerRegistry.java index 91f16f85..f90c0f71 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerRegistry.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerRegistry.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.announcer.internal; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import nl.hauntedmc.proxyfeatures.features.announcer.Announcer; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; import org.spongepowered.configurate.CommentedConfigurationNode; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsService.java index f5ec32c5..9968acfd 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsService.java @@ -112,6 +112,6 @@ private boolean loadEnabledState(UUID uuid, String username, boolean forceEnable } private PlayerReference findPlayer(Session session, UUID uuid, String username) { - return playerResolver.resolveManaged(session, uuid); + return playerResolver.resolveReference(uuid); } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/meta/Meta.java deleted file mode 100644 index ae02837f..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/announcer/meta/Meta.java +++ /dev/null @@ -1,23 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.announcer.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Announcer"; - } - - @Override - public String getFeatureVersion() { - return "2.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/AntiBot.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/AntiBot.java index f57cef9c..11262454 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/AntiBot.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/AntiBot.java @@ -1,10 +1,10 @@ package nl.hauntedmc.proxyfeatures.features.antibot; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.antibot.audit.AntiBotAuditLogService; import nl.hauntedmc.proxyfeatures.features.antibot.audit.PlayerAntiBotLogEntity; import nl.hauntedmc.proxyfeatures.features.antibot.command.AntiBotCommand; @@ -13,14 +13,13 @@ import nl.hauntedmc.proxyfeatures.features.antibot.internal.AntiBotService; import nl.hauntedmc.proxyfeatures.features.antibot.internal.KnownPlayerStore; import nl.hauntedmc.proxyfeatures.features.antibot.listener.AntiBotListener; -import nl.hauntedmc.proxyfeatures.features.antibot.meta.Meta; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import java.nio.file.Path; import java.time.Duration; import java.util.List; -public final class AntiBot extends VelocityBaseFeature { +public final class AntiBot extends VelocityBaseFeature { public static final String BYPASS_PERMISSION = "proxyfeatures.feature.antibot.bypass"; public static final String NOTIFY_PERMISSION = "proxyfeatures.feature.antibot.notify"; @@ -29,7 +28,7 @@ public final class AntiBot extends VelocityBaseFeature { private AntiBotService service; private AntiBotAuditLogService auditLogService; - public AntiBot(FeatureContext context) { + public AntiBot(FeatureContext context) { super(context); } @@ -196,10 +195,7 @@ public void initialize() { if (orm == null) { getLogger().warn("AntiBot database audit logging is disabled because the ORM context is unavailable."); } - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for AntiBot.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); this.auditLogService = new AntiBotAuditLogService(getLogger(), orm, playerResolver); Path knownPlayersFile = getLifecycleManager() diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/command/AntiBotCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/command/AntiBotCommand.java index 6c9aca44..ecdb63fd 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/command/AntiBotCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/command/AntiBotCommand.java @@ -8,7 +8,7 @@ import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.mojang.brigadier.tree.LiteralCommandNode; import com.velocitypowered.api.command.CommandSource; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.antibot.AntiBot; import nl.hauntedmc.proxyfeatures.features.antibot.internal.AddressWhitelist; import nl.hauntedmc.proxyfeatures.features.antibot.internal.AntiBotConfig; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AddressWhitelist.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AddressWhitelist.java index 89cb4fc6..1239981d 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AddressWhitelist.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AddressWhitelist.java @@ -1,5 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.antibot.internal; +import nl.hauntedmc.proxyfeatures.framework.network.IpAddressUtil; + import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AntiBotService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AntiBotService.java index 961bd348..62770cc2 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AntiBotService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/AntiBotService.java @@ -1,5 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.antibot.internal; +import nl.hauntedmc.proxyfeatures.framework.network.IpAddressUtil; + import nl.hauntedmc.proxyfeatures.features.antibot.AntiBot; import nl.hauntedmc.proxyfeatures.features.antibot.audit.AntiBotAuditLogService; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/listener/AntiBotLoginPolicy.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/listener/AntiBotLoginPolicy.java index 08c786ab..358989e5 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/listener/AntiBotLoginPolicy.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/listener/AntiBotLoginPolicy.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.antibot.listener; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.features.antibot.internal.IpAddressUtil; +import nl.hauntedmc.proxyfeatures.framework.network.IpAddressUtil; import java.net.InetSocketAddress; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/meta/Meta.java deleted file mode 100644 index 6d1cdae2..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/meta/Meta.java +++ /dev/null @@ -1,16 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.antibot.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "AntiBot"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/AntiVPN.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/AntiVPN.java index 68bbf0c3..8d0dda0f 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/AntiVPN.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/AntiVPN.java @@ -1,14 +1,14 @@ package nl.hauntedmc.proxyfeatures.features.antivpn; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheDirectory; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheType; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; -import nl.hauntedmc.proxyfeatures.features.antivpn.api.CountryAPI; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheDirectory; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheType; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.api.capability.player.NetworkLocationApi; import nl.hauntedmc.proxyfeatures.features.antivpn.audit.AntiVpnAuditLogService; import nl.hauntedmc.proxyfeatures.features.antivpn.audit.PlayerAntiVpnLogEntity; import nl.hauntedmc.proxyfeatures.features.antivpn.command.AntiVPNCommand; @@ -20,13 +20,12 @@ import nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider.ProviderChain; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider.ProviderRegistry; import nl.hauntedmc.proxyfeatures.features.antivpn.listener.AntiVPNListener; -import nl.hauntedmc.proxyfeatures.features.antivpn.meta.Meta; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import java.time.Duration; import java.util.List; -public class AntiVPN extends VelocityBaseFeature { +public class AntiVPN extends VelocityBaseFeature { private CountryService countryService; private MetricsCollector metrics; @@ -34,7 +33,7 @@ public class AntiVPN extends VelocityBaseFeature { private AntiVPNService service; private AntiVpnAuditLogService auditLogService; - public AntiVPN(FeatureContext context) { + public AntiVPN(FeatureContext context) { super(context); } @@ -151,10 +150,7 @@ public void initialize() { if (orm == null) { getLogger().warn("AntiVPN database audit logging is disabled because the ORM context is unavailable."); } - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for AntiVPN.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); this.auditLogService = new AntiVpnAuditLogService(getLogger(), orm, playerResolver); this.metrics = new MetricsCollector(); @@ -177,7 +173,7 @@ public void initialize() { this.service = new AntiVPNService(this, cache, providerChain, notifications, metrics); // API - getLifecycleManager().getApiManager().registerService(CountryAPI.class, countryService); + getLifecycleManager().getApiManager().registerService(NetworkLocationApi.class, countryService); // Listener getLifecycleManager().getListenerManager().registerListener( diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/api/CountryAPI.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/api/CountryAPI.java deleted file mode 100644 index abecb25b..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/api/CountryAPI.java +++ /dev/null @@ -1,14 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.antivpn.api; - -import java.util.Optional; -import java.util.UUID; - -/** - * Simple API to get the player's ISO country code (e.g., "NL", "US"). - */ -public interface CountryAPI { - /** - * Returns the country code for the given player UUID, if known. - */ - Optional getCountry(UUID uuid); -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/command/AntiVPNCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/command/AntiVPNCommand.java index c6e9937e..766d9fe3 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/command/AntiVPNCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/command/AntiVPNCommand.java @@ -12,7 +12,7 @@ import net.kyori.adventure.text.Component; import nl.hauntedmc.dataregistry.api.player.PlayerData; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.AntiVPNService; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.IpWhitelist; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryService.java index 0c051d45..e40d4257 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryService.java @@ -2,7 +2,8 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import nl.hauntedmc.proxyfeatures.features.antivpn.api.CountryAPI; +import nl.hauntedmc.proxyfeatures.api.capability.player.NetworkLocationApi; +import nl.hauntedmc.proxyfeatures.api.model.CountryCode; import java.time.Duration; import java.util.Locale; @@ -13,7 +14,7 @@ /** * Thread-safe storage of country codes keyed by UUID, with optional temporary username staging. */ -public final class CountryService implements CountryAPI { +public final class CountryService implements NetworkLocationApi { private final ConcurrentHashMap byUuid = new ConcurrentHashMap<>(); private final Cache stagedByUsernameLower; @@ -26,9 +27,9 @@ public CountryService(Duration usernameTtl) { } @Override - public Optional getCountry(UUID uuid) { + public Optional countryCode(UUID uuid) { String v = byUuid.get(uuid); - return (v == null || v.isBlank()) ? Optional.empty() : Optional.of(v); + return CountryCode.optional(v); } public void put(UUID uuid, String countryCode) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCache.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCache.java index 78deef72..ca7bb2dd 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCache.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCache.java @@ -2,8 +2,8 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import java.time.Duration; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/listener/AntiVPNLoginPolicy.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/listener/AntiVPNLoginPolicy.java index 69edf712..ea6838e5 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/listener/AntiVPNLoginPolicy.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/listener/AntiVPNLoginPolicy.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.listener; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.features.antibot.internal.IpAddressUtil; +import nl.hauntedmc.proxyfeatures.framework.network.IpAddressUtil; import java.net.InetAddress; import java.net.InetSocketAddress; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/meta/Meta.java deleted file mode 100644 index 9fb06a1b..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antivpn/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.antivpn.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "AntiVPN"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/Broadcast.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/Broadcast.java index aea046c5..2ba84017 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/Broadcast.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/Broadcast.java @@ -1,15 +1,14 @@ package nl.hauntedmc.proxyfeatures.features.broadcast; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.broadcast.command.BroadcastProxyCommand; -import nl.hauntedmc.proxyfeatures.features.broadcast.meta.Meta; -public class Broadcast extends VelocityBaseFeature { +public class Broadcast extends VelocityBaseFeature { - public Broadcast(FeatureContext context) { + public Broadcast(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommand.java index 37ced13d..005e34f9 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommand.java @@ -11,9 +11,9 @@ import com.velocitypowered.api.proxy.ProxyServer; import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; import nl.hauntedmc.proxyfeatures.features.broadcast.Broadcast; import org.jetbrains.annotations.NotNull; @@ -32,18 +32,9 @@ public final class BroadcastProxyCommand implements BrigadierCommand { private final Broadcast feature; private final ProxyServer proxy; - /** - * Cached title timings (computed once on init). - * Note: if you change config and run /proxyfeatures softreload, the framework reloads the YAML in memory, - * but it will NOT re-create this command. If you want these values to update on softreload too, - * you need a small hook to call {@link #reloadTitleTimesCache()} after config reload. - */ - private volatile Title.Times cachedTitleTimes; - public BroadcastProxyCommand(Broadcast feature) { this.feature = feature; this.proxy = feature.getPlugin().getProxy(); - reloadTitleTimesCache(); } @Override @@ -148,27 +139,23 @@ private void broadcastTitle(String msg, CommandSource src) { .features(ComponentFormatter.ALL_DEFAULTS()) .toComponent(); - Title.Times times = this.cachedTitleTimes; // volatile read - Title title = Title.title(titleComp, subComp, times); + Title title = Title.title(titleComp, subComp, titleTimes()); proxy.getAllPlayers().forEach(p -> p.showTitle(title)); acknowledge(src); } - /* ============================ Cache ============================ */ + /* ============================ Configuration ============================ */ - /** - * Recomputes the cached title timings from config. - * Call this if you ever add a softreload hook for this feature/command. - */ - public void reloadTitleTimesCache() { + /** Returns a point-in-time timing snapshot from the current feature configuration. */ + private Title.Times titleTimes() { var root = feature.getConfigHandler().node(); int fadeInTicks = clampNonNegative(root.get("title_fade_in").as(Integer.class, 20)); int stayTicks = clampNonNegative(root.get("title_stay").as(Integer.class, 100)); int fadeOutTicks = clampNonNegative(root.get("title_fade_out").as(Integer.class, 20)); - this.cachedTitleTimes = Title.Times.times( + return Title.Times.times( Duration.ofMillis(fadeInTicks * TICK_MILLIS), Duration.ofMillis(stayTicks * TICK_MILLIS), Duration.ofMillis(fadeOutTicks * TICK_MILLIS) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/meta/Meta.java deleted file mode 100644 index ded703fb..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/broadcast/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.broadcast.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Broadcast"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/Capacity.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/Capacity.java index 862042a8..add329a3 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/Capacity.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/Capacity.java @@ -2,20 +2,21 @@ import com.velocitypowered.api.scheduler.ScheduledTask; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.capacity.command.CapacityCommand; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfigStore; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityControlPlane; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityIntegrationSynchronizer; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityService; +import nl.hauntedmc.proxyfeatures.features.capacity.internal.AdmissionCapability; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionApi; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacitySnapshotPublisher; import nl.hauntedmc.proxyfeatures.features.capacity.listener.CapacityListener; -import nl.hauntedmc.proxyfeatures.features.capacity.meta.Meta; import java.time.Duration; import java.util.LinkedHashMap; @@ -23,7 +24,7 @@ import java.util.Map; import java.util.Optional; -public final class Capacity extends VelocityBaseFeature { +public final class Capacity extends VelocityBaseFeature { private CapacityService service; private CapacityIntegrationSynchronizer integrationSynchronizer; private CapacityControlPlane controlPlane; @@ -34,7 +35,7 @@ public final class Capacity extends VelocityBaseFeature { private String snapshotPublisherId; private int snapshotIntervalSeconds; - public Capacity(FeatureContext context) { + public Capacity(FeatureContext context) { super(context); } @@ -195,7 +196,11 @@ public void initialize() { integrationSynchronizer.start(); service.start(); - getLifecycleManager().getApiManager().registerService(CapacityAPI.class, service); + getLifecycleManager().getApiManager().registerInternalService(CapacityAPI.class, service); + getLifecycleManager().getApiManager().registerService( + AdmissionApi.class, + new AdmissionCapability(this, service) + ); getLifecycleManager().getListenerManager().registerListener(new CapacityListener(this, service)); getLifecycleManager().getCommandManager().registerFeatureCommand( new CapacityCommand(this, service, controlPlane) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommand.java index c500617a..30416cb7 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommand.java @@ -2,24 +2,20 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityScopeSnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacitySnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfigEditor; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityControlPlane; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityService; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityScopeSnapshot; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacitySnapshot; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.TreeSet; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.function.Consumer; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfig.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfig.java index 24a4ded4..52fd179a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfig.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfig.java @@ -1,21 +1,12 @@ package nl.hauntedmc.proxyfeatures.features.capacity.config; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import java.math.BigDecimal; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; /** Immutable validated Capacity configuration. A zero capacity disables that scope. */ public record CapacityConfig( diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigStore.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigStore.java index 3504812f..63cf24ef 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigStore.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigStore.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.capacity.config; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import org.spongepowered.configurate.serialize.SerializationException; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/AdmissionCapability.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/AdmissionCapability.java new file mode 100644 index 00000000..5354673b --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/AdmissionCapability.java @@ -0,0 +1,193 @@ +package nl.hauntedmc.proxyfeatures.features.capacity.internal; + +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionApi; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionDecision; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionDenialReason; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionIntent; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionLease; +import nl.hauntedmc.proxyfeatures.api.capability.admission.LeaseState; +import nl.hauntedmc.proxyfeatures.api.capability.admission.LeaseTerminalResult; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionRequest; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionScopeSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionState; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDecision; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityScopeSnapshot; +import nl.hauntedmc.proxyfeatures.framework.service.CapabilityProviderGenerationAware; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Public, permission-safe projection of the internal admission engine. */ +public final class AdmissionCapability implements AdmissionApi, CapabilityProviderGenerationAware { + private final Capacity feature; + private final CapacityService service; + private volatile long providerGeneration; + + public AdmissionCapability(Capacity feature, CapacityService service) { + this.feature = Objects.requireNonNull(feature, "feature"); + this.service = Objects.requireNonNull(service, "service"); + } + + @Override + public void providerGeneration(long generation) { + if (generation <= 0L) { + throw new IllegalArgumentException("providerGeneration must be positive"); + } + providerGeneration = generation; + } + + @Override + public AdmissionDecision tryAcquire(AdmissionRequest request) { + Objects.requireNonNull(request, "request"); + var player = feature.getPlugin().getProxy().getPlayer(request.playerId()); + if (player.isEmpty()) { + return AdmissionDecision.deny(AdmissionDenialReason.PLAYER_OFFLINE, "player"); + } + var trusted = service.createRequest( + player.get(), + request.previousServer().map(ServerId::value).orElse(""), + request.targetServer().value(), + toInternalIntent(request.intent()) + ); + return toPublicDecision(service.tryAcquire(trusted)); + } + + @Override + public AdmissionSnapshot snapshot() { + var source = service.snapshot(); + Map groups = new LinkedHashMap<>(); + source.groups().forEach((name, scope) -> groups.put(name, toPublicScope(scope))); + Map servers = new LinkedHashMap<>(); + source.servers().forEach((name, scope) -> servers.put(ServerId.of(name), toPublicScope(scope))); + return new AdmissionSnapshot( + toPublicScope(source.proxy()), + toPublicScope(source.gameplay()), + groups, + servers, + source.activeLeases(), + Instant.now() + ); + } + + private AdmissionDecision toPublicDecision(CapacityDecision decision) { + if (decision.allowed()) { + return AdmissionDecision.allow(new LeaseAdapter(decision.lease(), providerGeneration())); + } + return AdmissionDecision.deny(switch (decision.reason()) { + case NONE -> throw new IllegalStateException("Denied capacity decision has no reason"); + case FULL -> AdmissionDenialReason.CAPACITY; + case SERVER_STATE -> AdmissionDenialReason.SERVER_STATE; + case PROXY_HARD_LIMIT -> AdmissionDenialReason.PROXY_HARD_LIMIT; + case UNKNOWN_TARGET -> AdmissionDenialReason.UNKNOWN_TARGET; + case INVALID_REQUEST -> AdmissionDenialReason.INVALID_REQUEST; + }, decision.blockingScope()); + } + + private long providerGeneration() { + long generation = providerGeneration; + if (generation <= 0L) { + throw new IllegalStateException("Admission capability has not been published"); + } + return generation; + } + + private static AdmissionScopeSnapshot toPublicScope(CapacityScopeSnapshot source) { + return new AdmissionScopeSnapshot( + source.name(), + source.capacity(), + source.reservedSlots(), + source.occupied(), + source.pending(), + source.restorationReserved(), + AdmissionState.valueOf(source.state().name()) + ); + } + + private static nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent toInternalIntent( + AdmissionIntent intent + ) { + return switch (intent) { + case NORMAL -> nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent.NORMAL; + case QUEUE_ADVANCE -> nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent.QUEUE_ADVANCE; + case RESTART_RETURN -> nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent.RESTART_RETURN; + case MAINTENANCE_EVACUATION -> + nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent.MAINTENANCE_EVACUATION; + case SECURITY_ROUTE -> nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent.SECURITY_ROUTE; + case PLUGIN -> nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent.INTERNAL; + }; + } + + static final class LeaseAdapter implements AdmissionLease { + private final nl.hauntedmc.proxyfeatures.framework.admission.CapacityLease delegate; + private final long providerGeneration; + + LeaseAdapter(nl.hauntedmc.proxyfeatures.framework.admission.CapacityLease delegate, long providerGeneration) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + if (providerGeneration < 0) { + throw new IllegalArgumentException("providerGeneration must be non-negative"); + } + this.providerGeneration = providerGeneration; + } + + @Override + public java.util.UUID id() { + return delegate.id(); + } + + @Override + public java.util.UUID playerId() { + return delegate.playerId(); + } + + @Override + public ServerId targetServer() { + return ServerId.of(delegate.targetServer()); + } + + @Override + public AdmissionIntent intent() { + return AdmissionIntent.valueOf(delegate.intent().name().replace("INTERNAL", "PLUGIN")); + } + + @Override + public Instant expiresAt() { + return delegate.expiresAt(); + } + + @Override + public boolean isActive() { + return state() == LeaseState.ACTIVE; + } + + @Override + public LeaseState state() { + return LeaseState.valueOf(delegate.state().name()); + } + + @Override + public long providerGeneration() { + return providerGeneration; + } + + @Override + public LeaseTerminalResult commit() { + if (state() == LeaseState.ACTIVE) { + delegate.commit(); + } + return new LeaseTerminalResult(state(), providerGeneration()); + } + + @Override + public LeaseTerminalResult release() { + if (state() == LeaseState.ACTIVE) { + delegate.release(); + } + return new LeaseTerminalResult(state(), providerGeneration()); + } + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityControlPlane.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityControlPlane.java index d30a3b77..eee7f105 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityControlPlane.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityControlPlane.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.capacity.internal; import com.velocitypowered.api.scheduler.ScheduledTask; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfigStore; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityIntegrationSynchronizer.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityIntegrationSynchronizer.java index 049629e9..04e35251 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityIntegrationSynchronizer.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityIntegrationSynchronizer.java @@ -1,14 +1,13 @@ package nl.hauntedmc.proxyfeatures.features.capacity.internal; import com.velocitypowered.api.scheduler.ScheduledTask; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; -import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceApi; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; -import nl.hauntedmc.proxyfeatures.features.restart.Restart; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.admission.RestartCoordinationPort; import java.time.Duration; import java.util.HashSet; -import java.util.Locale; import java.util.Set; /** Mirrors independently owned operational restrictions into Capacity state claims. */ @@ -19,7 +18,7 @@ public final class CapacityIntegrationSynchronizer { private final CapacityService service; private final Set maintainedServers = new HashSet<>(); private ScheduledTask task; - private Restart attachedRestart; + private RestartCoordinationPort attachedRestart; private boolean closed; public CapacityIntegrationSynchronizer(Capacity feature, CapacityService service) { @@ -83,14 +82,12 @@ private synchronized void sync() { private void syncMaintenance() { Set active = new HashSet<>(); - var loaded = feature.getPlugin().getFeatureLoadManager().getFeatureRegistry() - .getLoadedFeature("Maintenance"); - if (loaded instanceof Maintenance maintenance && maintenance.getHandler() != null) { - maintenance.getHandler().getActiveGamemodes().stream() - .map(CapacityIntegrationSynchronizer::normalize) - .filter(server -> !server.isBlank()) - .forEach(active::add); - } + feature.findCapability(MaintenanceApi.class) + .map(MaintenanceApi::snapshot) + .stream() + .flatMap(snapshot -> snapshot.servers().stream()) + .map(server -> server.value()) + .forEach(active::add); Set all = new HashSet<>(maintainedServers); all.addAll(active); @@ -104,25 +101,22 @@ private void syncMaintenance() { } private void syncRestart() { - var loaded = feature.getPlugin().getFeatureLoadManager().getFeatureRegistry() - .getLoadedFeature("Restart"); - Restart current = loaded instanceof Restart restart ? restart : null; + RestartCoordinationPort current = feature.findInternalService(RestartCoordinationPort.class) + .orElse(null); if (attachedRestart != null && attachedRestart != current) { - attachedRestart.detachCapacity(service); + attachedRestart.detachAdmission(service); attachedRestart = null; } - if (current != null && current.attachCapacity(service)) { + if (current != null) { + current.attachAdmission(service); attachedRestart = current; } } private void detachRestart() { if (attachedRestart == null) return; - attachedRestart.detachCapacity(service); + attachedRestart.detachAdmission(service); attachedRestart = null; } - private static String normalize(String value) { - return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityService.java index 8c7faa16..461c5163 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityService.java @@ -3,34 +3,16 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.scheduler.ScheduledTask; -import nl.hauntedmc.proxyfeatures.api.capacity.AdmissionIntent; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDecision; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDenialReason; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityLease; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityRequest; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityScopeSnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacitySnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; -import nl.hauntedmc.proxyfeatures.api.queue.QueueAdmissionAPI; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceScope; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; +import nl.hauntedmc.proxyfeatures.framework.admission.*; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; -import java.util.UUID; +import java.util.*; /** * Local, atomic admission coordinator. All mutable admission state is guarded by this instance. @@ -110,6 +92,33 @@ public CapacityConfig config() { return config; } + @Override + public CapacityRequest createRequest( + Player player, + String previousServer, + String targetServer, + AdmissionIntent intent + ) { + Objects.requireNonNull(player, "player"); + String target = normalize(targetServer); + boolean maintenanceBypass = ServerId.optional(target).flatMap(server -> + feature.findCapability(MaintenanceApi.class) + .map(api -> api.mayBypass( + player.getUniqueId(), + MaintenanceScope.server(server) + ))).orElse(false); + CapacityConfig current = config; + return new CapacityRequest( + player.getUniqueId(), + previousServer, + target, + intent, + player.hasPermission(current.reservedPermission()), + player.hasPermission(current.capacityBypassPermission()), + player.hasPermission(current.stateBypassPermission()) || maintenanceBypass + ); + } + public synchronized void shutdown() { if (closed) return; closed = true; @@ -118,7 +127,7 @@ public synchronized void shutdown() { reconciliationTask = null; } for (LeaseImpl lease : new ArrayList<>(leasesById.values())) { - releaseLease(lease, false); + releaseLease(lease, CapacityLeaseState.INVALIDATED, false); } leasesById.clear(); pendingByPlayer.clear(); @@ -199,13 +208,13 @@ public synchronized CapacityDecision tryAcquire(CapacityRequest request) { || existing.configGeneration != configGeneration)) { // Prepared connections are checked again by the final Velocity event. Reacquire whenever // origin, intent, permission-derived flags or the validated configuration changed. - releaseLease(existing, true); + releaseLease(existing, CapacityLeaseState.INVALIDATED, true); existing = null; } CapacityState state = effectiveState(request.targetServer()); if (!request.stateBypass() && !state.acceptsNormalAdmissions()) { - if (existing != null) releaseLease(existing, true); + if (existing != null) releaseLease(existing, CapacityLeaseState.INVALIDATED, true); return CapacityDecision.deny( CapacityDenialReason.SERVER_STATE, "server:" + request.targetServer() @@ -297,21 +306,21 @@ public synchronized void connected(UUID playerId, String serverName) { if (lease.targetServer().equals(normalize(serverName))) { commitLease(lease); } else { - releaseLease(lease, true); + releaseLease(lease, CapacityLeaseState.RELEASED, true); } } public synchronized void connectionFailed(UUID playerId, String targetServer) { LeaseImpl lease = pendingByPlayer.get(playerId); if (lease != null && lease.targetServer().equals(normalize(targetServer))) { - releaseLease(lease, true); + releaseLease(lease, CapacityLeaseState.RELEASED, true); } } public synchronized void disconnected(UUID playerId) { releaseLogin(playerId); LeaseImpl lease = pendingByPlayer.get(playerId); - if (lease != null) releaseLease(lease, true); + if (lease != null) releaseLease(lease, CapacityLeaseState.RELEASED, true); } public boolean isQueueable(String serverName) { @@ -338,7 +347,8 @@ public synchronized void setServerState(String serverName, CapacityState state, if (!effective.acceptsNormalAdmissions()) { revokeBlockedLeases(server); } else if (previous != CapacityState.OPEN) { - FeatureServices.find(feature, QueueAdmissionAPI.class).ifPresent(api -> api.wake(server)); + feature.findInternalService(QueueAdmissionPort.class) + .ifPresent(api -> api.capacityChanged(ServerId.of(server))); } } @@ -575,7 +585,7 @@ private void revokeBlockedLeases(String targetServer) { if (targetServer != null && !lease.targetServer().equals(targetServer)) continue; if (!lease.request.stateBypass() && !effectiveState(lease.targetServer()).acceptsNormalAdmissions()) { - releaseLease(lease, true); + releaseLease(lease, CapacityLeaseState.INVALIDATED, true); } } } @@ -607,7 +617,7 @@ private void restoreRestoration(LeaseImpl lease) { private void cleanupExpired(long now) { loginReservations.entrySet().removeIf(entry -> entry.getValue().expiresAtMillis <= now); for (LeaseImpl lease : new ArrayList<>(leasesById.values())) { - if (!lease.isActiveAt(now)) releaseLease(lease, true); + if (!lease.isActiveAt(now)) releaseLease(lease, CapacityLeaseState.EXPIRED, true); } restorationsByServer.values().forEach(map -> map.entrySet().removeIf(entry -> entry.getValue().expiresAtMillis <= now)); @@ -639,20 +649,22 @@ private Set configuredServerNames() { } private void wakeQueues() { - FeatureServices.find(feature, QueueAdmissionAPI.class).ifPresent(api -> - configuredServerNames().forEach(api::wake)); + feature.findInternalService(QueueAdmissionPort.class).ifPresent(api -> + configuredServerNames().forEach(server -> api.capacityChanged(ServerId.of(server)))); } private void commitLease(LeaseImpl lease) { if (lease == null || !lease.active) return; lease.active = false; + lease.state = CapacityLeaseState.COMMITTED; leasesById.remove(lease.id()); pendingByPlayer.remove(lease.playerId(), lease); } - private void releaseLease(LeaseImpl lease, boolean restoreClaim) { + private void releaseLease(LeaseImpl lease, CapacityLeaseState state, boolean restoreClaim) { if (lease == null || !lease.active) return; lease.active = false; + lease.state = state; leasesById.remove(lease.id()); pendingByPlayer.remove(lease.playerId(), lease); if (restoreClaim) restoreRestoration(lease); @@ -706,6 +718,7 @@ private final class LeaseImpl implements CapacityLease { private final Restoration claimedRestoration; private final long configGeneration; private boolean active = true; + private CapacityLeaseState state = CapacityLeaseState.ACTIVE; private LeaseImpl( UUID id, @@ -748,7 +761,17 @@ public Instant expiresAt() { @Override public boolean isActive() { synchronized (CapacityService.this) { - return isActiveAt(System.currentTimeMillis()); + return state() == CapacityLeaseState.ACTIVE; + } + } + + @Override + public CapacityLeaseState state() { + synchronized (CapacityService.this) { + if (active && !isActiveAt(System.currentTimeMillis())) { + releaseLease(this, CapacityLeaseState.EXPIRED, true); + } + return state; } } @@ -757,16 +780,23 @@ private boolean isActiveAt(long now) { } @Override - public void commit() { + public boolean commit() { synchronized (CapacityService.this) { + if (!isActiveAt(System.currentTimeMillis())) { + releaseLease(this, CapacityLeaseState.EXPIRED, true); + return false; + } commitLease(this); + return true; } } @Override - public void release() { + public boolean release() { synchronized (CapacityService.this) { - releaseLease(this, true); + if (!active) return false; + releaseLease(this, CapacityLeaseState.RELEASED, true); + return true; } } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisher.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisher.java index 8b726fc2..324c8e27 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisher.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisher.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.capacity.internal; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityScopeSnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacitySnapshot; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityScopeSnapshot; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacitySnapshot; import nl.hauntedmc.proxyfeatures.contracts.messaging.CapacitySnapshotMessage; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/listener/CapacityListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/listener/CapacityListener.java index 3f1f45e3..d8ae8340 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/listener/CapacityListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/listener/CapacityListener.java @@ -10,19 +10,20 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.queue.QueueAdmissionAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.AdmissionIntent; -import nl.hauntedmc.proxyfeatures.api.capacity.RestartAdmissionAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDecision; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDenialReason; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityRequest; -import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceScope; +import nl.hauntedmc.proxyfeatures.api.capability.operations.RestartApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.TwoFactorApi; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDecision; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDenialReason; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityRequest; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityService; -import nl.hauntedmc.proxyfeatures.features.twofactor.TwoFactor; +import nl.hauntedmc.proxyfeatures.framework.admission.QueueAdmissionPort; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.util.Locale; @@ -93,16 +94,7 @@ public void onPreConnect(ServerPreConnectEvent event) { .filter(prepared -> prepared == AdmissionIntent.QUEUE_ADVANCE || prepared == AdmissionIntent.RESTART_RETURN) .orElseGet(() -> resolveIntent(player, previous, original, targetName)); - boolean maintenanceBypass = hasMaintenanceBypass(player, targetName); - request = new CapacityRequest( - player.getUniqueId(), - previous, - targetName, - intent, - player.hasPermission(config.reservedPermission()), - player.hasPermission(config.capacityBypassPermission()), - player.hasPermission(config.stateBypassPermission()) || maintenanceBypass - ); + request = service.createRequest(player, previous, targetName, intent); decision = service.tryAcquire(request); } catch (Throwable error) { @@ -159,62 +151,43 @@ public void onDisconnect(DisconnectEvent event) { } private AdmissionIntent resolveIntent(Player player, String previous, String original, String target) { - Maintenance maintenance = maintenance(); - if (maintenance != null && maintenance.getHandler() != null) { + MaintenanceApi maintenance = feature.findCapability(MaintenanceApi.class).orElse(null); + if (maintenance != null) { boolean redirectedFromMaintained = !original.isBlank() && !original.equals(target) - && maintenance.getHandler().isGamemodeActive(original); + && maintenance.isActive(MaintenanceScope.server(ServerId.of(original))); boolean evacuatingMaintained = !previous.isBlank() && !previous.equals(target) - && maintenance.getHandler().isGamemodeActive(previous); + && maintenance.isActive(MaintenanceScope.server(ServerId.of(previous))); if (redirectedFromMaintained || evacuatingMaintained) { return AdmissionIntent.MAINTENANCE_EVACUATION; } } - boolean restartReturn = FeatureServices.find(feature, RestartAdmissionAPI.class) - .map(api -> api.isRestartReturn(player.getUniqueId(), target)) + boolean restartReturn = feature.findCapability(RestartApi.class) + .map(api -> api.isExpectedReturn(player.getUniqueId(), ServerId.of(target))) .orElse(false); if (restartReturn) return AdmissionIntent.RESTART_RETURN; - TwoFactor twoFactor = twoFactor(); + TwoFactorApi twoFactor = feature.findCapability(TwoFactorApi.class).orElse(null); boolean twoFactorRoute = previous.isBlank() && !original.isBlank() && !original.equals(target) && twoFactor != null - && twoFactor.getService() != null - && twoFactor.getService().isLocked(player) - && twoFactor.isLockServer(target); + && twoFactor.isLocked(player.getUniqueId()) + && twoFactor.isAuthenticationServer(ServerId.of(target)); return twoFactorRoute ? AdmissionIntent.SECURITY_ROUTE : AdmissionIntent.NORMAL; } - private boolean hasMaintenanceBypass(Player player, String target) { - Maintenance maintenance = maintenance(); - return maintenance != null && maintenance.getHandler() != null - && maintenance.getHandler().hasGamemodeBypass(player, target); - } - - private Maintenance maintenance() { - var loaded = feature.getPlugin().getFeatureLoadManager().getFeatureRegistry() - .getLoadedFeature("Maintenance"); - return loaded instanceof Maintenance maintenance ? maintenance : null; - } - - private TwoFactor twoFactor() { - var loaded = feature.getPlugin().getFeatureLoadManager().getFeatureRegistry() - .getLoadedFeature("TwoFactor"); - return loaded instanceof TwoFactor twoFactor ? twoFactor : null; - } - private boolean enqueue(Player player, String serverName, CapacityDenialReason reason, CapacityRequest request) { - return FeatureServices.find(feature, QueueAdmissionAPI.class) - .filter(api -> api.isQueueEnabled(serverName)) - .map(api -> api.enqueue(player, serverName, reason, request)) + ServerId server = ServerId.of(serverName); + return feature.findInternalService(QueueAdmissionPort.class) + .map(api -> api.enqueueDenied(player, server, reason, request)) .orElse(false); } private boolean consumeCancelledQueueAdvance(Player player, String serverName) { - return FeatureServices.find(feature, QueueAdmissionAPI.class) - .map(api -> api.consumeCancelledAdvance(player.getUniqueId(), serverName)) + return feature.findInternalService(QueueAdmissionPort.class) + .map(api -> api.consumeCancelledAdvance(player.getUniqueId(), ServerId.of(serverName))) .orElse(false); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/meta/Meta.java deleted file mode 100644 index bac9c0ee..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/capacity/meta/Meta.java +++ /dev/null @@ -1,15 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.capacity.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public final class Meta implements BaseMeta { - @Override - public String getFeatureName() { - return "Capacity"; - } - - @Override - public String getFeatureVersion() { - return "1.4.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/ClientInfo.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/ClientInfo.java index ecf596b1..649519c8 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/ClientInfo.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/ClientInfo.java @@ -1,33 +1,28 @@ package nl.hauntedmc.proxyfeatures.features.clientinfo; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.clientinfo.command.ClientInfoBrigadierCommand; import nl.hauntedmc.proxyfeatures.features.clientinfo.entity.PlayerClientInfoChannelEntity; import nl.hauntedmc.proxyfeatures.features.clientinfo.entity.PlayerClientInfoEntity; import nl.hauntedmc.proxyfeatures.features.clientinfo.entity.PlayerClientInfoModEntity; import nl.hauntedmc.proxyfeatures.features.clientinfo.entity.PlayerClientInfoSettingsEntity; -import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.ClientInfoAdvisor; -import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.ClientInfoConfig; -import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.ClientInfoPersistenceService; -import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.ClientInfoSettingsService; -import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.ClientTelemetryService; +import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.*; import nl.hauntedmc.proxyfeatures.features.clientinfo.listener.PlayerListener; -import nl.hauntedmc.proxyfeatures.features.clientinfo.meta.Meta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import java.util.Map; -public class ClientInfo extends VelocityBaseFeature { +public class ClientInfo extends VelocityBaseFeature { public static final String STAFF_DETAILS_PERMISSION = "proxyfeatures.feature.clientinfo.command.staff"; private ClientInfoAdvisor advisor; - public ClientInfo(FeatureContext context) { + public ClientInfo(FeatureContext context) { super(context); } @@ -180,10 +175,7 @@ public void initialize() { PlayerClientInfoChannelEntity.class) .orElseThrow(); - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for ClientInfo.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); ClientInfoSettingsService settingsService = new ClientInfoSettingsService(ormContext, playerResolver); ClientTelemetryService telemetryService = new ClientTelemetryService(); ClientInfoPersistenceService persistenceService = new ClientInfoPersistenceService( diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/command/ClientInfoBrigadierCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/command/ClientInfoBrigadierCommand.java index 28a89b10..b0b197d4 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/command/ClientInfoBrigadierCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/command/ClientInfoBrigadierCommand.java @@ -9,7 +9,7 @@ import com.mojang.brigadier.tree.LiteralCommandNode; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.clientinfo.ClientInfo; import nl.hauntedmc.proxyfeatures.features.clientinfo.internal.ClientInfoAdvisor; import org.jetbrains.annotations.NotNull; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfig.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfig.java index 705351d3..b02609e5 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfig.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfig.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.clientinfo.internal; import com.velocitypowered.api.proxy.player.PlayerSettings; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import java.util.ArrayList; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoPersistenceService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoPersistenceService.java index 6988c2a1..bf6ecae0 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoPersistenceService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoPersistenceService.java @@ -74,7 +74,7 @@ private void runPersist(UUID uuid, long generation) { private void persistSnapshot(ClientTelemetryService.SessionSnapshot snapshot) { try { orm.runInTransaction(session -> { - PlayerReference player = playerResolver.resolveManaged(session, snapshot.uuid()); + PlayerReference player = playerResolver.resolveReference(snapshot.uuid()); if (player == null) { return null; } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsService.java index 5290cdde..92a30fbf 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsService.java @@ -61,7 +61,7 @@ private PlayerClientInfoSettingsEntity loadSettings(UUID uuid, String username) } private PlayerReference findPlayer(org.hibernate.Session session, UUID uuid, String username) { - return playerResolver.resolveManaged(session, uuid); + return playerResolver.resolveReference(uuid); } public Optional getPlayerReference(UUID uuid) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/meta/Meta.java deleted file mode 100644 index aa07c193..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/clientinfo/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.clientinfo.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "ClientInfo"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/CommandHider.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/CommandHider.java index aabfc2e2..802407dc 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/CommandHider.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/CommandHider.java @@ -1,21 +1,20 @@ package nl.hauntedmc.proxyfeatures.features.commandhider; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.commandhider.command.CommandHiderCommand; import nl.hauntedmc.proxyfeatures.features.commandhider.internal.HiderHandler; import nl.hauntedmc.proxyfeatures.features.commandhider.listener.AvailableCommandListener; -import nl.hauntedmc.proxyfeatures.features.commandhider.meta.Meta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import java.util.List; -public final class CommandHider extends VelocityBaseFeature { +public final class CommandHider extends VelocityBaseFeature { private HiderHandler hiderHandler; - public CommandHider(FeatureContext context) { + public CommandHider(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/command/CommandHiderCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/command/CommandHiderCommand.java index aa6afbbe..423311c7 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/command/CommandHiderCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/command/CommandHiderCommand.java @@ -7,7 +7,7 @@ import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.mojang.brigadier.tree.LiteralCommandNode; import com.velocitypowered.api.command.CommandSource; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.commandhider.CommandHider; import nl.hauntedmc.proxyfeatures.features.commandhider.internal.HiderHandler; import org.jetbrains.annotations.NotNull; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/meta/Meta.java deleted file mode 100644 index c903d0c5..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandhider/meta/Meta.java +++ /dev/null @@ -1,18 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.commandhider.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "CommandHider"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } - -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/CommandLogger.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/CommandLogger.java index 2f3aac62..feda5a61 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/CommandLogger.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/CommandLogger.java @@ -1,23 +1,22 @@ package nl.hauntedmc.proxyfeatures.features.commandlogger; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.commandlogger.entity.CommandExecutionEntity; import nl.hauntedmc.proxyfeatures.features.commandlogger.internal.LogHandler; import nl.hauntedmc.proxyfeatures.features.commandlogger.listener.CommandListener; -import nl.hauntedmc.proxyfeatures.features.commandlogger.meta.Meta; import nl.hauntedmc.proxyfeatures.features.commandlogger.service.CommandLogService; -public class CommandLogger extends VelocityBaseFeature { +public class CommandLogger extends VelocityBaseFeature { private LogHandler logHandler; private CommandLogService commandLogService; private ORMContext ormContext; - public CommandLogger(FeatureContext context) { + public CommandLogger(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/meta/Meta.java deleted file mode 100644 index ff69fbb8..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.commandlogger.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "CommandLogger"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogService.java index 2d2b4447..6745dfbf 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogService.java @@ -2,12 +2,10 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.proxyfeatures.features.commandlogger.CommandLogger; -import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.proxyfeatures.features.commandlogger.entity.CommandExecutionEntity; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import java.util.Locale; @@ -19,18 +17,12 @@ public class CommandLogService { private final PlayerReferenceResolver playerResolver; public CommandLogService(CommandLogger feature) { - this(feature, feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for CommandLogger."))); - } - - CommandLogService(CommandLogger feature, DataRegistryApi dataRegistry) { - this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(dataRegistry); + this(feature, feature.getPlugin().getPlayerReferenceResolver()); } - CommandLogService(CommandLogger feature, PlayerDirectory playerDirectory) { + CommandLogService(CommandLogger feature, PlayerReferenceResolver playerResolver) { this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + this.playerResolver = playerResolver; } /** @@ -74,7 +66,7 @@ private void schedulePersist(java.util.UUID playerUuid, String sourceLabel, Stri private void persist(java.util.UUID playerUuid, String sourceLabel, String fullCommand, long timestamp) { feature.getOrmContext().runInTransaction(session -> { - PlayerReference playerEntity = playerUuid == null ? null : playerResolver.resolveManaged(session, playerUuid); + PlayerReference playerEntity = playerUuid == null ? null : playerResolver.resolveReference(playerUuid); if (playerUuid != null && playerEntity == null) { return null; } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/CommandRelay.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/CommandRelay.java index 8a9167e1..8f03fa35 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/CommandRelay.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/CommandRelay.java @@ -6,18 +6,17 @@ import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.proxyfeatures.features.commandrelay.audit.CommandRelayAuditLogEntity; import nl.hauntedmc.proxyfeatures.features.commandrelay.audit.CommandRelayAuditLogService; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.commandrelay.internal.EventBusHandler; -import nl.hauntedmc.proxyfeatures.features.commandrelay.meta.Meta; import java.util.List; import java.util.Locale; import java.util.Optional; -public class CommandRelay extends VelocityBaseFeature { +public class CommandRelay extends VelocityBaseFeature { private static final String STREAM = "proxy.commandrelay.command"; private static final String DEFAULT_CONSUMER_GROUP = "proxyfeatures.commandrelay.proxy"; @@ -26,7 +25,7 @@ public class CommandRelay extends VelocityBaseFeature { private EventBusHandler eventBusHandler; private CommandRelayAuditLogService auditLogService; - public CommandRelay(FeatureContext context) { + public CommandRelay(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandler.java index 9379b1f2..8c278d09 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandler.java @@ -8,10 +8,9 @@ import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableSubscription; import nl.hauntedmc.dataprovider.database.messaging.durable.PublishedDurableEvent; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheDirectory; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheType; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; -import nl.hauntedmc.proxyfeatures.api.util.type.CastUtils; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheDirectory; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheType; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; import nl.hauntedmc.proxyfeatures.contracts.messaging.CommandRelayMessage; import nl.hauntedmc.proxyfeatures.features.commandrelay.CommandRelay; import nl.hauntedmc.proxyfeatures.features.commandrelay.audit.CommandRelayAuditLogService; @@ -142,9 +141,10 @@ private void handleIncoming(String stream, DurableDelivery ? full.substring(0, full.indexOf(' ')) : full; - List whitelist = CastUtils.safeCastToList( - feature.getConfigHandler().get("command_whitelist"), - String.class + List whitelist = feature.getConfigHandler().getList( + "command_whitelist", + String.class, + List.of() ); Set allowed = whitelist.stream() .filter(value -> value != null && !value.isBlank()) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedger.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedger.java index 330f91a7..bb12a717 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedger.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedger.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.commandrelay.internal; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; import java.util.Objects; import java.util.Set; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/meta/Meta.java deleted file mode 100644 index 1f749e34..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/commandrelay/meta/Meta.java +++ /dev/null @@ -1,25 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.commandrelay.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "CommandRelay"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER); - } - -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/ConnectionInfo.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/ConnectionInfo.java index dd06c54d..1019cc34 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/ConnectionInfo.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/ConnectionInfo.java @@ -1,20 +1,19 @@ package nl.hauntedmc.proxyfeatures.features.connectioninfo; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.connectioninfo.command.ConnectionInfoCommand; import nl.hauntedmc.proxyfeatures.features.connectioninfo.command.PingCommand; import nl.hauntedmc.proxyfeatures.features.connectioninfo.internal.SessionHandler; import nl.hauntedmc.proxyfeatures.features.connectioninfo.listener.PlayerListener; -import nl.hauntedmc.proxyfeatures.features.connectioninfo.meta.Meta; -public class ConnectionInfo extends VelocityBaseFeature { +public class ConnectionInfo extends VelocityBaseFeature { private SessionHandler sessionHandler; - public ConnectionInfo(FeatureContext context) { + public ConnectionInfo(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommand.java index b6c8c6e0..a99b534d 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.connectioninfo.ConnectionInfo; import java.net.InetSocketAddress; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/PingCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/PingCommand.java index 0541154e..14cdd413 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/PingCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/PingCommand.java @@ -2,8 +2,8 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.connectioninfo.ConnectionInfo; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import java.util.List; import java.util.Optional; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/meta/Meta.java deleted file mode 100644 index ad361232..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/meta/Meta.java +++ /dev/null @@ -1,18 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.connectioninfo.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "ConnectionInfo"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java index 6aed32b3..cedcdf8e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java @@ -1,28 +1,27 @@ package nl.hauntedmc.proxyfeatures.features.friends; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.api.friends.FriendshipApi; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.friends.api.FriendshipApiImpl; import nl.hauntedmc.proxyfeatures.features.friends.command.AsyncFriendCommand; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendRelationEntity; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendSettingsEntity; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendsService; import nl.hauntedmc.proxyfeatures.features.friends.listener.FriendActivityListener; -import nl.hauntedmc.proxyfeatures.features.friends.meta.Meta; import nl.hauntedmc.proxyfeatures.features.friends.support.FriendsCache; -public class Friends extends VelocityBaseFeature { +public class Friends extends VelocityBaseFeature { private ORMContext orm; private FriendsService service; private FriendsCache cache; private FriendshipApiImpl friendshipApi; - public Friends(FeatureContext context) { + public Friends(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImpl.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImpl.java index e6c4d391..2f0fee52 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImpl.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImpl.java @@ -1,6 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.friends.api; -import nl.hauntedmc.proxyfeatures.api.friends.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.ApiFailureCode; +import nl.hauntedmc.proxyfeatures.api.ApiOperationException; import nl.hauntedmc.proxyfeatures.features.friends.Friends; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendStatus; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendsService; @@ -110,7 +112,10 @@ private static CompletableFuture unavailableFuture() { return CompletableFuture.failedFuture(unavailableFailure()); } - private static IllegalStateException unavailableFailure() { - return new IllegalStateException("Friends feature service is no longer available."); + private static ApiOperationException unavailableFailure() { + return new ApiOperationException( + ApiFailureCode.PROVIDER_RELOADED, + "Friends capability provider is no longer available." + ); } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java index 8dd56431..5a8abd75 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.friends.Friends; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/FriendCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/FriendCommand.java index 7d744641..f0f74ada 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/FriendCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/FriendCommand.java @@ -5,15 +5,14 @@ import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; -import nl.hauntedmc.proxyfeatures.api.util.tools.Paginator; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.pagination.Paginator; import nl.hauntedmc.proxyfeatures.features.friends.Friends; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendStatus; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendsService; import nl.hauntedmc.proxyfeatures.features.friends.entity.PlayerRef; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -643,8 +642,8 @@ private PlayerRef resolvePlayerRef(String name) { } private boolean notVanished(Player pl) { - return FeatureServices.find(feature, VanishAPI.class) - .map(api -> !api.isVanished(pl.getUniqueId())) + return feature.findCapability(PresenceApi.class) + .map(api -> !api.isHidden(pl.getUniqueId())) .orElse(true); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsService.java index a28f7979..a475c5b9 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsService.java @@ -1,9 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.friends.entity; import jakarta.persistence.PersistenceException; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.proxyfeatures.features.friends.Friends; import nl.hauntedmc.proxyfeatures.features.friends.support.FriendsCache; @@ -20,20 +18,13 @@ public class FriendsService { private final PlayerReferenceResolver playerResolver; public FriendsService(Friends feature, FriendsCache cache) { - this(feature, cache, feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Friends."))); + this(feature, cache, feature.getPlugin().getPlayerReferenceResolver()); } - FriendsService(Friends feature, FriendsCache cache, DataRegistryApi dataRegistry) { + FriendsService(Friends feature, FriendsCache cache, PlayerReferenceResolver playerResolver) { this.feature = feature; this.cache = cache; - this.playerResolver = new PlayerReferenceResolver(dataRegistry); - } - - FriendsService(Friends feature, FriendsCache cache, PlayerDirectory playerDirectory) { - this.feature = feature; - this.cache = cache; - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + this.playerResolver = playerResolver; } // -------- Player lookups (cached, lightweight) -------- @@ -560,7 +551,7 @@ private String displayName(Long playerId) { if (playerId == null) { return "-"; } - return playerResolver.findActiveIdentityById(playerId) + return playerResolver.findIdentityById(playerId) .map(identity -> identity.username()) .orElse("#" + playerId); } @@ -569,7 +560,7 @@ private FriendSnapshot snapshot(Long playerId) { if (playerId == null) { return new FriendSnapshot(null, null, "-"); } - return playerResolver.findActiveIdentityById(playerId) + return playerResolver.findIdentityById(playerId) .map(identity -> new FriendSnapshot( identity.playerId(), identity.uuid().toString(), identity.username())) .orElseGet(() -> new FriendSnapshot(playerId, null, "#" + playerId)); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/listener/FriendActivityListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/listener/FriendActivityListener.java index b1bd53da..0863fb89 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/listener/FriendActivityListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/listener/FriendActivityListener.java @@ -9,9 +9,8 @@ import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendSnapshot; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendsService; import nl.hauntedmc.proxyfeatures.features.friends.entity.PlayerRef; -import nl.hauntedmc.proxyfeatures.features.vanish.event.VanishStateChangeEvent; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceChangedEvent; import java.time.Duration; import java.util.ArrayList; @@ -114,13 +113,13 @@ public void onDisconnect(DisconnectEvent event) { } @Subscribe - public void onVanishStateChange(VanishStateChangeEvent event) { - Optional currentVanishState = vanishState(event.playerUuid()); - if (currentVanishState.isEmpty() || currentVanishState.get() != event.vanished()) { + public void onPresenceChanged(PresenceChangedEvent event) { + Optional currentVanishState = vanishState(event.playerId()); + if (currentVanishState.isEmpty() || currentVanishState.get() != event.hidden()) { return; } - Optional subjectOptional = feature.getPlugin().getProxy().getPlayer(event.playerUuid()); + Optional subjectOptional = feature.getPlugin().getProxy().getPlayer(event.playerId()); if (subjectOptional.isEmpty()) { return; } @@ -130,8 +129,8 @@ public void onVanishStateChange(VanishStateChangeEvent event) { .map(current -> current.getServerInfo().getName()) .orElse(null); FriendPresenceTracker.Plan plan = presenceTracker.applyVanishState( - event.playerUuid(), - event.vanished(), + event.playerId(), + event.hidden(), currentServer ); if (!announceVanishStateChanges) { @@ -143,11 +142,11 @@ public void onVanishStateChange(VanishStateChangeEvent event) { : event.playerName(); if (plan.type() == FriendPresenceTracker.NotificationType.VANISH_OFFLINE) { feature.getLifecycleManager().getTaskManager().scheduleTask(() -> - notifyFriendsOffline(event.playerUuid(), username, plan.generation())); + notifyFriendsOffline(event.playerId(), username, plan.generation())); } else if (plan.type() == FriendPresenceTracker.NotificationType.VANISH_ONLINE) { feature.getLifecycleManager().getTaskManager().scheduleTask(() -> notifyFriendsOnline( - event.playerUuid(), + event.playerId(), username, plan.to(), plan.generation() @@ -288,8 +287,8 @@ private boolean isVanished(Player player) { private Optional vanishState(UUID playerUuid) { try { - return FeatureServices.find(feature, VanishAPI.class) - .map(api -> api.isVanished(playerUuid)); + return feature.findCapability(PresenceApi.class) + .map(api -> api.isHidden(playerUuid)); } catch (RuntimeException ignored) { return Optional.empty(); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/meta/Meta.java deleted file mode 100644 index 25eb6091..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/meta/Meta.java +++ /dev/null @@ -1,28 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.friends.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Friends"; - } - - @Override - public String getFeatureVersion() { - return "1.4.0"; - } - - @Override - public List getDependencies() { - return List.of(); - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/HLink.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/HLink.java index ec89140b..5475b195 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/HLink.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/HLink.java @@ -1,22 +1,21 @@ package nl.hauntedmc.proxyfeatures.features.hlink; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.hlink.command.HLinkCommand; import nl.hauntedmc.proxyfeatures.features.hlink.command.LinkCommand; import nl.hauntedmc.proxyfeatures.features.hlink.command.RegisterCommand; import nl.hauntedmc.proxyfeatures.features.hlink.internal.HLinkHandler; import nl.hauntedmc.proxyfeatures.features.hlink.internal.hook.LuckPermsHook; -import nl.hauntedmc.proxyfeatures.features.hlink.meta.Meta; -public class HLink extends VelocityBaseFeature { +public class HLink extends VelocityBaseFeature { private HLinkHandler hlinkHandler; private LuckPermsHook luckPermsHook; - public HLink(FeatureContext context) { + public HLink(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/HLinkCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/HLinkCommand.java index 83a6e0fd..3ee4254a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/HLinkCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/HLinkCommand.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.hlink.HLink; import nl.hauntedmc.proxyfeatures.features.hlink.internal.HLinkHandler; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/LinkCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/LinkCommand.java index c4cc6b6c..38ec03ec 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/LinkCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/LinkCommand.java @@ -4,7 +4,7 @@ import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.hlink.HLink; import nl.hauntedmc.proxyfeatures.features.hlink.internal.HLinkHandler; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/RegisterCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/RegisterCommand.java index 158ece6b..aa93b379 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/RegisterCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/command/RegisterCommand.java @@ -4,7 +4,7 @@ import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.hlink.HLink; import nl.hauntedmc.proxyfeatures.features.hlink.internal.HLinkHandler; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/internal/HLinkHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/internal/HLinkHandler.java index 6964069f..e3ffcd66 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/internal/HLinkHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/internal/HLinkHandler.java @@ -10,8 +10,8 @@ import net.luckperms.api.node.types.InheritanceNode; import net.luckperms.api.track.Track; import net.luckperms.api.track.TrackManager; -import nl.hauntedmc.proxyfeatures.api.util.http.SimpleHttpClient; -import nl.hauntedmc.proxyfeatures.api.util.http.SimpleHttpClient.FormParameter; +import nl.hauntedmc.proxyfeatures.toolkit.http.HttpTransport; +import nl.hauntedmc.proxyfeatures.toolkit.http.HttpTransport.FormParameter; import nl.hauntedmc.proxyfeatures.features.hlink.HLink; import nl.hauntedmc.proxyfeatures.features.hlink.internal.api.AccountRequest; import nl.hauntedmc.proxyfeatures.features.hlink.internal.api.LinkRequest; @@ -164,7 +164,7 @@ private boolean updatePlayerData(UUID uuid, String username) { args.add(new FormParameter("groups", primaryGroup)); try { - SimpleHttpClient.postHttps(apiUrl + "/updatePlayerCache", args); + HttpTransport.postHttps(apiUrl + "/updatePlayerCache", args); updateCache.put(uuid, new CachedPlayerData(username, primaryGroup)); return true; } catch (InterruptedException e) { @@ -238,7 +238,7 @@ public String doesKeyExist(String uuid, int keyType) { args.add(new FormParameter("uuid", uuid)); args.add(new FormParameter("key_type", String.valueOf(keyType))); try { - String response = SimpleHttpClient.postHttps(apiUrl + "/checkForExistingLink", args); + String response = HttpTransport.postHttps(apiUrl + "/checkForExistingLink", args); LinkRequest request = gson.fromJson(response, LinkRequest.class); if (request != null && request.getResults() != null && !request.getResults().isEmpty()) { return request.getResults(); @@ -259,7 +259,7 @@ public boolean alreadyRegistered(String uuid) { args.add(new FormParameter("api_key", apiKey)); args.add(new FormParameter("uuid", uuid)); try { - String response = SimpleHttpClient.postHttps(apiUrl + "/checkUserAccountExists", args); + String response = HttpTransport.postHttps(apiUrl + "/checkUserAccountExists", args); AccountRequest request = gson.fromJson(response, AccountRequest.class); return request != null && request.getExists(); } catch (InterruptedException e) { @@ -346,8 +346,8 @@ private LinkResult addNewKey(UUID playerId, String username, int keyType) { argsUpdate.add(new FormParameter("groups", groups)); try { - SimpleHttpClient.postHttps(apiUrl + "/createLinkKey", argsCreate); - SimpleHttpClient.postHttps(apiUrl + "/updatePlayerCache", argsUpdate); + HttpTransport.postHttps(apiUrl + "/createLinkKey", argsCreate); + HttpTransport.postHttps(apiUrl + "/updatePlayerCache", argsUpdate); } catch (InterruptedException e) { Thread.currentThread().interrupt(); feature.getLogger().warn(Component.text("Link-key creation interrupted for " + username)); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/meta/Meta.java deleted file mode 100644 index 4402cd5d..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hlink/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.hlink.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "HLink"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } - - @Override - public List getPluginDependencies() { - return List.of("luckperms"); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/Hub.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/Hub.java index b39daf2b..0074ee24 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/Hub.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/Hub.java @@ -1,15 +1,14 @@ package nl.hauntedmc.proxyfeatures.features.hub; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.hub.command.HubCommand; -import nl.hauntedmc.proxyfeatures.features.hub.meta.Meta; -public class Hub extends VelocityBaseFeature { +public class Hub extends VelocityBaseFeature { - public Hub(FeatureContext context) { + public Hub(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/command/HubCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/command/HubCommand.java index d763f37a..ca785242 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/command/HubCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/command/HubCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.RegisteredServer; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.hub.Hub; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/meta/Meta.java deleted file mode 100644 index e6bbd310..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/hub/meta/Meta.java +++ /dev/null @@ -1,16 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.hub.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Hub"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/Maintenance.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/Maintenance.java index 6b4e64ed..89a4d650 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/Maintenance.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/Maintenance.java @@ -1,25 +1,28 @@ package nl.hauntedmc.proxyfeatures.features.maintenance; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceApi; +import nl.hauntedmc.proxyfeatures.api.extension.ExtensionRegistration; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContribution; +import nl.hauntedmc.proxyfeatures.api.extension.MotdExtensions; import nl.hauntedmc.proxyfeatures.features.maintenance.command.MaintenanceCommand; import nl.hauntedmc.proxyfeatures.features.maintenance.internal.MaintenanceHandler; import nl.hauntedmc.proxyfeatures.features.maintenance.listener.MaintenanceConnectionListener; -import nl.hauntedmc.proxyfeatures.features.maintenance.meta.Meta; -import nl.hauntedmc.proxyfeatures.features.motd.internal.MotdLine2OverrideRegistry; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import java.util.ArrayList; import java.util.List; -public class Maintenance extends VelocityBaseFeature { +public class Maintenance extends VelocityBaseFeature { private static final String MOTD_OVERRIDE_KEY = "maintenance"; private MaintenanceHandler handler; + private ExtensionRegistration motdRegistration; - public Maintenance(FeatureContext context) { + public Maintenance(FeatureContext context) { super(context); } @@ -112,14 +115,23 @@ public MessageMap getDefaultMessages() { @Override public void initialize() { this.handler = new MaintenanceHandler(this); - MotdLine2OverrideRegistry.register(MOTD_OVERRIDE_KEY, 100, handler::resolveMotdLine2Override); + getLifecycleManager().getApiManager().registerService(MaintenanceApi.class, handler); + motdRegistration = requireCapability(MotdExtensions.class).register( + MOTD_OVERRIDE_KEY, + 100, + context -> java.util.Optional.ofNullable(handler.resolveMotdLine2Override()) + .map(MotdContribution::secondLine) + ); getLifecycleManager().getCommandManager().registerFeatureCommand(new MaintenanceCommand(this)); getLifecycleManager().getListenerManager().registerListener(new MaintenanceConnectionListener(this)); } @Override public void disable() { - MotdLine2OverrideRegistry.unregister(MOTD_OVERRIDE_KEY); + if (motdRegistration != null) { + motdRegistration.close(); + motdRegistration = null; + } if (handler != null) { handler.shutdown(); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/command/MaintenanceCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/command/MaintenanceCommand.java index 06897472..c102e58b 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/command/MaintenanceCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/command/MaintenanceCommand.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.maintenance.command; import com.velocitypowered.api.command.CommandSource; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; import nl.hauntedmc.proxyfeatures.features.maintenance.internal.MaintenanceHandler; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/internal/MaintenanceHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/internal/MaintenanceHandler.java index f9553a39..c574d48e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/internal/MaintenanceHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/internal/MaintenanceHandler.java @@ -5,6 +5,10 @@ import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceScope; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceSnapshot; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; @@ -18,11 +22,12 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -public final class MaintenanceHandler { +public final class MaintenanceHandler implements MaintenanceApi { public enum ToggleResult { ENABLED, @@ -158,6 +163,32 @@ public boolean isGlobalActive() { return globalActive.get(); } + @Override + public boolean isActive(MaintenanceScope scope) { + if (scope.isGlobal()) { + return isGlobalActive(); + } + return scope.server().map(server -> isGamemodeActive(server.value())).orElse(false); + } + + @Override + public boolean mayBypass(UUID playerId, MaintenanceScope scope) { + if (playerId == null) { + return false; + } + return proxy.getPlayer(playerId).map(player -> scope.server() + .map(server -> hasGamemodeBypass(player, server.value())) + .orElseGet(() -> hasGlobalBypass(player))).orElse(false); + } + + @Override + public MaintenanceSnapshot snapshot() { + Set servers = activeGamemodes.stream() + .map(ServerId::of) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return new MaintenanceSnapshot(isGlobalActive(), servers, java.time.Instant.now()); + } + public boolean isGlobalCountdownRunning() { return globalCountdownRunning.get(); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListener.java index 018249b6..ee466076 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListener.java @@ -6,8 +6,9 @@ import com.velocitypowered.api.event.player.ServerPreConnectEvent; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.RegisteredServer; +import nl.hauntedmc.proxyfeatures.api.capability.operations.TwoFactorApi; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; -import nl.hauntedmc.proxyfeatures.features.twofactor.TwoFactor; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; public class MaintenanceConnectionListener { @@ -105,15 +106,10 @@ private boolean isSecurityRoute(ServerPreConnectEvent event, RegisteredServer ta return false; } - var loaded = feature.getPlugin().getFeatureLoadManager().getFeatureRegistry() - .getLoadedFeature("TwoFactor"); - if (!(loaded instanceof TwoFactor twoFactor)) { - return false; - } - var service = twoFactor.getService(); - return service != null - && service.isLocked(event.getPlayer()) - && twoFactor.isLockServer(targetName); + return feature.findCapability(TwoFactorApi.class) + .map(api -> api.isLocked(event.getPlayer().getUniqueId()) + && api.isAuthenticationServer(ServerId.of(targetName))) + .orElse(false); } @Subscribe diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/meta/Meta.java deleted file mode 100644 index b93c11d2..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/maintenance/meta/Meta.java +++ /dev/null @@ -1,16 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.maintenance.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Maintenance"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/Messenger.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/Messenger.java index d9e4ff62..e2697b53 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/Messenger.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/Messenger.java @@ -1,11 +1,11 @@ package nl.hauntedmc.proxyfeatures.features.messager; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.messager.command.MessagingCommand; import nl.hauntedmc.proxyfeatures.features.messager.command.ReplyCommand; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; @@ -13,17 +13,16 @@ import nl.hauntedmc.proxyfeatures.features.messager.history.PlayerMessageLogEntity; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessagingHandler; import nl.hauntedmc.proxyfeatures.features.messager.listener.PlayerListener; -import nl.hauntedmc.proxyfeatures.features.messager.meta.Meta; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; -public class Messenger extends VelocityBaseFeature { +public class Messenger extends VelocityBaseFeature { private MessagingHandler handler; private ORMContext ormContext; private PlayerMessageHistoryLogService messageHistoryLogService; private MessageMode defaultMessageMode = MessageMode.FRIENDS; - public Messenger(FeatureContext context) { + public Messenger(FeatureContext context) { super(context); } @@ -76,10 +75,7 @@ public void initialize() { PlayerMessageSettingsEntity.class, PlayerMessageLogEntity.class ).orElseThrow(); - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Messenger.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); this.messageHistoryLogService = new PlayerMessageHistoryLogService(getLogger(), ormContext, playerResolver); this.handler = new MessagingHandler(this); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingCommand.java index 913ad914..d7fcc6a0 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingCommand.java @@ -3,11 +3,11 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessagingHandler; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessengerTargetVisibility; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import java.util.*; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/ReplyCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/ReplyCommand.java index 865960a9..9f340699 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/ReplyCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/command/ReplyCommand.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessagingHandler; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessengerTargetVisibility; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverter.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverter.java index 54837357..02686f3c 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverter.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverter.java @@ -2,7 +2,7 @@ import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; /** * Persists message modes by stable name. diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntity.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntity.java index caf7e8c9..cfe58dc1 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntity.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntity.java @@ -1,12 +1,11 @@ package nl.hauntedmc.proxyfeatures.features.messager.entity; import jakarta.persistence.*; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import java.util.HashSet; import java.util.Objects; -import java.util.Optional; import java.util.Set; @Entity @@ -28,8 +27,8 @@ public class PlayerMessageSettingsEntity { private boolean msgSpy = false; @Convert(converter = MessageModeConverter.class) - @Column(name = "message_mode", length = 32) - private MessageMode messageMode; + @Column(name = "message_mode", length = 32, nullable = false) + private MessageMode messageMode = MessageMode.FRIENDS; @ElementCollection(fetch = FetchType.LAZY) @CollectionTable( @@ -63,12 +62,8 @@ public void setMsgSpy(boolean msgSpy) { this.msgSpy = msgSpy; } - public Optional getStoredMessageMode() { - return Optional.ofNullable(messageMode); - } - - public MessageMode getMessageMode(MessageMode fallback) { - return messageMode == null ? Objects.requireNonNull(fallback, "fallback") : messageMode; + public MessageMode getMessageMode() { + return messageMode; } public void setMessageMode(MessageMode messageMode) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicy.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicy.java index b3703e80..2a3c5ab7 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicy.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicy.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.messager.internal; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import java.util.Objects; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandler.java index 57a5aea4..911883d6 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandler.java @@ -1,14 +1,13 @@ package nl.hauntedmc.proxyfeatures.features.messager.internal; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.friends.FriendshipApi; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; import nl.hauntedmc.proxyfeatures.features.messager.history.PlayerMessageHistoryLogService; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -35,7 +34,7 @@ public final class MessagingHandler { private final Map lastMessageFrom = new ConcurrentHashMap<>(); public MessagingHandler(Messenger feature) { - this(feature, () -> FeatureServices.find(feature, FriendshipApi.class)); + this(feature, () -> feature.findCapability(FriendshipApi.class)); } MessagingHandler(Messenger feature, FriendshipApi friendshipApi) { @@ -66,14 +65,14 @@ public boolean loadPlayerSettings(Player player) { if (loadedSettings.contains(id)) return true; try { - PlayerMessageSettingsEntity s = settings.loadSettings(id, player.getUsername()); + PlayerMessageSettingsEntity s = settings.loadSettings(id); // messaging toggle if (!s.isMsgToggle()) toggledOff.add(id); else toggledOff.remove(id); // privacy mode - messageModes.put(id, s.getMessageMode(feature.getDefaultMessageMode())); + messageModes.put(id, s.getMessageMode()); // spy mode if (s.isMsgSpy()) spies.add(id); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsService.java index fed8adc1..42ec5b90 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsService.java @@ -1,10 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.messager.internal; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; @@ -19,28 +17,23 @@ public class MessagingSettingsService { private final PlayerReferenceResolver playerResolver; public MessagingSettingsService(Messenger feature) { - this(feature, feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Messenger."))); - } - - MessagingSettingsService(Messenger feature, DataRegistryApi dataRegistry) { this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(dataRegistry); + this.playerResolver = feature.getPlugin().getPlayerReferenceResolver(); } - MessagingSettingsService(Messenger feature, PlayerDirectory playerDirectory) { + MessagingSettingsService(Messenger feature, PlayerReferenceResolver playerResolver) { this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + this.playerResolver = playerResolver; } /** * Loads MessageSettings for an existing DataRegistryApi player. * If the player row is not available yet, returns transient configured defaults without writing PlayerReference. */ - public PlayerMessageSettingsEntity loadSettings(UUID uuid, String username) { + public PlayerMessageSettingsEntity loadSettings(UUID uuid) { return feature.getOrmContext().runInTransaction(session -> { MessageMode defaultMode = defaultMessageMode(); - PlayerReference playerEnt = findPlayer(session, uuid, username); + PlayerReference playerEnt = playerResolver.resolveReference(uuid); if (playerEnt == null) { PlayerMessageSettingsEntity transientSettings = new PlayerMessageSettingsEntity(); transientSettings.setMessageMode(defaultMode); @@ -56,10 +49,6 @@ public PlayerMessageSettingsEntity loadSettings(UUID uuid, String username) { PlayerMessageSettingsEntity settings; if (existing.isPresent()) { settings = existing.get(); - if (settings.getStoredMessageMode().isEmpty()) { - settings.setMessageMode(defaultMode); - session.merge(settings); - } } else { settings = new PlayerMessageSettingsEntity(playerEnt); settings.setMessageMode(defaultMode); @@ -72,10 +61,6 @@ public PlayerMessageSettingsEntity loadSettings(UUID uuid, String username) { }); } - private PlayerReference findPlayer(org.hibernate.Session session, UUID uuid, String username) { - return playerResolver.resolveManaged(session, uuid); - } - public void setToggle(PlayerReference player, boolean enabled) { feature.getOrmContext().runInTransaction(session -> { PlayerMessageSettingsEntity s = session.find(PlayerMessageSettingsEntity.class, player.getId()); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibility.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibility.java index a1859389..d8ea102e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibility.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibility.java @@ -1,9 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.messager.internal; import com.velocitypowered.api.proxy.Player; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.util.List; import java.util.Objects; @@ -48,9 +47,10 @@ public static List list(Messenger feature, Player viewer) { return List.copyOf(feature.getPlugin().getProxy().getAllPlayers()); } try { - return FeatureServices.find(feature, VanishAPI.class) - .map(VanishAPI::getAdjustedOnlinePlayers) - .map(List::copyOf) + return feature.findCapability(PresenceApi.class) + .map(api -> feature.getPlugin().getProxy().getAllPlayers().stream() + .filter(player -> !api.isHidden(player.getUniqueId())) + .toList()) .orElseGet(() -> List.copyOf(feature.getPlugin().getProxy().getAllPlayers())); } catch (RuntimeException failure) { feature.getPlugin().getLogger() @@ -64,8 +64,8 @@ private static boolean isVisible(Messenger feature, Player viewer, Player target return true; } try { - return FeatureServices.find(feature, VanishAPI.class) - .map(api -> !api.isVanished(target.getUniqueId())) + return feature.findCapability(PresenceApi.class) + .map(api -> !api.isHidden(target.getUniqueId())) .orElse(true); } catch (RuntimeException failure) { feature.getPlugin().getLogger() diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/meta/Meta.java deleted file mode 100644 index 5c6dd9e0..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.messager.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Messenger"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/messaging/MessageMode.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/model/MessageMode.java similarity index 94% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/messaging/MessageMode.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/model/MessageMode.java index c807abc8..e4cce0c2 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/messaging/MessageMode.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/messager/model/MessageMode.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.messaging; +package nl.hauntedmc.proxyfeatures.features.messager.model; import java.util.Locale; import java.util.Optional; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/Motd.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/Motd.java index 2bf4628d..ba71e954 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/Motd.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/Motd.java @@ -1,12 +1,11 @@ package nl.hauntedmc.proxyfeatures.features.motd; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.motd.internal.MotdHandler; import nl.hauntedmc.proxyfeatures.features.motd.listener.PingListener; -import nl.hauntedmc.proxyfeatures.features.motd.meta.Meta; import java.util.List; @@ -35,11 +34,11 @@ *

  • {@code random_words}: compose line 2 from random words plus the configured suffix.
  • * */ -public class Motd extends VelocityBaseFeature { +public class Motd extends VelocityBaseFeature { private MotdHandler motdHandler; - public Motd(FeatureContext context) { + public Motd(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandler.java index 1029e35a..2d4a3532 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandler.java @@ -3,13 +3,13 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.velocitypowered.api.proxy.server.ServerPing; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContext; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; import nl.hauntedmc.proxyfeatures.features.motd.Motd; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import nl.hauntedmc.proxyfeatures.features.versioncheck.VersionCheck; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.VersionApi; import java.util.ArrayList; import java.util.Collections; @@ -52,14 +52,14 @@ public MotdHandler(Motd feature) { .build(); } - public ServerPing modifyServerPing(ServerPing unmodifiedPing) { - return createNewServerPing(unmodifiedPing); + public ServerPing modifyServerPing(ServerPing unmodifiedPing, MotdContext context) { + return createNewServerPing(unmodifiedPing, context); } - private ServerPing createNewServerPing(ServerPing unmodifiedPing) { + private ServerPing createNewServerPing(ServerPing unmodifiedPing, MotdContext context) { PlayerCountSnapshot playerCounts = getPlayerCountSnapshot(unmodifiedPing); ServerPing.Version version = getDisplayedVersion(unmodifiedPing); - Component motd = getMotd(playerCounts, version); + Component motd = getMotd(playerCounts, version, context); return new ServerPing(version, playerCounts.adjustedPlayers(), @@ -70,14 +70,12 @@ private ServerPing createNewServerPing(ServerPing unmodifiedPing) { private ServerPing.Version getDisplayedVersion(ServerPing unmodifiedPing) { ServerPing.Version version = unmodifiedPing.getVersion(); - if (feature.getPlugin().getFeatureLoadManager().getFeatureRegistry().isFeatureLoaded("VersionCheck")) { - VersionCheck versionCheck = (VersionCheck) feature.getPlugin() - .getFeatureLoadManager() - .getFeatureRegistry() - .getLoadedFeature("VersionCheck"); - if (versionCheck.getVersionHandler().isUnsupportedVersion(unmodifiedPing.getVersion().getProtocol())) { - int minProtocol = versionCheck.getVersionHandler().getMinimumProtcolVersion(); - String friendlyName = versionCheck.getVersionHandler().getFriendlyProtocolName() + "+"; + Optional versionApi = feature.findCapability(VersionApi.class); + if (versionApi.isPresent()) { + VersionApi supported = versionApi.get(); + if (!supported.isSupported(unmodifiedPing.getVersion().getProtocol())) { + int minProtocol = supported.minimumProtocolVersion(); + String friendlyName = supported.minimumVersionName() + "+"; version = new ServerPing.Version(minProtocol, friendlyName); } } @@ -95,8 +93,9 @@ private PlayerCountSnapshot getPlayerCountSnapshot(ServerPing unmodifiedPing) { int onlinePlayers = players.map(ServerPing.Players::getOnline).orElse(0); int maxPlayers = players.map(ServerPing.Players::getMax).orElse(onlinePlayers); List sample = players.map(ServerPing.Players::getSample).orElse(List.of()); - int vanishedPlayers = FeatureServices.find(feature, VanishAPI.class) - .map(VanishAPI::getVanishedCount) + int vanishedPlayers = feature.findCapability(PresenceApi.class) + .map(PresenceApi::snapshot) + .map(snapshot -> snapshot.hiddenCount()) .orElse(0); int visiblePlayers = Math.max(0, onlinePlayers - vanishedPlayers); @@ -115,9 +114,9 @@ private double readPlayerCountMultiplier() { return feature.getConfigHandler().get("player_count_multiplier", Double.class, 1.0D); } - private Component getMotd(PlayerCountSnapshot playerCounts, ServerPing.Version version) { + private Component getMotd(PlayerCountSnapshot playerCounts, ServerPing.Version version, MotdContext context) { MotdConfig config = configCache.get(TEMPLATE_CACHE_KEY, ignored -> MotdConfig.load(feature)); - String renderedMotd = renderMotd(config, playerCounts, version); + String renderedMotd = renderMotd(config, playerCounts, version, context); return motdCache.get(renderedMotd, this::buildComponent); } @@ -127,9 +126,16 @@ public void invalidateCache() { rotatingMessageCursor.set(0); } - private String renderMotd(MotdConfig config, PlayerCountSnapshot playerCounts, ServerPing.Version version) { + private String renderMotd( + MotdConfig config, + PlayerCountSnapshot playerCounts, + ServerPing.Version version, + MotdContext context + ) { String line1 = applyPlaceholders(config.line1(), playerCounts, version); - String line2Template = MotdLine2OverrideRegistry.resolve().orElseGet(() -> resolveLine2(config)); + String line2Template = feature.getPlugin().getMotdExtensions().resolve(context) + .flatMap(contribution -> contribution.secondLine()) + .orElseGet(() -> resolveLine2(config)); String line2 = applyPlaceholders(line2Template, playerCounts, version); return line1 + "\n" + line2; } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdLine2OverrideRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdLine2OverrideRegistry.java deleted file mode 100644 index 91065527..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdLine2OverrideRegistry.java +++ /dev/null @@ -1,75 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.motd.internal; - -import java.util.Comparator; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; - -/** - * Simple global registry for runtime line-2 MOTD overrides. - * - *

    This keeps features such as Maintenance decoupled from Motd load order. - * Providers return a raw line-2 template, or {@code null} when they do not want - * to override the MOTD for the current moment. - * - *

    If multiple features register an override at the same time, the entry with the - * highest priority wins. Ties are resolved deterministically by registry key. - */ -public final class MotdLine2OverrideRegistry { - - private static final Map ENTRIES = new ConcurrentHashMap<>(); - - private MotdLine2OverrideRegistry() { - } - - public static void register(String key, int priority, Supplier supplier) { - if (key == null || key.isBlank() || supplier == null) { - return; - } - ENTRIES.put(key, new Entry(priority, supplier)); - } - - public static void unregister(String key) { - if (key == null || key.isBlank()) { - return; - } - ENTRIES.remove(key); - } - - public static Optional resolve() { - return resolveRegistration().map(ResolvedOverride::value); - } - - public static Optional resolveRegistration() { - return ENTRIES.entrySet().stream() - .sorted(Comparator.>comparingInt(entry -> entry.getValue().priority()).reversed() - .thenComparing(Map.Entry::getKey)) - .map(entry -> { - String value = entry.getValue().supply(); - if (value == null || value.isBlank()) { - return null; - } - return new ResolvedOverride(entry.getKey(), entry.getValue().priority(), value); - }) - .filter(entry -> entry != null) - .findFirst(); - } - - static void clear() { - ENTRIES.clear(); - } - - public record ResolvedOverride(String key, int priority, String value) { - } - - private record Entry(int priority, Supplier supplier) { - private String supply() { - try { - return supplier.get(); - } catch (RuntimeException ignored) { - return null; - } - } - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/listener/PingListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/listener/PingListener.java index 25231d9f..7ffa2839 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/listener/PingListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/listener/PingListener.java @@ -3,6 +3,7 @@ import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.proxy.ProxyPingEvent; import com.velocitypowered.api.proxy.server.ServerPing; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContext; import nl.hauntedmc.proxyfeatures.features.motd.Motd; public class PingListener { @@ -15,7 +16,11 @@ public PingListener(Motd feature) { @Subscribe(priority = 10) public void onProxyPing(ProxyPingEvent event) { - ServerPing serverPing = feature.getMotdHandler().modifyServerPing(event.getPing()); + MotdContext context = new MotdContext( + event.getConnection().getRemoteAddress().getAddress(), + event.getConnection().getProtocolVersion().getProtocol() + ); + ServerPing serverPing = feature.getMotdHandler().modifyServerPing(event.getPing(), context); event.setPing(serverPing); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/meta/Meta.java deleted file mode 100644 index 44381627..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/motd/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.motd.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Motd"; - } - - @Override - public String getFeatureVersion() { - return "1.2.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/PlayerCount.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/PlayerCount.java index 5473dae5..d34be7cc 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/PlayerCount.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/PlayerCount.java @@ -1,15 +1,14 @@ package nl.hauntedmc.proxyfeatures.features.playercount; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; -import nl.hauntedmc.proxyfeatures.features.playercount.internal.PlayerCountAPI; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.features.playercount.internal.PlayerCountService; import nl.hauntedmc.proxyfeatures.features.playercount.internal.PlayerCountPublisher; -import nl.hauntedmc.proxyfeatures.features.playercount.meta.Meta; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountApi; import java.time.Duration; import java.util.Optional; @@ -17,16 +16,16 @@ /** * Publishes vanish-aware player counts for the complete network and every backend. */ -public final class PlayerCount extends VelocityBaseFeature { +public final class PlayerCount extends VelocityBaseFeature { static final String DEFAULT_CHANNEL = "proxy.playercount.snapshot"; static final int DEFAULT_PUBLISH_INTERVAL_SECONDS = 2; static final String DEFAULT_PUBLISHER_ID = "proxy"; - private PlayerCountAPI api; + private PlayerCountService api; private PlayerCountPublisher publisher; - public PlayerCount(FeatureContext context) { + public PlayerCount(FeatureContext context) { super(context); } @@ -47,11 +46,11 @@ public MessageMap getDefaultMessages() { @Override public void initialize() { - api = new PlayerCountAPI( + api = new PlayerCountService( getPlugin().getProxy(), - () -> FeatureServices.find(this, VanishAPI.class) + () -> findCapability(PresenceApi.class) ); - getLifecycleManager().getApiManager().registerService(PlayerCountAPI.class, api); + getLifecycleManager().getApiManager().registerService(PlayerCountApi.class, api); Optional redisBus = registerRedisMessagingDataAccess( "redis", @@ -90,7 +89,7 @@ public void disable() { } } - public PlayerCountAPI getApi() { + public PlayerCountService getApi() { return api; } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisher.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisher.java index 09f2ef21..d8bc8f11 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisher.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisher.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; import nl.hauntedmc.proxyfeatures.contracts.messaging.PlayerCountSnapshotMessage; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountSnapshot; import nl.hauntedmc.proxyfeatures.features.playercount.PlayerCount; import java.util.LinkedHashMap; @@ -22,7 +23,7 @@ public final class PlayerCountPublisher { private final PlayerCount feature; private final MessagingDataAccess redisBus; - private final PlayerCountAPI api; + private final PlayerCountService api; private final String channel; private final String publisherId; private final String publisherEpoch = UUID.randomUUID().toString(); @@ -34,7 +35,7 @@ public final class PlayerCountPublisher { public PlayerCountPublisher( PlayerCount feature, MessagingDataAccess redisBus, - PlayerCountAPI api, + PlayerCountService api, String channel, String publisherId ) { @@ -52,7 +53,7 @@ public void publishNow() { PlayerCountSnapshotMessage message; try { - PlayerCountSnapshot snapshot = api.capture(); + PlayerCountSnapshot snapshot = api.snapshot(); if (closed) { publishInFlight.set(false); return; @@ -104,8 +105,8 @@ public void close() { private PlayerCountSnapshotMessage toMessage(PlayerCountSnapshot snapshot, long nextSequence) { Map servers = new LinkedHashMap<>(); snapshot.servers().forEach((serverName, counts) -> servers.put( - serverName, - new PlayerCountSnapshotMessage.ServerCounts(counts.online(), counts.vanished()) + serverName.value(), + new PlayerCountSnapshotMessage.ServerCounts(counts.online(), counts.hidden()) )); return new PlayerCountSnapshotMessage( publisherId, @@ -113,7 +114,7 @@ private PlayerCountSnapshotMessage toMessage(PlayerCountSnapshot snapshot, long nextSequence, System.currentTimeMillis(), snapshot.network().online(), - snapshot.network().vanished(), + snapshot.network().hidden(), servers ); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountAPI.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountService.java similarity index 65% rename from proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountAPI.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountService.java index ec0d24fd..21b4d5a8 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountAPI.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountService.java @@ -2,33 +2,37 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountApi; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCounts; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; import nl.hauntedmc.proxyfeatures.contracts.messaging.PlayerCountSnapshotMessage; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import java.util.Collection; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.time.Instant; import java.util.function.Supplier; /** * Reusable source of vanish-aware network and backend player counts. */ -public final class PlayerCountAPI { +public final class PlayerCountService implements PlayerCountApi { private final ProxyServer proxy; - private final Supplier> vanishApi; + private final Supplier> presenceApi; - public PlayerCountAPI(ProxyServer proxy, Supplier> vanishApi) { + public PlayerCountService(ProxyServer proxy, Supplier> presenceApi) { this.proxy = java.util.Objects.requireNonNull(proxy, "proxy"); - this.vanishApi = java.util.Objects.requireNonNull(vanishApi, "vanishApi"); + this.presenceApi = java.util.Objects.requireNonNull(presenceApi, "presenceApi"); } - public PlayerCountSnapshot capture() { + @Override + public PlayerCountSnapshot snapshot() { List players = List.copyOf(proxy.getAllPlayers()); Set vanishedPlayers = currentVanishedPlayers(); Map perServer = new LinkedHashMap<>(); @@ -64,34 +68,21 @@ public PlayerCountSnapshot capture() { }); } - Map immutableServers = new LinkedHashMap<>(); - perServer.forEach((serverName, counts) -> immutableServers.put(serverName, counts.snapshot())); + Map immutableServers = new LinkedHashMap<>(); + perServer.forEach((serverName, counts) -> immutableServers.put(ServerId.of(serverName), counts.snapshot())); return new PlayerCountSnapshot( - new PlayerCountSnapshot.Counts(players.size(), networkVanished), - immutableServers + new PlayerCounts(players.size(), networkVanished), + immutableServers, + Instant.now() ); } - public PlayerCountSnapshot.Counts getNetworkCounts() { - return capture().network(); - } - - public PlayerCountSnapshot.Counts getServerCounts(String serverName) { - return capture().server(serverName); - } - private Set currentVanishedPlayers() { - Optional current = vanishApi.get(); + Optional current = presenceApi.get(); if (current == null || current.isEmpty()) { return Set.of(); } - Collection vanished = current.get().getVanishedPlayers(); - if (vanished == null || vanished.isEmpty()) { - return Set.of(); - } - Set playerIds = new HashSet<>(); - vanished.forEach(player -> playerIds.add(player.getUniqueId())); - return Set.copyOf(playerIds); + return current.get().snapshot().hiddenPlayers(); } private static final class MutableCounts { @@ -105,8 +96,8 @@ private void increment(boolean isVanished) { } } - private PlayerCountSnapshot.Counts snapshot() { - return new PlayerCountSnapshot.Counts(online, vanished); + private PlayerCounts snapshot() { + return new PlayerCounts(online, vanished); } } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountSnapshot.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountSnapshot.java deleted file mode 100644 index 6239d31c..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountSnapshot.java +++ /dev/null @@ -1,61 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playercount.internal; - -import nl.hauntedmc.proxyfeatures.contracts.messaging.PlayerCountSnapshotMessage; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Immutable in-process view of current network and backend player counts. - */ -public record PlayerCountSnapshot( - Counts network, - Map servers -) { - - public PlayerCountSnapshot { - network = Objects.requireNonNull(network, "network"); - if (servers == null || servers.isEmpty()) { - servers = Map.of(); - } else { - Map normalized = new LinkedHashMap<>(); - servers.forEach((name, counts) -> { - String normalizedName = PlayerCountSnapshotMessage.normalizeServerName(name); - if (normalizedName.isEmpty()) { - throw new IllegalArgumentException("server name must not be blank"); - } - Counts normalizedCounts = Objects.requireNonNull(counts, "server counts"); - if (normalized.putIfAbsent(normalizedName, normalizedCounts) != null) { - throw new IllegalArgumentException( - "duplicate normalized server name: " + normalizedName - ); - } - }); - servers = Map.copyOf(normalized); - } - } - - public Counts server(String serverName) { - return servers.getOrDefault( - PlayerCountSnapshotMessage.normalizeServerName(serverName), - Counts.empty() - ); - } - - public record Counts(int online, int vanished) { - public Counts { - if (online < 0 || vanished < 0 || vanished > online) { - throw new IllegalArgumentException("invalid player counts"); - } - } - - public static Counts empty() { - return new Counts(0, 0); - } - - public int visible() { - return Math.max(0, online - vanished); - } - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/meta/Meta.java deleted file mode 100644 index 8f957f8b..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playercount/meta/Meta.java +++ /dev/null @@ -1,23 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playercount.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public final class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "PlayerCount"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/PlayerInfo.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/PlayerInfo.java index bf6f7fb1..4fad384d 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/PlayerInfo.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/PlayerInfo.java @@ -1,21 +1,17 @@ package nl.hauntedmc.proxyfeatures.features.playerinfo; -import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.playerinfo.command.PlayerInfoCommand; -import nl.hauntedmc.proxyfeatures.features.playerinfo.meta.Meta; import nl.hauntedmc.proxyfeatures.features.playerinfo.service.PlayerInfoService; -import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; -public class PlayerInfo extends VelocityBaseFeature { +public class PlayerInfo extends VelocityBaseFeature { - private ORMContext ormContext; private PlayerInfoService service; - public PlayerInfo(FeatureContext context) { + public PlayerInfo(FeatureContext context) { super(context); } @@ -70,10 +66,6 @@ public MessageMap getDefaultMessages() { @Override public void initialize() { - ormContext = createPlayerOrmContext( - SanctionEntity.class - ).orElseThrow(); - service = new PlayerInfoService(this); // Command @@ -84,10 +76,6 @@ public void initialize() { public void disable() { } - public ORMContext getOrmContext() { - return ormContext; - } - public PlayerInfoService getService() { return service; } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/command/PlayerInfoCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/command/PlayerInfoCommand.java index 33e3a299..9e950b71 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/command/PlayerInfoCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/command/PlayerInfoCommand.java @@ -6,11 +6,11 @@ import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.dataregistry.api.player.PlayerNameHistoryEntry; import nl.hauntedmc.dataregistry.api.player.PlayerProfile; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionSnapshot; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; import nl.hauntedmc.proxyfeatures.features.playerinfo.PlayerInfo; import nl.hauntedmc.proxyfeatures.features.playerinfo.service.PlayerInfoService; -import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import java.util.List; import java.util.Locale; @@ -93,7 +93,7 @@ private void renderProfile( Optional languageOpt = svc.getLanguage(playerIdentity); // Active sanctions - List activeSanctions = svc.getActiveSanctions(playerIdentity); + var activeSanctionsStage = svc.getActiveSanctions(playerIdentity); // Header source.sendMessage(feature.getLocalizationHandler() @@ -158,9 +158,14 @@ private void renderProfile( } // Possible alts by last known IP (exclude self, A-Z) - svc.findUsernamesSharingLastIp(profile).whenComplete((altNames, altThrowable) -> + svc.findUsernamesSharingLastIp(profile) + .thenCombine(activeSanctionsStage, ProfileExtras::new) + .whenComplete((extras, extrasThrowable) -> feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - List safeAltNames = altThrowable == null && altNames != null ? altNames : List.of(); + List safeAltNames = extrasThrowable == null && extras != null + ? extras.altNames() : List.of(); + List activeSanctions = extrasThrowable == null && extras != null + ? extras.sanctions() : List.of(); if (safeAltNames.isEmpty()) { sendEntry(source, lblAlts, raw(source, "playerinfo.alts_none")); } else { @@ -172,14 +177,14 @@ private void renderProfile( } else { sendListHeader(source, lblPunishments); String permanentLabel = raw(source, "playerinfo.permanent"); - for (SanctionEntity s : activeSanctions) { - String expires = (s.getExpiresAt() == null) ? permanentLabel : svc.fmt(s.getExpiresAt()); - String created = svc.fmt(s.getCreatedAt()); + for (SanctionSnapshot sanction : activeSanctions) { + String expires = sanction.expiresAt().map(svc::fmt).orElse(permanentLabel); + String created = svc.fmt(sanction.createdAt()); source.sendMessage(feature.getLocalizationHandler() .getMessage("playerinfo.punishment_item") - .with("type", s.getType().name()) - .with("reason", s.getReason()) + .with("type", sanction.type().name()) + .with("reason", sanction.reason()) .with("expires", expires) .with("created", created) .forAudience(source) @@ -190,6 +195,9 @@ private void renderProfile( ); } + private record ProfileExtras(List altNames, List sanctions) { + } + private void sendList(CommandSource audience, String field, List values) { sendListHeader(audience, field); for (String value : values) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/meta/Meta.java deleted file mode 100644 index 09bad1f3..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playerinfo.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "PlayerInfo"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoService.java index 571b1bae..24776f26 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoService.java @@ -7,10 +7,11 @@ import nl.hauntedmc.dataregistry.api.player.PlayerData; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.dataregistry.api.player.PlayerProfile; -import nl.hauntedmc.proxyfeatures.features.playerlanguage.api.LanguageAPI; +import nl.hauntedmc.proxyfeatures.api.capability.player.PlayerLanguageApi; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionFilter; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionsApi; import nl.hauntedmc.proxyfeatures.features.playerinfo.PlayerInfo; -import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.time.Instant; import java.time.ZoneId; @@ -92,32 +93,26 @@ public Optional getLanguage(PlayerIdentity player) { return Optional.empty(); } UUID uuid = player.uuid(); - return FeatureServices.find(feature, LanguageAPI.class) + return feature.findCapability(PlayerLanguageApi.class) .map(api -> { - var preference = api.getPreference(uuid); - if (preference == nl.hauntedmc.proxyfeatures.api.io.localization.Language.AUTO) { - return "AUTO (" + api.get(uuid).name() + ")"; + Optional preference = api.preference(uuid); + if (preference.isEmpty()) { + String effective = api.resolvedLanguage(uuid) + .map(java.util.Locale::toLanguageTag) + .orElse("unknown"); + return "AUTO (" + effective.toUpperCase(java.util.Locale.ROOT) + ")"; } - return preference.name(); + return preference.get().toLanguageTag().toUpperCase(java.util.Locale.ROOT); }); } - public List getActiveSanctions(PlayerIdentity player) { + public CompletionStage> getActiveSanctions(PlayerIdentity player) { if (player == null) { - return List.of(); + return CompletableFuture.completedFuture(List.of()); } - Instant now = Instant.now(); - return feature.getOrmContext().runInTransaction(session -> - session.createQuery( - "SELECT s FROM SanctionEntity s " + - "WHERE s.targetPlayerId = :playerId AND s.active = true " + - "AND (s.expiresAt IS NULL OR s.expiresAt > :now) " + - "ORDER BY s.createdAt DESC", - SanctionEntity.class) - .setParameter("playerId", player.playerId()) - .setParameter("now", now) - .getResultList() - ); + return feature.findCapability(SanctionsApi.class) + .map(api -> api.find(player.uuid(), SanctionFilter.ACTIVE)) + .orElseGet(() -> CompletableFuture.completedFuture(List.of())); } public OnlineStatus getOnlineStatus(String nameOrUuid) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/PlayerLanguage.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/PlayerLanguage.java index 51300d77..2db862b9 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/PlayerLanguage.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/PlayerLanguage.java @@ -2,19 +2,18 @@ import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.dataregistry.api.DataRegistryFeature; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; -import nl.hauntedmc.proxyfeatures.features.playerlanguage.api.LanguageAPI; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.api.capability.player.PlayerLanguageApi; import nl.hauntedmc.proxyfeatures.features.playerlanguage.command.LanguageCommand; import nl.hauntedmc.proxyfeatures.features.playerlanguage.listener.LanguageListener; -import nl.hauntedmc.proxyfeatures.features.playerlanguage.meta.Meta; import nl.hauntedmc.proxyfeatures.features.playerlanguage.service.LanguageService; import java.util.List; -public class PlayerLanguage extends VelocityBaseFeature { +public class PlayerLanguage extends VelocityBaseFeature { public static final String DEFAULT_LANGUAGE = "AUTO"; public static final String DEFAULT_AUTO_FALLBACK_LANGUAGE = "EN"; @@ -22,7 +21,7 @@ public class PlayerLanguage extends VelocityBaseFeature { private LanguageService service; - public PlayerLanguage(FeatureContext context) { + public PlayerLanguage(FeatureContext context) { super(context); } @@ -69,7 +68,7 @@ public void initialize() { service = new LanguageService(this, dataRegistry); getLifecycleManager().getCommandManager().registerFeatureCommand(new LanguageCommand(this)); getLifecycleManager().getListenerManager().registerListener(new LanguageListener(this)); - getLifecycleManager().getApiManager().registerService(LanguageAPI.class, service); + getLifecycleManager().getApiManager().registerService(PlayerLanguageApi.class, service); } @Override diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/api/LanguageAPI.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/api/LanguageAPI.java deleted file mode 100644 index e56967b1..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/api/LanguageAPI.java +++ /dev/null @@ -1,13 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playerlanguage.api; - -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; - -import java.util.UUID; - -public interface LanguageAPI { - Language get(UUID playerUuid); - - Language getPreference(UUID playerUuid); - - void set(UUID playerUuid, Language language); -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommand.java index 631222e9..0eec3ecf 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommand.java @@ -2,9 +2,9 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; import nl.hauntedmc.proxyfeatures.features.playerlanguage.PlayerLanguage; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; import java.util.Arrays; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicy.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicy.java index 92dad1a5..a010ced0 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicy.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicy.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.playerlanguage.command; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; import java.util.ArrayList; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/meta/Meta.java deleted file mode 100644 index ebb71f4e..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/meta/Meta.java +++ /dev/null @@ -1,28 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playerlanguage.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "PlayerLanguage"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getDependencies() { - return List.of("AntiVPN"); - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java index 0a919816..1dd962c4 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java @@ -4,11 +4,10 @@ import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.dataregistry.api.player.PlayerData; import nl.hauntedmc.dataregistry.api.player.PlayerLanguageSettings; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; -import nl.hauntedmc.proxyfeatures.features.antivpn.api.CountryAPI; +import nl.hauntedmc.proxyfeatures.api.capability.player.NetworkLocationApi; +import nl.hauntedmc.proxyfeatures.api.capability.player.PlayerLanguageApi; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; import nl.hauntedmc.proxyfeatures.features.playerlanguage.PlayerLanguage; -import nl.hauntedmc.proxyfeatures.features.playerlanguage.api.LanguageAPI; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.time.Duration; import java.util.Locale; @@ -22,7 +21,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; -public final class LanguageService implements LanguageAPI { +public final class LanguageService implements PlayerLanguageApi { public record LanguageState(Language preference, Language effective) { } @@ -61,12 +60,10 @@ public void forget(UUID playerUuid) { stateCache.remove(playerUuid); } - @Override public Language get(UUID playerUuid) { return getState(playerUuid).effective(); } - @Override public Language getPreference(UUID playerUuid) { return getState(playerUuid).preference(); } @@ -75,7 +72,6 @@ public Language getEffectiveLanguage(UUID playerUuid) { return getState(playerUuid).effective(); } - @Override public void set(UUID playerUuid, Language language) { setAsync(playerUuid, language); } @@ -110,6 +106,38 @@ public CompletionStage setAsync(UUID playerUuid, Language language) { }); } + @Override + public Optional resolvedLanguage(UUID playerId) { + return Optional.of(toLocale(get(playerId))); + } + + @Override + public Optional preference(UUID playerId) { + Language preference = getPreference(playerId); + return preference == Language.AUTO ? Optional.empty() : Optional.of(toLocale(preference)); + } + + @Override + public CompletionStage setPreference(UUID playerId, Locale language) { + Objects.requireNonNull(language, "language"); + Language requested = fromLocale(language); + return savePreference(playerId, requested); + } + + @Override + public CompletionStage clearPreference(UUID playerId) { + return savePreference(playerId, Language.AUTO); + } + + private CompletionStage savePreference(UUID playerId, Language requested) { + Objects.requireNonNull(playerId, "playerId"); + return setAsync(playerId, requested).thenCompose(saved -> saved + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new nl.hauntedmc.proxyfeatures.api.ApiOperationException( + nl.hauntedmc.proxyfeatures.api.ApiFailureCode.PERSISTENCE_UNAVAILABLE, + "Language preference was not saved"))); + } + public Optional resolveUuidByName(String username) { if (username == null || username.isBlank()) { return Optional.empty(); @@ -244,8 +272,9 @@ private Language resolveEffectiveLanguage(UUID playerUuid, Language preference, } private String resolveCountryCode(UUID playerUuid) { - return FeatureServices.find(feature, CountryAPI.class) - .flatMap(countryApi -> countryApi.getCountry(playerUuid)) + return feature.findCapability(NetworkLocationApi.class) + .flatMap(countryApi -> countryApi.countryCode(playerUuid)) + .map(nl.hauntedmc.proxyfeatures.api.model.CountryCode::value) .orElse("UNKNOWN"); } @@ -320,6 +349,17 @@ private static String nullToEmpty(String value) { return value == null ? "" : value; } + private static Locale toLocale(Language language) { + return switch (language) { + case NL -> Locale.forLanguageTag("nl"); + case EN, AUTO -> Locale.ENGLISH; + }; + } + + private static Language fromLocale(Locale locale) { + return "nl".equalsIgnoreCase(locale.getLanguage()) ? Language.NL : Language.EN; + } + private static String rootMessage(Throwable throwable) { Throwable current = throwable; while (current.getCause() != null) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/PlayerList.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/PlayerList.java index aa4cd202..2bd53e10 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/PlayerList.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/PlayerList.java @@ -1,21 +1,20 @@ package nl.hauntedmc.proxyfeatures.features.playerlist; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.playerlist.command.GlobalListCommand; import nl.hauntedmc.proxyfeatures.features.playerlist.command.ListCommand; import nl.hauntedmc.proxyfeatures.features.playerlist.internal.PlayerListHandler; -import nl.hauntedmc.proxyfeatures.features.playerlist.meta.Meta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import java.util.List; -public class PlayerList extends VelocityBaseFeature { +public class PlayerList extends VelocityBaseFeature { private PlayerListHandler playerListHandler; - public PlayerList(FeatureContext context) { + public PlayerList(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/GlobalListCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/GlobalListCommand.java index cd809d69..951c387a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/GlobalListCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/GlobalListCommand.java @@ -4,8 +4,7 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.type.CastUtils; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.playerlist.PlayerList; import java.util.Collection; @@ -19,7 +18,7 @@ public class GlobalListCommand implements FeatureCommand { public GlobalListCommand(PlayerList feature) { this.feature = feature; - blacklist = CastUtils.safeCastToList(feature.getConfigHandler().get("blacklist"), String.class); + blacklist = feature.getConfigHandler().getList("blacklist", String.class, List.of()); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/ListCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/ListCommand.java index 2d9fccd2..8e1617c1 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/ListCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/command/ListCommand.java @@ -5,8 +5,7 @@ import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.type.CastUtils; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.playerlist.PlayerList; import java.util.List; @@ -21,7 +20,7 @@ public class ListCommand implements FeatureCommand { public ListCommand(PlayerList feature) { this.feature = feature; - this.blacklist = CastUtils.safeCastToList(feature.getConfigHandler().get("blacklist"), String.class); + this.blacklist = feature.getConfigHandler().getList("blacklist", String.class, List.of()); } public void execute(Invocation invocation) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandler.java index 68e34d86..ad102612 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandler.java @@ -6,8 +6,7 @@ import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.TextComponent; import nl.hauntedmc.proxyfeatures.features.playerlist.PlayerList; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import java.util.*; import java.util.concurrent.TimeUnit; @@ -184,13 +183,13 @@ public Component formatPlayerList(String serverName, Collection players, } private Predicate visiblePlayerPredicate() { - Optional vanishApi = FeatureServices.find(feature, VanishAPI.class); + Optional vanishApi = feature.findCapability(PresenceApi.class); if (vanishApi.isEmpty()) { return player -> true; } - VanishAPI api = vanishApi.get(); - return player -> !api.isVanished(player.getUniqueId()); + PresenceApi api = vanishApi.get(); + return player -> !api.isHidden(player.getUniqueId()); } private ServerView createServerView(RegisteredServer server, Predicate isVisible) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/meta/Meta.java deleted file mode 100644 index d6880a0e..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlist/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playerlist.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "PlayerList"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } - - @Override - public List getDependencies() { - return List.of(); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/ProxyInfo.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/ProxyInfo.java index 266d35b0..9f17b347 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/ProxyInfo.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/ProxyInfo.java @@ -1,19 +1,18 @@ // src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/ProxyInfo.java package nl.hauntedmc.proxyfeatures.features.proxyinfo; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.proxyinfo.command.ProxyInfoCommand; -import nl.hauntedmc.proxyfeatures.features.proxyinfo.meta.Meta; import java.time.Instant; -public class ProxyInfo extends VelocityBaseFeature { +public class ProxyInfo extends VelocityBaseFeature { private final Instant startTime; - public ProxyInfo(FeatureContext context) { + public ProxyInfo(FeatureContext context) { super(context); this.startTime = Instant.now(); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/command/ProxyInfoCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/command/ProxyInfoCommand.java index 0b774de6..8285c946 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/command/ProxyInfoCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/command/ProxyInfoCommand.java @@ -4,7 +4,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.server.RegisteredServer; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.proxyinfo.ProxyInfo; import java.lang.management.ManagementFactory; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/meta/Meta.java deleted file mode 100644 index 37a2c197..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/proxyinfo/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.proxyinfo.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "ProxyInfo"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/Queue.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/Queue.java index a2a069db..c24bfb7c 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/Queue.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/Queue.java @@ -1,24 +1,22 @@ package nl.hauntedmc.proxyfeatures.features.queue; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.api.queue.QueueAdmissionAPI; -import nl.hauntedmc.proxyfeatures.api.queue.QueueObservabilityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueApi; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.QueueAdmissionPort; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.queue.command.QueueCommand; import nl.hauntedmc.proxyfeatures.features.queue.listener.ConnectionListener; -import nl.hauntedmc.proxyfeatures.features.queue.meta.Meta; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.time.Duration; import java.util.List; -public class Queue extends VelocityBaseFeature { +public class Queue extends VelocityBaseFeature { private QueueManager manager; - public Queue(FeatureContext context) { + public Queue(FeatureContext context) { super(context); } @@ -71,10 +69,10 @@ public MessageMap getDefaultMessages() { @Override public void initialize() { - CapacityAPI capacity = FeatureServices.require(this, CapacityAPI.class); + CapacityAPI capacity = requireInternalService(CapacityAPI.class); manager = new QueueManager(this, getPlugin().getLogger(), capacity); - getLifecycleManager().getApiManager().registerService(QueueAdmissionAPI.class, manager); - getLifecycleManager().getApiManager().registerService(QueueObservabilityAPI.class, manager); + getLifecycleManager().getApiManager().registerService(QueueApi.class, manager); + getLifecycleManager().getApiManager().registerInternalService(QueueAdmissionPort.class, manager); getLifecycleManager().getListenerManager().registerListener(new ConnectionListener(manager)); getLifecycleManager().getCommandManager().registerFeatureCommand(new QueueCommand(this, manager)); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManager.java index 42cd49fb..64437378 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManager.java @@ -4,16 +4,22 @@ import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.scheduler.ScheduledTask; -import nl.hauntedmc.proxyfeatures.api.queue.QueueAdmissionAPI; -import nl.hauntedmc.proxyfeatures.api.queue.QueueObservabilityAPI; -import nl.hauntedmc.proxyfeatures.api.queue.QueueServerSnapshot; -import nl.hauntedmc.proxyfeatures.api.queue.QueueSnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.AdmissionIntent; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDecision; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDenialReason; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityLease; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityRequest; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueApi; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueJoinRequest; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueJoinResult; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueJoinStatus; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueLeaveStatus; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueServerSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueuedPlayerSnapshot; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDecision; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDenialReason; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityLease; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityRequest; +import nl.hauntedmc.proxyfeatures.framework.admission.QueueAdmissionPort; import nl.hauntedmc.proxyfeatures.features.queue.model.QueueEntry; import nl.hauntedmc.proxyfeatures.features.queue.model.ServerQueue; import nl.hauntedmc.proxyfeatures.features.queue.util.PriorityResolver; @@ -30,12 +36,14 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; /** Priority/FIFO waiting and paced dispatch. Capacity is the sole capacity authority. */ -public final class QueueManager implements QueueAdmissionAPI, QueueObservabilityAPI { +public final class QueueManager implements QueueApi, QueueAdmissionPort { private static final class Dispatch { private final QueueEntry entry; @@ -158,24 +166,71 @@ public synchronized void shutdown() { actionbarCycle.clear(); } - @Override public boolean isQueueEnabled(String serverName) { return !closed && queues.containsKey(normalize(serverName)); } + @Override + public boolean isEnabled(ServerId server) { + return isQueueEnabled(server.value()); + } + public boolean isServerQueued(String serverName) { return isQueueEnabled(serverName); } @Override - public synchronized boolean enqueue(Player player, String serverName, CapacityDenialReason reason, - CapacityRequest admissionContext) { + public synchronized boolean enqueueDenied( + Player player, + ServerId server, + CapacityDenialReason reason, + CapacityRequest admissionContext + ) { + QueueJoinResult result = enqueueNow(player, server.value(), reason, admissionContext); + return result.entry().isPresent(); + } + + @Override + public CompletionStage join(QueueJoinRequest request) { + if (request == null || closed) { + return CompletableFuture.completedFuture(QueueJoinResult.failure(QueueJoinStatus.UNAVAILABLE)); + } + Player player = proxy.getPlayer(request.playerId()).orElse(null); + if (player == null) { + return CompletableFuture.completedFuture(QueueJoinResult.failure(QueueJoinStatus.PLAYER_OFFLINE)); + } + if (!isEnabled(request.targetServer())) { + return CompletableFuture.completedFuture(QueueJoinResult.failure(QueueJoinStatus.QUEUE_DISABLED)); + } + String previous = player.getCurrentServer() + .map(connection -> connection.getServerInfo().getName()) + .orElse(""); + CapacityRequest context = capacity.createRequest( + player, + previous, + request.targetServer().value(), + AdmissionIntent.INTERNAL + ); + return CompletableFuture.completedFuture(enqueueNow( + player, + request.targetServer().value(), + CapacityDenialReason.FULL, + context + )); + } + + private synchronized QueueJoinResult enqueueNow( + Player player, + String serverName, + CapacityDenialReason reason, + CapacityRequest admissionContext + ) { if (closed || player == null || admissionContext == null || reason != CapacityDenialReason.FULL) { - return false; + return QueueJoinResult.failure(QueueJoinStatus.REJECTED); } String server = normalize(serverName); ServerQueue target = queues.get(server); - if (target == null) return false; + if (target == null) return QueueJoinResult.failure(QueueJoinStatus.QUEUE_DISABLED); UUID playerId = player.getUniqueId(); admissionContexts.put(playerId, admissionContext); @@ -187,6 +242,11 @@ public synchronized boolean enqueue(Player player, String serverName, CapacityDe player.sendMessage(feature.getLocalizationHandler().getMessage("queue.join.already_in_queue") .with("server", server).with("position", position) .forAudience(player).build()); + actionbarCycle.putIfAbsent(playerId, -1); + return QueueJoinResult.success( + QueueJoinStatus.ALREADY_QUEUED, + snapshotEntry(target, target.find(playerId).orElseThrow(), position, false) + ); } else { cancelInFlight(playerId, false); ServerQueue old = queues.get(current); @@ -196,12 +256,15 @@ public synchronized boolean enqueue(Player player, String serverName, CapacityDe player.sendMessage(feature.getLocalizationHandler().getMessage("queue.join.moved_between_queues") .with("server", server).with("position", position) .forAudience(player).build()); + actionbarCycle.putIfAbsent(playerId, -1); + return QueueJoinResult.success( + QueueJoinStatus.MOVED, + snapshotEntry(target, target.find(playerId).orElseThrow(), position, false) + ); } - actionbarCycle.putIfAbsent(playerId, -1); - return true; } - target.enqueue(playerId, resolvePriority(player)); + QueueEntry entry = target.enqueue(playerId, resolvePriority(player)); int position = target.positionOf(playerId).orElse(0) + 1; player.sendMessage(feature.getLocalizationHandler().getMessage("queue.join.denied.full") .with("server", server).with("position", position) @@ -210,12 +273,15 @@ public synchronized boolean enqueue(Player player, String serverName, CapacityDe .with("seconds", graceSeconds).forAudience(player).build()); actionbarCycle.putIfAbsent(playerId, -1); scheduleDispatch(server, false); - return true; + return QueueJoinResult.success( + QueueJoinStatus.JOINED, + snapshotEntry(target, entry, position, false) + ); } @Override - public void wake(String serverName) { - scheduleDispatch(normalize(serverName), true); + public void capacityChanged(ServerId server) { + scheduleDispatch(server.value(), true); } private void scheduleDispatch(String server, boolean resetBackoff) { @@ -233,7 +299,7 @@ private void scheduleDispatch(String server, boolean resetBackoff) { } @Override - public synchronized boolean consumeCancelledAdvance(UUID playerId, String targetServer) { + public synchronized boolean consumeCancelledAdvance(UUID playerId, ServerId targetServer) { if (playerId == null) return false; long now = System.currentTimeMillis(); CancelledAdvance cancelled = cancelledAdvances.get(playerId); @@ -242,14 +308,14 @@ public synchronized boolean consumeCancelledAdvance(UUID playerId, String target cancelledAdvances.remove(playerId, cancelled); return false; } - if (!cancelled.server().equals(normalize(targetServer))) return false; + if (!cancelled.server().equals(targetServer.value())) return false; return cancelledAdvances.remove(playerId, cancelled); } @Override public synchronized QueueSnapshot snapshot() { long now = System.currentTimeMillis(); - Map snapshots = new LinkedHashMap<>(); + Map snapshots = new LinkedHashMap<>(); for (String server : queues.keySet().stream().sorted().toList()) { ServerQueue queue = queues.get(server); if (queue == null) continue; @@ -262,14 +328,15 @@ public synchronized QueueSnapshot snapshot() { int dispatching = (int) inFlight.values().stream() .filter(dispatch -> dispatch.server().equals(server)) .count(); - snapshots.put(server, new QueueServerSnapshot( - server, + ServerId serverId = ServerId.of(server); + snapshots.put(serverId, new QueueServerSnapshot( + serverId, queue.size(), connected.get(), queue.graceCount(), dispatching, blocked.get(), - queue.oldestEnqueuedAt().orElse(null) + queue.oldestEnqueuedAt() )); } return new QueueSnapshot(snapshots, Instant.ofEpochMilli(now)); @@ -283,7 +350,7 @@ public int resolvePriority(Player player) { return priorityResolver.resolve(player); } - public synchronized Optional leave(UUID playerId) { + public synchronized Optional leaveNow(UUID playerId) { Optional server = findQueueOf(playerId); if (server.isEmpty()) { clearPlayerState(playerId); @@ -299,6 +366,40 @@ public synchronized Optional leave(UUID playerId) { return server; } + @Override + public CompletionStage leave(UUID playerId) { + if (playerId == null || closed) { + return CompletableFuture.completedFuture(QueueLeaveStatus.UNAVAILABLE); + } + return CompletableFuture.completedFuture( + leaveNow(playerId).isPresent() ? QueueLeaveStatus.LEFT : QueueLeaveStatus.NOT_QUEUED + ); + } + + @Override + public synchronized Optional find(UUID playerId) { + if (playerId == null || closed) return Optional.empty(); + for (ServerQueue queue : queues.values()) { + Optional entry = queue.find(playerId); + if (entry.isPresent()) { + int position = queue.positionOf(playerId).orElse(0) + 1; + return Optional.of(snapshotEntry(queue, entry.get(), position, false)); + } + } + Dispatch dispatch = inFlight.get(playerId); + if (dispatch == null) return Optional.empty(); + ServerQueue queue = queues.get(dispatch.server()); + ServerId server = ServerId.of(dispatch.server()); + return Optional.of(new QueuedPlayerSnapshot( + playerId, + server, + 1, + dispatch.entry().priority(), + dispatch.entry().enqueuedAt(), + true + )); + } + public synchronized void onDisconnect(UUID playerId) { cancelledAdvances.remove(playerId); Dispatch dispatch = inFlight.remove(playerId); @@ -579,6 +680,22 @@ private static long deadline(long now, long delay) { return now + Math.max(0L, delay); } + private static QueuedPlayerSnapshot snapshotEntry( + ServerQueue queue, + QueueEntry entry, + int position, + boolean inFlight + ) { + return new QueuedPlayerSnapshot( + entry.playerId(), + ServerId.of(queue.serverName()), + position, + entry.priority(), + entry.enqueuedAt(), + inFlight + ); + } + private static String normalize(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommand.java index 870ab697..c928ed48 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommand.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.queue.Queue; import nl.hauntedmc.proxyfeatures.features.queue.QueueManager; import nl.hauntedmc.proxyfeatures.features.queue.model.ServerQueue; @@ -99,7 +99,7 @@ private void handleLeave(Invocation invocation) { .build()); return; } - Optional queueServer = manager.leave(player.getUniqueId()); + Optional queueServer = manager.leaveNow(player.getUniqueId()); if (queueServer.isEmpty()) { source.sendMessage(feature.getLocalizationHandler() .getMessage("queue.status.none") diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/meta/Meta.java deleted file mode 100644 index 500563d3..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/meta/Meta.java +++ /dev/null @@ -1,22 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.queue.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - @Override - public String getFeatureName() { - return "Queue"; - } - - @Override - public String getFeatureVersion() { - return "2.0.0"; - } - - @Override - public List getDependencies() { - return List.of("Capacity"); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/model/ServerQueue.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/model/ServerQueue.java index cd6dc670..e4ee516a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/model/ServerQueue.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/queue/model/ServerQueue.java @@ -107,6 +107,10 @@ public synchronized boolean contains(UUID playerId) { return index.containsKey(playerId); } + public synchronized Optional find(UUID playerId) { + return Optional.ofNullable(index.get(playerId)); + } + public synchronized int size() { return index.size(); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/ResourcePack.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/ResourcePack.java index a9b1c4b4..13cc98bb 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/ResourcePack.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/ResourcePack.java @@ -1,20 +1,19 @@ package nl.hauntedmc.proxyfeatures.features.resourcepack; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.resourcepack.command.ResourcePackCommand; import nl.hauntedmc.proxyfeatures.features.resourcepack.internal.ResourcePackHandler; import nl.hauntedmc.proxyfeatures.features.resourcepack.listener.PlayerListener; import nl.hauntedmc.proxyfeatures.features.resourcepack.listener.ResourcePackStatusListener; -import nl.hauntedmc.proxyfeatures.features.resourcepack.meta.Meta; -public class ResourcePack extends VelocityBaseFeature { +public class ResourcePack extends VelocityBaseFeature { private ResourcePackHandler handler; - public ResourcePack(FeatureContext context) { + public ResourcePack(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommand.java index a6da5d83..b9400446 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommand.java @@ -4,16 +4,12 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.player.ResourcePackInfo; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.resourcepack.ResourcePack; import nl.hauntedmc.proxyfeatures.features.resourcepack.internal.ResourcePackHandler; import nl.hauntedmc.proxyfeatures.features.resourcepack.util.ResourceUtils; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Locale; -import java.util.Optional; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/internal/ResourcePackHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/internal/ResourcePackHandler.java index 182b6e0e..88c1fe15 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/internal/ResourcePackHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/internal/ResourcePackHandler.java @@ -5,9 +5,9 @@ import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.player.ResourcePackInfo; import com.velocitypowered.api.scheduler.ScheduledTask; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import nl.hauntedmc.proxyfeatures.features.resourcepack.ResourcePack; import nl.hauntedmc.proxyfeatures.features.resourcepack.util.ResourceUtils; import org.jetbrains.annotations.NotNull; @@ -202,11 +202,6 @@ private void rememberPackIds(Map loadedPacks) { .build(); } - /** For legacy callers that didn’t specify force/prompt. Defaults: force=true, prompt_key=resourcepack.prompt */ - public @NotNull ResourcePackInfo buildPackInfo(String url, byte[] hash) { - return buildPackInfo(url, hash, true, "resourcepack.prompt"); - } - public ConfigurationToken blockConfiguration( Player player, ResourcePackInfo packInfo, diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/meta/Meta.java deleted file mode 100644 index b4fa002d..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/resourcepack/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.resourcepack.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "ResourcePack"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/Restart.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/Restart.java index 5931a84f..d2dbeb78 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/Restart.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/Restart.java @@ -2,12 +2,13 @@ import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.RestartAdmissionAPI; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.api.capability.operations.RestartApi; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.RestartCoordinationPort; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.restart.command.AutoreconnectCommand; import nl.hauntedmc.proxyfeatures.features.restart.command.ProxyRestartCommand; import nl.hauntedmc.proxyfeatures.features.restart.internal.BackendReconnectManager; @@ -15,15 +16,13 @@ import nl.hauntedmc.proxyfeatures.features.restart.internal.RestartCapacityCoordinator; import nl.hauntedmc.proxyfeatures.features.restart.listener.BackendReconnectListener; import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleBus; -import nl.hauntedmc.proxyfeatures.features.restart.meta.Meta; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Optional; -public class Restart extends VelocityBaseFeature { +public class Restart extends VelocityBaseFeature { private static final String DEFAULT_RESTART_STREAM = "server.restart.lifecycle"; private static final String DEFAULT_CONSUMER_GROUP = "proxyfeatures.restart.autoreconnect"; private static final int MAX_CONSUMER_GROUP_LENGTH = 128; @@ -36,7 +35,7 @@ public class Restart extends VelocityBaseFeature { private BackendReconnectListener reconnectListener; private AutoreconnectCommand autoreconnectCommand; - public Restart(FeatureContext context) { + public Restart(FeatureContext context) { super(context); } @@ -119,6 +118,12 @@ public MessageMap getDefaultMessages() { public void initialize() { handler = new RestartHandler(this); getLifecycleManager().getCommandManager().registerFeatureCommand(new ProxyRestartCommand(this)); + capacityCoordinator = new RestartCapacityCoordinator(this, null); + getLifecycleManager().getApiManager().registerService(RestartApi.class, capacityCoordinator); + getLifecycleManager().getApiManager().registerInternalService( + RestartCoordinationPort.class, + capacityCoordinator + ); if (!getConfigHandler().get("backend_autoreconnect.enabled", Boolean.class, true)) return; Optional redisProvider = registerRedisMessagingProvider("backend-autoreconnect-redis"); @@ -135,7 +140,10 @@ public void initialize() { getLifecycleManager().getCommandManager().registerFeatureCommand(autoreconnectCommand); getLifecycleManager().getListenerManager().registerListener(reconnectListener); - FeatureServices.find(this, CapacityAPI.class).ifPresent(this::attachCapacity); + restartLifecycleBus.setCapacityCoordinator(capacityCoordinator); + reconnectListener.setCapacityCoordinator(capacityCoordinator); + autoreconnectCommand.setCapacityCoordinator(capacityCoordinator); + findInternalService(CapacityAPI.class).ifPresent(this::attachCapacity); String configuredStream = getConfigHandler().get("backend_autoreconnect.stream", String.class, DEFAULT_RESTART_STREAM); @@ -164,12 +172,10 @@ public synchronized boolean attachCapacity(CapacityAPI capacity) { if (attachedCapacity != null) detachCapacityInternal(false); RestartCapacityCoordinator coordinator = capacityCoordinator; - if (coordinator == null) coordinator = new RestartCapacityCoordinator(this, capacity); - else coordinator.attachCapacity(capacity); + if (coordinator == null) return false; + coordinator.attachCapacity(capacity); attachedCapacity = capacity; - capacityCoordinator = coordinator; - getLifecycleManager().getApiManager().registerService(RestartAdmissionAPI.class, coordinator); if (restartLifecycleBus != null) restartLifecycleBus.setCapacityCoordinator(coordinator); if (reconnectListener != null) reconnectListener.setCapacityCoordinator(coordinator); if (autoreconnectCommand != null) autoreconnectCommand.setCapacityCoordinator(coordinator); @@ -187,7 +193,6 @@ private void detachCapacityInternal(boolean shutdownCoordinator) { RestartCapacityCoordinator coordinator = capacityCoordinator; CapacityAPI previousCapacity = attachedCapacity; attachedCapacity = null; - getLifecycleManager().getApiManager().unregisterService(RestartAdmissionAPI.class); if (coordinator == null) return; if (shutdownCoordinator) { if (restartLifecycleBus != null) restartLifecycleBus.setCapacityCoordinator(null); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/AutoreconnectCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/AutoreconnectCommand.java index fa68ee12..16ac6e7c 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/AutoreconnectCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/AutoreconnectCommand.java @@ -2,10 +2,10 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.restart.Restart; import nl.hauntedmc.proxyfeatures.features.restart.internal.BackendReconnectManager; import nl.hauntedmc.proxyfeatures.features.restart.internal.RestartCapacityCoordinator; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import java.util.List; import java.util.Locale; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/ProxyRestartCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/ProxyRestartCommand.java index e512b9dc..de733114 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/ProxyRestartCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/command/ProxyRestartCommand.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.restart.command; import com.velocitypowered.api.command.CommandSource; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.restart.Restart; import nl.hauntedmc.proxyfeatures.features.restart.internal.RestartHandler; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManager.java index 1fe00145..fd6e0431 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManager.java @@ -4,20 +4,12 @@ import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.scheduler.ScheduledTask; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import java.time.Duration; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; /** * Owns proxy-side eligibility for backend restart reconnects. diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinator.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinator.java index 16ff3d5e..7c0a47cb 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinator.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinator.java @@ -2,28 +2,23 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.scheduler.ScheduledTask; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.RestartAdmissionAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; +import nl.hauntedmc.proxyfeatures.api.capability.operations.RestartApi; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.admission.RestartCoordinationPort; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; /** * Mirrors validated backend restart lifecycle messages into Capacity state and return reservations. * The existing BackendReconnectManager remains authoritative for reconnect eligibility and pacing. */ -public final class RestartCapacityCoordinator implements RestartAdmissionAPI { +public final class RestartCapacityCoordinator implements RestartApi, RestartCoordinationPort { private static final String STATE_OWNER = "restart"; @@ -90,6 +85,16 @@ public RestartCapacityCoordinator(Restart feature, CapacityAPI capacity) { this.capacity = capacity; } + @Override + public void attachAdmission(CapacityAPI capacity) { + attachCapacity(capacity); + } + + @Override + public void detachAdmission(CapacityAPI capacity) { + detachCapacity(capacity); + } + /** Rebinds a replacement Capacity service without losing an active restart session. */ public synchronized void attachCapacity(CapacityAPI newCapacity) { if (closed || newCapacity == null) return; @@ -121,7 +126,7 @@ public synchronized void handle(RestartLifecycleMessage message) { } @Override - public synchronized boolean isRestartReturn(UUID playerId, String serverName) { + public synchronized boolean isExpectedReturn(UUID playerId, ServerId server) { if (closed || playerId == null) return false; Candidate candidate = candidatesByPlayer.get(playerId); if (candidate == null || !candidate.session.ready || candidate.phase != Phase.READY) return false; @@ -129,7 +134,19 @@ public synchronized boolean isRestartReturn(UUID playerId, String serverName) { removeSession(candidate.session, true); return false; } - return candidate.session.serverName.equals(normalize(serverName)); + return candidate.session.serverName.equals(server.value()); + } + + @Override + public synchronized boolean isDraining(ServerId server) { + if (closed) return false; + Session session = sessionsByServer.get(server.value()); + if (session == null) return false; + if (session.expiresAtMillis <= System.currentTimeMillis()) { + removeSession(session, true); + return false; + } + return !session.ready; } /** Records the authoritative kick before normal proxy fallback routing selects its destination. */ diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBus.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBus.java index 139a22dd..e5987bfe 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBus.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBus.java @@ -1,5 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.restart.messaging; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; + import nl.hauntedmc.dataprovider.database.messaging.durable.DurableDelivery; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableSubscription; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/meta/Meta.java deleted file mode 100644 index e32dee6b..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/restart/meta/Meta.java +++ /dev/null @@ -1,15 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.restart.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - @Override - public String getFeatureName() { - return "Restart"; - } - - @Override - public String getFeatureVersion() { - return "1.4.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/Sanctions.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/Sanctions.java index c7ec66b3..ae9f6785 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/Sanctions.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/Sanctions.java @@ -1,24 +1,25 @@ package nl.hauntedmc.proxyfeatures.features.sanctions; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionsApi; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.sanctions.audit.PlayerSanctionsSecurityLogEntity; import nl.hauntedmc.proxyfeatures.features.sanctions.audit.SanctionsSecurityAuditLogService; import nl.hauntedmc.proxyfeatures.features.sanctions.command.*; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.features.sanctions.listener.ConnectListener; -import nl.hauntedmc.proxyfeatures.features.sanctions.meta.Meta; import nl.hauntedmc.proxyfeatures.features.sanctions.service.DiscordService; import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionsService; +import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionsCapability; import nl.hauntedmc.proxyfeatures.features.sanctions.service.ServiceLookup; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import java.time.Duration; -public class Sanctions extends VelocityBaseFeature { +public class Sanctions extends VelocityBaseFeature { private ORMContext orm; private SanctionsService service; @@ -26,7 +27,7 @@ public class Sanctions extends VelocityBaseFeature { private DiscordService discordService; private SanctionsSecurityAuditLogService securityAuditLogService; - public Sanctions(FeatureContext context) { + public Sanctions(FeatureContext context) { super(context); } @@ -189,13 +190,14 @@ public void initialize() { SanctionEntity.class, PlayerSanctionsSecurityLogEntity.class) .orElseThrow(); - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Sanctions.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); securityAuditLogService = new SanctionsSecurityAuditLogService(getLogger(), orm, playerResolver); service = new SanctionsService(this); + getLifecycleManager().getApiManager().registerService( + SanctionsApi.class, + new SanctionsCapability(service) + ); serviceLookup = new ServiceLookup(this); discordService = new DiscordService(this); @@ -218,7 +220,7 @@ public void initialize() { getLifecycleManager().getTaskManager().scheduleRepeatingTask(() -> service.sweepExpiries(), Duration.ofSeconds(sweep)); } - private void registerAsyncCommand(nl.hauntedmc.proxyfeatures.api.command.FeatureCommand command) { + private void registerAsyncCommand(nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand command) { getLifecycleManager().getCommandManager() .registerFeatureCommand(new nl.hauntedmc.proxyfeatures.features.sanctions.command.AsyncSanctionsCommand( this, diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommand.java index 8eee4a36..a3232ba8 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommand.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.SimpleCommand; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanCommand.java index 3e69b952..338ff4af 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanIpCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanIpCommand.java index 306e193d..3084216f 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanIpCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/BanIpCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/KickCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/KickCommand.java index c139b931..a3b875ab 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/KickCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/KickCommand.java @@ -3,8 +3,8 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/MuteCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/MuteCommand.java index a8299d80..a0736450 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/MuteCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/MuteCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/SanctionListCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/SanctionListCommand.java index c91b0d5e..dd2924af 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/SanctionListCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/SanctionListCommand.java @@ -3,10 +3,10 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java index 0c89c95d..3c4162c6 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java @@ -3,8 +3,8 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionSuggestionLookup; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java index bdcbc605..34973e68 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.sanctions.command; import com.velocitypowered.api.command.CommandSource; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import java.net.InetAddress; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java index 76b64816..6fef4836 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java @@ -3,8 +3,8 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionSuggestionLookup; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/WarnCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/WarnCommand.java index 91044eda..12ad3259 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/WarnCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/WarnCommand.java @@ -3,8 +3,8 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import java.util.*; diff --git a/proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntity.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntity.java similarity index 100% rename from proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntity.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntity.java diff --git a/proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionType.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionType.java similarity index 100% rename from proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionType.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionType.java diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListener.java index 867a7a75..821f7041 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListener.java @@ -8,7 +8,7 @@ import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionsService; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import java.net.InetAddress; import java.net.InetSocketAddress; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/meta/Meta.java deleted file mode 100644 index ad27cbd6..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.sanctions.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Sanctions"; - } - - @Override - public String getFeatureVersion() { - return "1.1.1"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordService.java index 60e6f88d..b635277a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordService.java @@ -1,9 +1,9 @@ package nl.hauntedmc.proxyfeatures.features.sanctions.service; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.util.http.DiscordUtils; -import nl.hauntedmc.proxyfeatures.api.util.parse.JsonUtils; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.http.HttpTransport; +import nl.hauntedmc.proxyfeatures.toolkit.json.JsonStrings; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; @@ -11,10 +11,21 @@ public class DiscordService { + @FunctionalInterface + interface WebhookTransport { + void post(String url, String payload) throws java.io.IOException, InterruptedException; + } + private final Sanctions feature; + private final WebhookTransport transport; public DiscordService(Sanctions feature) { + this(feature, HttpTransport::postJsonHttps); + } + + DiscordService(Sanctions feature, WebhookTransport transport) { this.feature = feature; + this.transport = java.util.Objects.requireNonNull(transport, "transport"); } /* ========================= Public API ========================= */ @@ -128,8 +139,12 @@ private void sendEmbed(String sanctionType, int color, String... fieldJsonParts) + "}"; feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { - boolean delivered = DiscordUtils.sendPayload(webhookUrl, payload); - if (!delivered) { + try { + transport.post(webhookUrl, payload); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + feature.getLogger().warn("[Sanctions/Discord] Webhook delivery was interrupted."); + } catch (java.io.IOException | RuntimeException error) { feature.getLogger().warn("[Sanctions/Discord] Failed to deliver webhook payload."); } }); @@ -156,7 +171,7 @@ private String joinWithCommas(String[] parts) { } private String json(String s) { - return JsonUtils.escapeJson(s == null ? "" : s); + return JsonStrings.escapeJson(s == null ? "" : s); } private String nullToDash(String s) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java index 8ae23fb6..2589b1a1 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java @@ -30,8 +30,7 @@ public static List activeTargetNames( } String prefix = startsWith == null ? "" : startsWith.toLowerCase(Locale.ROOT); - PlayerReferenceResolver resolver = new PlayerReferenceResolver(feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for sanction suggestions."))); + PlayerReferenceResolver resolver = feature.getPlugin().getPlayerReferenceResolver(); return feature.getOrm().runInTransaction(session -> session.createQuery( @@ -42,7 +41,7 @@ public static List activeTargetNames( Long.class) .setParameter("type", type) .list().stream() - .map(resolver::findActiveIdentityById) + .map(resolver::findIdentityById) .flatMap(java.util.Optional::stream) .map(identity -> identity.username()) .filter(Objects::nonNull) diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsCapability.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsCapability.java new file mode 100644 index 00000000..5d976859 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsCapability.java @@ -0,0 +1,59 @@ +package nl.hauntedmc.proxyfeatures.features.sanctions.service; + +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionFilter; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionsApi; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** Persistence-independent projection of the sanctions subsystem. */ +public final class SanctionsCapability implements SanctionsApi { + private final SanctionsService service; + private final Clock clock; + + public SanctionsCapability(SanctionsService service) { + this(service, Clock.systemUTC()); + } + + SanctionsCapability(SanctionsService service, Clock clock) { + this.service = Objects.requireNonNull(service, "service"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public CompletionStage> find(UUID playerId, SanctionFilter filter) { + Objects.requireNonNull(playerId, "playerId"); + Objects.requireNonNull(filter, "filter"); + return service.findSanctionsByUuid(playerId, filter == SanctionFilter.ACTIVE) + .thenApply(entities -> entities.stream().map(entity -> snapshot(playerId, entity, clock)).toList()); + } + + private static SanctionSnapshot snapshot(UUID playerId, SanctionEntity entity, Clock clock) { + Instant expiresAt = entity.getExpiresAt(); + boolean active = entity.isActive() && (expiresAt == null || expiresAt.isAfter(clock.instant())); + return new SanctionSnapshot( + entity.getId() == null ? 0L : entity.getId(), + playerId, + switch (entity.getType()) { + case BAN -> nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType.BAN; + case BAN_IP -> nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType.IP_BAN; + case MUTE -> nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType.MUTE; + case WARN -> nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType.WARNING; + case KICK -> nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType.KICK; + }, + entity.getReason(), + entity.getActorName() == null || entity.getActorName().isBlank() + ? "CONSOLE" : entity.getActorName(), + entity.getCreatedAt(), + Optional.ofNullable(expiresAt), + active + ); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsService.java index 82bb5321..968c6829 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsService.java @@ -1,11 +1,9 @@ package nl.hauntedmc.proxyfeatures.features.sanctions.service; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import nl.hauntedmc.dataregistry.api.player.PlayerData; -import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; @@ -32,25 +30,22 @@ public class SanctionsService { private final Map muteCache = new ConcurrentHashMap<>(); public SanctionsService(Sanctions feature) { - this(feature, feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Sanctions."))); - } - - SanctionsService(Sanctions feature, DataRegistryApi dataRegistry) { this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(dataRegistry); - this.players = dataRegistry.players(); + this.playerResolver = feature.getPlugin().getPlayerReferenceResolver(); + this.players = feature.getPlugin().getDataRegistry() + .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Sanctions.")) + .players(); } - SanctionsService(Sanctions feature, PlayerDirectory playerDirectory) { + SanctionsService(Sanctions feature, PlayerReferenceResolver playerResolver) { this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + this.playerResolver = playerResolver; this.players = null; } - SanctionsService(Sanctions feature, PlayerDirectory playerDirectory, PlayerData players) { + SanctionsService(Sanctions feature, PlayerReferenceResolver playerResolver, PlayerData players) { this.feature = feature; - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + this.playerResolver = playerResolver; this.players = Objects.requireNonNull(players, "players"); } @@ -108,8 +103,8 @@ private SanctionEntity createActive(SanctionType type, PlayerReference target, S String sanitized = sanitizeReason(reason); Instant now = Instant.now(); return feature.getOrm().runInTransaction(session -> { - PlayerReference managedTarget = target == null ? null : playerResolver.resolveManagedById(session, target.getId()); - PlayerReference managedActor = actor == null ? null : playerResolver.resolveManagedById(session, actor.getId()); + PlayerReference managedTarget = target == null ? null : playerResolver.resolveReferenceById(target.getId()); + PlayerReference managedActor = actor == null ? null : playerResolver.resolveReferenceById(actor.getId()); // Deactivate any pre-existing active sanctions of same type & target/IP in the same transaction if (managedTarget != null) { session.createMutationQuery( @@ -149,8 +144,8 @@ private void createInstant(SanctionType type, PlayerReference target, String ip, String sanitized = sanitizeReason(reason); Instant now = Instant.now(); feature.getOrm().runInTransaction(session -> { - PlayerReference managedTarget = target == null ? null : playerResolver.resolveManagedById(session, target.getId()); - PlayerReference managedActor = actor == null ? null : playerResolver.resolveManagedById(session, actor.getId()); + PlayerReference managedTarget = target == null ? null : playerResolver.resolveReferenceById(target.getId()); + PlayerReference managedActor = actor == null ? null : playerResolver.resolveReferenceById(actor.getId()); SanctionEntity s = new SanctionEntity(); s.setType(type); s.setTargetPlayer(managedTarget); @@ -519,7 +514,7 @@ private String resolveUsername(PlayerReference ref) { } Long id = ref.getId(); if (id == null) return null; - return playerResolver.findActiveIdentityById(id) + return playerResolver.findIdentityById(id) .map(nl.hauntedmc.dataregistry.api.player.PlayerIdentity::username) .orElse(null); } @@ -670,6 +665,15 @@ public List listSanctionsForPlayer(PlayerReference p, boolean ac }); } + /** Resolves a public UUID without exposing DataRegistry or ORM types to callers. */ + public CompletionStage> findSanctionsByUuid(UUID playerId, boolean activeOnly) { + if (playerId == null) return CompletableFuture.completedFuture(List.of()); + return playerResolver.findByUuidAsync(playerId) + .thenApply(reference -> reference + .map(value -> listSanctionsForPlayer(value, activeOnly)) + .orElseGet(List::of)); + } + /** * Resolve a username for a (possibly detached) PlayerReference safely. */ diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookup.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookup.java index 199b9edf..71fefd9c 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookup.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookup.java @@ -1,8 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.sanctions.service; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; @@ -12,16 +10,11 @@ public class ServiceLookup { private final PlayerReferenceResolver playerResolver; public ServiceLookup(Sanctions feature) { - this(feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Sanctions."))); + this.playerResolver = feature.getPlugin().getPlayerReferenceResolver(); } - ServiceLookup(DataRegistryApi dataRegistry) { - this.playerResolver = new PlayerReferenceResolver(dataRegistry); - } - - ServiceLookup(PlayerDirectory playerDirectory) { - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + ServiceLookup(PlayerReferenceResolver playerResolver) { + this.playerResolver = playerResolver; } public Optional byName(String name) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/ServerLinks.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/ServerLinks.java index eb276363..3759c67f 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/ServerLinks.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/ServerLinks.java @@ -1,19 +1,18 @@ package nl.hauntedmc.proxyfeatures.features.serverlinks; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.serverlinks.internal.ServerLinksHandler; import nl.hauntedmc.proxyfeatures.features.serverlinks.listener.JoinListener; -import nl.hauntedmc.proxyfeatures.features.serverlinks.meta.Meta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; -public class ServerLinks extends VelocityBaseFeature { +public class ServerLinks extends VelocityBaseFeature { private ServerLinksHandler serverLinksHandler; - public ServerLinks(FeatureContext context) { + public ServerLinks(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/meta/Meta.java deleted file mode 100644 index 63565d5c..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/serverlinks/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.serverlinks.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "ServerLinks"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/SlashServer.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/SlashServer.java index acb358fb..bbe2387e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/SlashServer.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/SlashServer.java @@ -1,14 +1,13 @@ package nl.hauntedmc.proxyfeatures.features.slashserver; import com.velocitypowered.api.proxy.server.RegisteredServer; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.slashserver.command.SlashServerAdminCommand; import nl.hauntedmc.proxyfeatures.features.slashserver.command.SlashServerCommand; -import nl.hauntedmc.proxyfeatures.features.slashserver.meta.Meta; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -19,11 +18,11 @@ import java.util.Optional; import java.util.Set; -public class SlashServer extends VelocityBaseFeature { +public class SlashServer extends VelocityBaseFeature { private final Set registeredShorthandCommands = new LinkedHashSet<>(); - public SlashServer(FeatureContext context) { + public SlashServer(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerAdminCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerAdminCommand.java index 3ea6087a..d942e15e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerAdminCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerAdminCommand.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.command.CommandSource; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.slashserver.SlashServer; import java.util.ArrayList; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerCommand.java index 454e5f92..142e2664 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/command/SlashServerCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.server.RegisteredServer; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.slashserver.SlashServer; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/meta/Meta.java deleted file mode 100644 index 082f1597..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/slashserver/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.slashserver.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "SlashServer"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/StaffChat.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/StaffChat.java index 926fb80a..62c55ed5 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/StaffChat.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/StaffChat.java @@ -1,21 +1,20 @@ package nl.hauntedmc.proxyfeatures.features.staffchat; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.staffchat.internal.ChatChannelHandler; import nl.hauntedmc.proxyfeatures.features.staffchat.internal.messaging.EventBusHandler; import nl.hauntedmc.proxyfeatures.features.staffchat.listener.ConnectListener; -import nl.hauntedmc.proxyfeatures.features.staffchat.meta.Meta; -public class StaffChat extends VelocityBaseFeature { +public class StaffChat extends VelocityBaseFeature { private ChatChannelHandler chatChannelHandler; private EventBusHandler eventBusHandler; - public StaffChat(FeatureContext context) { + public StaffChat(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/internal/ChatChannel.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/internal/ChatChannel.java index 207ef5b9..dbf64edb 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/internal/ChatChannel.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/internal/ChatChannel.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; import nl.hauntedmc.proxyfeatures.features.staffchat.StaffChat; import java.util.Collections; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/listener/ConnectListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/listener/ConnectListener.java index 642064ef..c257109e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/listener/ConnectListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/listener/ConnectListener.java @@ -6,7 +6,7 @@ import com.velocitypowered.api.event.player.ServerPostConnectEvent; import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.staffchat.StaffChat; import nl.hauntedmc.proxyfeatures.features.staffchat.internal.ChatChannel; import nl.hauntedmc.proxyfeatures.features.staffchat.internal.ChatChannelHandler; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/meta/Meta.java deleted file mode 100644 index 3504ac26..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/staffchat/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.staffchat.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "StaffChat"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/TextCommands.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/TextCommands.java index e23d38e7..dc964b5c 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/TextCommands.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/TextCommands.java @@ -1,24 +1,23 @@ package nl.hauntedmc.proxyfeatures.features.textcommands; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.textcommands.command.TextCommand; -import nl.hauntedmc.proxyfeatures.features.textcommands.meta.Meta; import java.util.LinkedHashMap; import java.util.HashMap; import java.util.Map; -public class TextCommands extends VelocityBaseFeature { +public class TextCommands extends VelocityBaseFeature { /** * name -> command definition (message key + placeholders) */ private Map commands; - public TextCommands(FeatureContext context) { + public TextCommands(FeatureContext context) { super(context); } @@ -47,7 +46,7 @@ public void initialize() { private void initializeTextCommands() { var commandNodes = getConfigHandler().node("commands").children(); - for (Map.Entry entry : commandNodes.entrySet()) { + for (Map.Entry entry : commandNodes.entrySet()) { String name = entry.getKey(); if (name == null || name.isBlank()) { continue; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommand.java index f238edf2..3defbf8e 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommand.java @@ -3,9 +3,9 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.textcommands.TextCommands; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import java.util.List; import java.util.Map; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/meta/Meta.java deleted file mode 100644 index 6d094f7b..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/textcommands/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.textcommands.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "TextCommands"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactor.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactor.java index ab3f1181..badf301a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactor.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactor.java @@ -4,10 +4,10 @@ import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.scheduler.ScheduledTask; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.twofactor.audit.PlayerTwoFactorLogEntity; import nl.hauntedmc.proxyfeatures.features.twofactor.audit.TwoFactorAuditLogService; import nl.hauntedmc.proxyfeatures.features.twofactor.command.TwoFactorCommand; @@ -15,11 +15,12 @@ import nl.hauntedmc.proxyfeatures.features.twofactor.crypto.TotpService; import nl.hauntedmc.proxyfeatures.features.twofactor.crypto.TwoFactorCrypto; import nl.hauntedmc.proxyfeatures.features.twofactor.listener.TwoFactorListener; -import nl.hauntedmc.proxyfeatures.features.twofactor.meta.Meta; import nl.hauntedmc.proxyfeatures.features.twofactor.persistence.OrmTwoFactorAccountStore; import nl.hauntedmc.proxyfeatures.features.twofactor.persistence.PlayerTwoFactorEntity; import nl.hauntedmc.proxyfeatures.features.twofactor.persistence.TwoFactorAccountStore; import nl.hauntedmc.proxyfeatures.features.twofactor.service.TwoFactorService; +import nl.hauntedmc.proxyfeatures.features.twofactor.service.TwoFactorCapability; +import nl.hauntedmc.proxyfeatures.api.capability.operations.TwoFactorApi; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import java.nio.file.Path; @@ -42,7 +43,7 @@ * secure operating mode requires a dedicated lock server where unauthenticated * players are held until they complete {@code /2fa }. */ -public class TwoFactor extends VelocityBaseFeature { +public class TwoFactor extends VelocityBaseFeature { private static final Duration LOCK_PROMPT_DELAY = Duration.ofMillis(750); @@ -52,7 +53,7 @@ public class TwoFactor extends VelocityBaseFeature { private final Set lockTransfersInFlight = ConcurrentHashMap.newKeySet(); private final ConcurrentHashMap trustedExpiryTasks = new ConcurrentHashMap<>(); - public TwoFactor(FeatureContext context) { + public TwoFactor(FeatureContext context) { super(context); } @@ -170,10 +171,7 @@ public void initialize() { ORMContext orm = createPlayerOrmContext(PlayerTwoFactorEntity.class, PlayerTwoFactorLogEntity.class) .orElseThrow(); - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for TwoFactor.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); auditLogService = new TwoFactorAuditLogService(getLogger(), orm, playerResolver); Path secretDir = getPlugin().getDataDirectory().resolve("local").resolve("twofactor"); @@ -182,6 +180,10 @@ public void initialize() { TotpService totp = new TotpService(config.digits(), config.periodSeconds(), config.allowedDriftWindows()); service = new TwoFactorService(getPlugin().getProxy(), config, store, crypto, totp); + getLifecycleManager().getApiManager().registerService( + TwoFactorApi.class, + new TwoFactorCapability(this, service) + ); getLifecycleManager().getCommandManager().registerFeatureCommand(new TwoFactorCommand(this)); getLifecycleManager().getListenerManager().registerListener(new TwoFactorListener(this)); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java index e66c9e21..c2137ef6 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.twofactor.TwoFactor; import nl.hauntedmc.proxyfeatures.features.twofactor.persistence.TwoFactorAccountState; import nl.hauntedmc.proxyfeatures.features.twofactor.policy.TwoFactorPermissions; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/meta/Meta.java deleted file mode 100644 index bbccda31..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/meta/Meta.java +++ /dev/null @@ -1,21 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.twofactor.meta; - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "TwoFactor"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public java.util.List getPluginDependencies() { - return java.util.List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStore.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStore.java index 3c732005..bae26ec7 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStore.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStore.java @@ -61,7 +61,7 @@ public List listKnownUsernames() { Long.class ) .list().stream() - .map(playerResolver::findActiveIdentityById) + .map(playerResolver::findIdentityById) .flatMap(Optional::stream) .map(identity -> identity.username()) .sorted(String.CASE_INSENSITIVE_ORDER) @@ -141,7 +141,7 @@ private StoredAccount toStoredAccount(PlayerTwoFactorEntity entity, UUID uuid, S } private PlayerReference findPlayer(Session session, UUID uuid) { - return playerResolver.resolveManaged(session, uuid); + return playerResolver.resolveReference(uuid); } private static String normalizeIdentifier(String identifier) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/service/TwoFactorCapability.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/service/TwoFactorCapability.java new file mode 100644 index 00000000..fd798e42 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/service/TwoFactorCapability.java @@ -0,0 +1,29 @@ +package nl.hauntedmc.proxyfeatures.features.twofactor.service; + +import nl.hauntedmc.proxyfeatures.api.capability.operations.TwoFactorApi; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; +import nl.hauntedmc.proxyfeatures.features.twofactor.TwoFactor; + +import java.util.Objects; +import java.util.UUID; + +/** Public projection of active two-factor authentication locks. */ +public final class TwoFactorCapability implements TwoFactorApi { + private final TwoFactor feature; + private final TwoFactorService service; + + public TwoFactorCapability(TwoFactor feature, TwoFactorService service) { + this.feature = Objects.requireNonNull(feature, "feature"); + this.service = Objects.requireNonNull(service, "service"); + } + + @Override + public boolean isLocked(UUID playerId) { + return feature.getPlugin().getProxy().getPlayer(playerId).map(service::isLocked).orElse(false); + } + + @Override + public boolean isAuthenticationServer(ServerId server) { + return feature.isLockServer(server.value()); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/Vanish.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/Vanish.java index 4532d4b1..e73b8400 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/Vanish.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/Vanish.java @@ -2,33 +2,35 @@ import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.feature.stateful.SnapshotState; -import nl.hauntedmc.proxyfeatures.api.feature.stateful.StatefulFeature; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.features.vanish.internal.PresenceService; import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishRegistry; import nl.hauntedmc.proxyfeatures.features.vanish.internal.messaging.EventBusHandler; import nl.hauntedmc.proxyfeatures.features.vanish.listener.ConnectListener; -import nl.hauntedmc.proxyfeatures.features.vanish.meta.Meta; +import nl.hauntedmc.proxyfeatures.framework.feature.ActivatableFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.stateful.SnapshotState; +import nl.hauntedmc.proxyfeatures.framework.feature.stateful.StatefulFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; -import java.util.Map; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.UUID; -public class Vanish extends VelocityBaseFeature implements StatefulFeature { +public class Vanish extends VelocityBaseFeature + implements StatefulFeature, ActivatableFeature { private static final String DEFAULT_STREAM = "proxy.vanish.update"; private static final String DEFAULT_CONSUMER_GROUP = "proxyfeatures.vanish.proxy"; private VanishRegistry vanishRegistry; private EventBusHandler eventBusHandler; - private VanishAPI api; + private PresenceService presenceService; - public Vanish(FeatureContext context) { + public Vanish(FeatureContext context) { super(context); } @@ -46,26 +48,24 @@ public MessageMap getDefaultMessages() { return new MessageMap(); } + /** Prepares state holders and staged API publication without opening external ingress. */ @Override public void initialize() { - // Prepare registry & API this.vanishRegistry = new VanishRegistry(this); - this.api = new VanishAPI(this); - - getLifecycleManager().getApiManager().registerService(VanishAPI.class, this.api); + this.presenceService = new PresenceService(getPlugin().getProxy(), vanishRegistry); + getLifecycleManager().getApiManager().registerService(PresenceApi.class, presenceService); + getLifecycleManager().getApiManager().registerActivationHook(this::activate); + } - // Optional Redis setup + /** Starts Redis consumption and listeners only after reload state restoration. */ + @Override + public void activate() { Optional redisProvider = registerRedisMessagingProvider("redis"); - if (redisProvider.isEmpty()) { getLogger().warn("Redis messaging connection 'redis' not available. Vanish feature will still run but won't receive updates."); } else { DurableMessagingDataAccess redisBus = redisProvider.get().getDurableDataAccess(); - String stream = resolveStream(getConfigHandler().get( - "stream", - String.class, - DEFAULT_STREAM - )); + String stream = resolveStream(getConfigHandler().get("stream", String.class, DEFAULT_STREAM)); String configuredGroup = getConfigHandler().get( "consumer_group", String.class, @@ -84,8 +84,6 @@ public void initialize() { "Consuming durable Redis stream '" + stream + "' as group '" + consumerGroup + "'." ); } - - // Listener to keep registry tidy on disconnects getLifecycleManager().getListenerManager().registerListener(new ConnectListener(this)); } @@ -127,8 +125,8 @@ public EventBusHandler getEventBusHandler() { return eventBusHandler; } - public VanishAPI getVanishAPI() { - return api; + public PresenceApi getPresenceApi() { + return presenceService; } static String resolveStream(String configuredStream) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/event/VanishStateChangeEvent.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/event/VanishStateChangeEvent.java deleted file mode 100644 index 21f56e54..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/event/VanishStateChangeEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.vanish.event; - -import java.util.Objects; -import java.util.UUID; - -/** - * Fired after the proxy accepts and applies a real online player's vanish state transition. - * Snapshot restoration, duplicate revisions and disconnect cleanup do not fire this event. - */ -public record VanishStateChangeEvent(UUID playerUuid, String playerName, boolean vanished) { - - public VanishStateChangeEvent { - Objects.requireNonNull(playerUuid, "playerUuid"); - playerName = playerName == null ? "" : playerName; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/PresenceService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/PresenceService.java new file mode 100644 index 00000000..4fece3ad --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/PresenceService.java @@ -0,0 +1,45 @@ +package nl.hauntedmc.proxyfeatures.features.vanish.internal; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; + +import java.time.Clock; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** Velocity projection of the authoritative Vanish registry. */ +public final class PresenceService implements PresenceApi { + private final ProxyServer proxy; + private final VanishRegistry registry; + private final Clock clock; + + public PresenceService(ProxyServer proxy, VanishRegistry registry) { + this(proxy, registry, Clock.systemUTC()); + } + + PresenceService(ProxyServer proxy, VanishRegistry registry, Clock clock) { + this.proxy = Objects.requireNonNull(proxy, "proxy"); + this.registry = Objects.requireNonNull(registry, "registry"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public boolean isHidden(UUID playerId) { + return registry.isVanished(playerId); + } + + @Override + public PresenceSnapshot snapshot() { + Set online = proxy.getAllPlayers().stream() + .map(Player::getUniqueId) + .collect(Collectors.toUnmodifiableSet()); + Set hidden = registry.snapshot().keySet().stream() + .filter(online::contains) + .collect(Collectors.toUnmodifiableSet()); + return new PresenceSnapshot(online, hidden, clock.instant()); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishAPI.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishAPI.java deleted file mode 100644 index 10e5490d..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishAPI.java +++ /dev/null @@ -1,51 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.vanish.internal; - -import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.features.vanish.Vanish; - -import java.util.List; -import java.util.UUID; - -/** - * Public API other features can use to query vanish-aware player stats. - */ -public class VanishAPI { - - private final Vanish feature; - - public VanishAPI(Vanish feature) { - this.feature = feature; - } - - /** - * All online players minus the currently vanished online players. - */ - public int getAdjustedPlayerCount() { - return feature.getVanishRegistry().getAdjustedOnlineCount(); - } - - /** - * List of online players excluding those currently vanished. - */ - public List getAdjustedOnlinePlayers() { - return feature.getVanishRegistry().getAdjustedOnlinePlayers(); - } - - /** - * List of currently vanished online players. - */ - public List getVanishedPlayers() { - return feature.getVanishRegistry().getVanishedOnlinePlayers(); - } - - /** - * Number of currently vanished online players. - */ - public int getVanishedCount() { - return feature.getVanishRegistry().getVanishedOnlineCount(); - } - - public boolean isVanished(UUID uuid) { - return feature.getVanishRegistry().isVanished(uuid); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistry.java index d2ba804b..ea1f99ed 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistry.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistry.java @@ -1,9 +1,10 @@ package nl.hauntedmc.proxyfeatures.features.vanish.internal; import com.velocitypowered.api.proxy.Player; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceChangedEvent; import nl.hauntedmc.proxyfeatures.features.vanish.Vanish; -import nl.hauntedmc.proxyfeatures.features.vanish.event.VanishStateChangeEvent; +import java.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -48,7 +49,7 @@ public boolean applyUpdate( boolean vanished, long stateVersion ) { - VanishStateChangeEvent stateChange; + PresenceChangedEvent stateChange; synchronized (this) { if (uuid == null || stateVersion <= 0L) { return false; @@ -64,7 +65,7 @@ public boolean applyUpdate( return true; } - private VanishStateChangeEvent applyCurrentState(UUID uuid, String name, boolean vanished) { + private PresenceChangedEvent applyCurrentState(UUID uuid, String name, boolean vanished) { if (uuid == null) { return null; } @@ -90,10 +91,10 @@ private VanishStateChangeEvent applyCurrentState(UUID uuid, String name, boolean if (previouslyVanished == vanished) { return null; } - return new VanishStateChangeEvent(uuid, resolvedName, vanished); + return new PresenceChangedEvent(uuid, resolvedName, vanished, Instant.now()); } - private void publishStateChange(VanishStateChangeEvent stateChange) { + private void publishStateChange(PresenceChangedEvent stateChange) { if (stateChange == null) { return; } @@ -106,7 +107,7 @@ private void publishStateChange(VanishStateChangeEvent stateChange) { if (feature.getLogger() != null) { feature.getLogger().warn( "Could not publish proxy vanish state transition for " - + stateChange.playerUuid() + ": " + exception.getMessage() + + stateChange.playerId() + ": " + exception.getMessage() ); } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/listener/TabCompleteListener.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/listener/TabCompleteListener.java index 0f154204..b9ea44d7 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/listener/TabCompleteListener.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/listener/TabCompleteListener.java @@ -31,7 +31,7 @@ public void onTabComplete(TabCompleteEvent event) { } Set vanishedNamesLower = VanishTabCompletePolicy.normalizeNamesLower( - feature.getVanishAPI().getVanishedPlayers().stream() + feature.getVanishRegistry().getVanishedOnlinePlayers().stream() .map(Player::getUsername) .toList() ); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/meta/Meta.java deleted file mode 100644 index cf50ca67..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/vanish/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.vanish.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Vanish"; - } - - @Override - public String getFeatureVersion() { - return "1.0.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/VersionCheck.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/VersionCheck.java index 7458d55a..b78628a4 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/VersionCheck.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/VersionCheck.java @@ -1,23 +1,25 @@ package nl.hauntedmc.proxyfeatures.features.versioncheck; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.versioncheck.audit.PlayerVersionLogEntity; import nl.hauntedmc.proxyfeatures.features.versioncheck.audit.VersionAuditLogService; import nl.hauntedmc.proxyfeatures.features.versioncheck.internal.VersionHandler; import nl.hauntedmc.proxyfeatures.features.versioncheck.listener.ConnectionListener; -import nl.hauntedmc.proxyfeatures.features.versioncheck.meta.Meta; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; +import nl.hauntedmc.proxyfeatures.api.capability.operations.VersionApi; -public class VersionCheck extends VelocityBaseFeature { +public class VersionCheck extends VelocityBaseFeature { + + public static final int DEFAULT_MINIMUM_PROTOCOL_VERSION = 763; private VersionHandler versionHandler; private VersionAuditLogService auditLogService; - public VersionCheck(FeatureContext context) { + public VersionCheck(FeatureContext context) { super(context); } @@ -25,7 +27,7 @@ public VersionCheck(FeatureContext context) { public ConfigMap getDefaultConfig() { ConfigMap defaults = new ConfigMap(); defaults.put("enabled", false); - defaults.put("minimum_protocol_version", 763); + defaults.put("minimum_protocol_version", DEFAULT_MINIMUM_PROTOCOL_VERSION); defaults.put("friendly_protocol_name", "1.21"); return defaults; } @@ -44,12 +46,10 @@ public void initialize() { if (orm == null) { getLogger().warn("VersionCheck database logging is disabled because the ORM context is unavailable."); } - PlayerReferenceResolver playerResolver = new PlayerReferenceResolver( - getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for VersionCheck.")) - ); + PlayerReferenceResolver playerResolver = getPlugin().getPlayerReferenceResolver(); auditLogService = new VersionAuditLogService(getLogger(), orm, playerResolver); versionHandler = new VersionHandler(this); + getLifecycleManager().getApiManager().registerService(VersionApi.class, versionHandler); getLifecycleManager().getListenerManager().registerListener(new ConnectionListener(this)); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandler.java index fe10192e..091ae6f8 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandler.java @@ -2,70 +2,67 @@ import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.proxy.Player; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.JoinConfiguration; -import net.kyori.adventure.text.format.NamedTextColor; +import nl.hauntedmc.proxyfeatures.api.capability.operations.VersionApi; import nl.hauntedmc.proxyfeatures.features.versioncheck.VersionCheck; import nl.hauntedmc.proxyfeatures.features.versioncheck.audit.VersionAuditLogService; import nl.hauntedmc.proxyfeatures.framework.log.ConnectionLogHelper; -public class VersionHandler { +public class VersionHandler implements VersionApi { private final VersionCheck feature; private final VersionAuditLogService auditLogService; - private final int minimum_protocol_version; - private final String friendly_protocol_name; + private final int minimumProtocolVersion; + private final String friendlyProtocolName; public VersionHandler(VersionCheck feature) { this.feature = feature; this.auditLogService = feature.getAuditLogService(); - minimum_protocol_version = feature.getConfigHandler().get("minimum_protocol_version", Integer.class, 0); + int configuredMinimum = feature.getConfigHandler() + .get("minimum_protocol_version", Integer.class, VersionCheck.DEFAULT_MINIMUM_PROTOCOL_VERSION); + if (configuredMinimum < 0) { + throw new IllegalArgumentException("minimum_protocol_version must be zero or greater"); + } + minimumProtocolVersion = configuredMinimum; String friendly = feature.getConfigHandler().get("friendly_protocol_name", String.class, ""); if (friendly == null || friendly.isBlank()) { friendly = "unsupported"; } - friendly_protocol_name = friendly; + friendlyProtocolName = friendly; } public void checkVersion(LoginEvent event) { Player player = event.getPlayer(); int protocolVersion = player.getProtocolVersion().getProtocol(); - if (isUnsupportedVersion(protocolVersion)) { - auditLogService.logObservation(player, "denied", minimum_protocol_version, friendly_protocol_name); + if (!isSupported(protocolVersion)) { + auditLogService.logObservation(player, "denied", minimumProtocolVersion, friendlyProtocolName); ConnectionLogHelper.logLoginDenied( feature.getLogger(), "unsupported_version", player, - "minimum_protocol", String.valueOf(minimum_protocol_version), - "minimum_version_name", friendly_protocol_name + "minimum_protocol", String.valueOf(minimumProtocolVersion), + "minimum_version_name", friendlyProtocolName ); event.setResult(LoginEvent.ComponentResult.denied( - Component.join( - JoinConfiguration.separator(Component.text(" ")), - Component.text("Verbinding verbroken:", NamedTextColor.RED), - feature.getLocalizationHandler().getMessage("versioncheck.unsupported_version") - .forAudience(player) - .with("friendly_protocol_name", friendly_protocol_name) - .build() - ) + feature.getLocalizationHandler().getMessage("versioncheck.unsupported_version") + .forAudience(player) + .with("friendly_protocol_name", friendlyProtocolName) + .build() )); return; } - auditLogService.logObservation(player, "allowed", minimum_protocol_version, friendly_protocol_name); - } - - public boolean isUnsupportedVersion(int protocolVersion) { - return protocolVersion < minimum_protocol_version; + auditLogService.logObservation(player, "allowed", minimumProtocolVersion, friendlyProtocolName); } - public int getMinimumProtcolVersion() { - return minimum_protocol_version; + @Override + public int minimumProtocolVersion() { + return minimumProtocolVersion; } - public String getFriendlyProtocolName() { - return friendly_protocol_name; + @Override + public String minimumVersionName() { + return friendlyProtocolName; } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/meta/Meta.java deleted file mode 100644 index 6e724dc5..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/versioncheck/meta/Meta.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.versioncheck.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "VersionCheck"; - } - - @Override - public String getFeatureVersion() { - return "1.1.0"; - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/Votifier.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/Votifier.java index 90cdc957..e10f8ea4 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/Votifier.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/Votifier.java @@ -4,10 +4,10 @@ import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.durable.PublishedDurableEvent; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.features.votifier.audit.VotifierSecurityAuditLogService; import nl.hauntedmc.proxyfeatures.features.votifier.audit.VotifierSecurityLogEntity; import nl.hauntedmc.proxyfeatures.features.votifier.command.ExtendedVotifierCommand; @@ -17,19 +17,18 @@ import nl.hauntedmc.proxyfeatures.features.votifier.internal.VotifierService; import nl.hauntedmc.proxyfeatures.features.votifier.listener.VotifierPlayerListener; import nl.hauntedmc.proxyfeatures.features.votifier.messaging.TargetedVoteTestPublisher; -import nl.hauntedmc.proxyfeatures.features.votifier.meta.Meta; import nl.hauntedmc.proxyfeatures.framework.persistence.DataRegistryIdentityGate; import java.util.List; import java.util.concurrent.CompletableFuture; -public class Votifier extends VelocityBaseFeature { +public class Votifier extends VelocityBaseFeature { private volatile VotifierService service; private volatile VotifierSecurityAuditLogService securityAuditLogService; private volatile TargetedVoteTestPublisher targetedVoteTestPublisher; - public Votifier(FeatureContext context) { + public Votifier(FeatureContext context) { super(context); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/ExtendedVotifierCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/ExtendedVotifierCommand.java index a29c91c2..03d2ac10 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/ExtendedVotifierCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/ExtendedVotifierCommand.java @@ -5,7 +5,7 @@ import com.mojang.brigadier.tree.CommandNode; import com.mojang.brigadier.tree.LiteralCommandNode; import com.velocitypowered.api.command.CommandSource; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.votifier.Votifier; import org.jetbrains.annotations.NotNull; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/VotifierCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/VotifierCommand.java index e485c331..519ae515 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/VotifierCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/command/VotifierCommand.java @@ -14,7 +14,7 @@ import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import nl.hauntedmc.proxyfeatures.features.votifier.Votifier; import nl.hauntedmc.proxyfeatures.features.votifier.internal.VoteLeaderboardEntry; import nl.hauntedmc.proxyfeatures.features.votifier.internal.VotePlayerStatsView; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteMonthlyEntity.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteMonthlyEntity.java index f3d344b4..7c6363e7 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteMonthlyEntity.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteMonthlyEntity.java @@ -1,10 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.votifier.entity; -import jakarta.persistence.Column; -import jakarta.persistence.EmbeddedId; -import jakarta.persistence.Entity; -import jakarta.persistence.Index; -import jakarta.persistence.Table; +import jakarta.persistence.*; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; @Entity diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteStatsEntity.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteStatsEntity.java index 63508a74..0212db48 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteStatsEntity.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/entity/PlayerVoteStatsEntity.java @@ -1,10 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.votifier.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.Table; +import jakarta.persistence.*; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; @Entity diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsService.java index b99825fb..10ec3a9d 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsService.java @@ -1,15 +1,13 @@ package nl.hauntedmc.proxyfeatures.features.votifier.internal; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; -import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.proxyfeatures.features.votifier.Votifier; import nl.hauntedmc.proxyfeatures.features.votifier.entity.PlayerVoteMonthlyEntity; import nl.hauntedmc.proxyfeatures.features.votifier.entity.PlayerVoteStatsEntity; import nl.hauntedmc.proxyfeatures.features.votifier.entity.VotifierRolloverStateEntity; import nl.hauntedmc.proxyfeatures.features.votifier.model.Vote; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.LockMode; import org.hibernate.Session; @@ -18,11 +16,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.sql.DatabaseMetaData; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; +import java.sql.*; import java.time.Duration; import java.time.Instant; import java.time.YearMonth; @@ -72,24 +66,13 @@ public VoteStatsService(Votifier feature, ORMContext ormContext) { ORMContext systemOrmContext, boolean ensureSchemaGuard ) { - this(feature, playerOrmContext, systemOrmContext, ensureSchemaGuard, feature.getPlugin().getDataRegistry() - .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for Votifier."))); - } - - VoteStatsService( - Votifier feature, - ORMContext playerOrmContext, - ORMContext systemOrmContext, - boolean ensureSchemaGuard, - DataRegistryApi dataRegistry - ) { - this.feature = feature; - this.playerOrmContext = playerOrmContext; - this.systemOrmContext = systemOrmContext; - this.playerResolver = new PlayerReferenceResolver(dataRegistry); - if (ensureSchemaGuard) { - ensureRemindColumnBestEffort(); - } + this( + feature, + playerOrmContext, + systemOrmContext, + ensureSchemaGuard, + feature.getPlugin().getPlayerReferenceResolver() + ); } VoteStatsService( @@ -97,12 +80,12 @@ public VoteStatsService(Votifier feature, ORMContext ormContext) { ORMContext playerOrmContext, ORMContext systemOrmContext, boolean ensureSchemaGuard, - PlayerDirectory playerDirectory + PlayerReferenceResolver playerResolver ) { this.feature = feature; this.playerOrmContext = playerOrmContext; this.systemOrmContext = systemOrmContext; - this.playerResolver = new PlayerReferenceResolver(playerDirectory); + this.playerResolver = playerResolver; if (ensureSchemaGuard) { ensureRemindColumnBestEffort(); } @@ -322,7 +305,7 @@ public List winnersLeaderboard(int limit) { } private String displayName(long playerId) { - return playerResolver.findActiveIdentityById(playerId) + return playerResolver.findIdentityById(playerId) .map(identity -> identity.username()) .orElse("#" + playerId); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierService.java index d5d971ab..acb2ceed 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierService.java @@ -34,12 +34,7 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; public final class VotifierService { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/VoteDeliverySettings.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/VoteDeliverySettings.java index 78dd0509..5da9a3d2 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/VoteDeliverySettings.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/VoteDeliverySettings.java @@ -1,14 +1,10 @@ package nl.hauntedmc.proxyfeatures.features.votifier.messaging; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.votifier.Votifier; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.util.*; record VoteDeliverySettings( List servers, diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/meta/Meta.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/meta/Meta.java deleted file mode 100644 index 3b33544d..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/meta/Meta.java +++ /dev/null @@ -1,24 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.votifier.meta; - - -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; - -import java.util.List; - -public class Meta implements BaseMeta { - - @Override - public String getFeatureName() { - return "Votifier"; - } - - @Override - public String getFeatureVersion() { - return "1.6.0"; - } - - @Override - public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/server/VotifierServer.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/server/VotifierServer.java index a50dd7e4..53164458 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/server/VotifierServer.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/votifier/server/VotifierServer.java @@ -6,28 +6,14 @@ import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; import javax.crypto.Cipher; -import java.io.EOFException; -import java.io.InputStream; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketAddress; -import java.net.SocketException; -import java.net.SocketTimeoutException; +import java.io.*; +import java.net.*; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.PrivateKey; import java.security.interfaces.RSAPrivateKey; import java.util.Objects; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; public final class VotifierServer { diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/AdmissionIntent.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/AdmissionIntent.java similarity index 88% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/AdmissionIntent.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/AdmissionIntent.java index bf5e4ffc..0a09a499 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/AdmissionIntent.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/AdmissionIntent.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; /** Describes why a backend connection is being attempted. */ public enum AdmissionIntent { diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityAPI.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityAPI.java similarity index 74% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityAPI.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityAPI.java index e3106f99..ba08f703 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityAPI.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityAPI.java @@ -1,5 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; +import com.velocitypowered.api.proxy.Player; import java.time.Instant; import java.util.Collection; import java.util.Optional; @@ -7,6 +8,13 @@ /** Shared authoritative admission-control service exported by the Capacity feature. */ public interface CapacityAPI { + CapacityRequest createRequest( + Player player, + String previousServer, + String targetServer, + AdmissionIntent intent + ); + CapacityDecision tryAcquire(CapacityRequest request); Optional findPreparedLease(UUID playerId, String targetServer); diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityDecision.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityDecision.java similarity index 93% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityDecision.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityDecision.java index 8ce02b5c..30cda2eb 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityDecision.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityDecision.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; import java.util.Objects; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityDenialReason.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityDenialReason.java similarity index 71% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityDenialReason.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityDenialReason.java index 89142ce3..14693d1d 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityDenialReason.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityDenialReason.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; public enum CapacityDenialReason { NONE, diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityLease.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityLease.java similarity index 62% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityLease.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityLease.java index 7829dc59..853a6607 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityLease.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityLease.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; import java.time.Instant; import java.util.UUID; @@ -17,9 +17,13 @@ public interface CapacityLease extends AutoCloseable { boolean isActive(); - void commit(); + default CapacityLeaseState state() { + return isActive() ? CapacityLeaseState.ACTIVE : CapacityLeaseState.INVALIDATED; + } + + boolean commit(); - void release(); + boolean release(); @Override default void close() { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityLeaseState.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityLeaseState.java new file mode 100644 index 00000000..6db31bfa --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityLeaseState.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.framework.admission; + +/** Internal terminal and non-terminal state of a capacity reservation. */ +public enum CapacityLeaseState { + ACTIVE, + COMMITTED, + RELEASED, + EXPIRED, + INVALIDATED +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityRequest.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityRequest.java similarity index 95% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityRequest.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityRequest.java index 53cd696b..5e0ae4c6 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityRequest.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityRequest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; import java.util.Locale; import java.util.Objects; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityScopeSnapshot.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityScopeSnapshot.java similarity index 92% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityScopeSnapshot.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityScopeSnapshot.java index 322b1aea..ea729b90 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityScopeSnapshot.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityScopeSnapshot.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; /** Immutable operational view of one configured capacity scope. */ public record CapacityScopeSnapshot( diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacitySnapshot.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacitySnapshot.java similarity index 89% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacitySnapshot.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacitySnapshot.java index b41431c7..b223a90a 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacitySnapshot.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacitySnapshot.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; import java.util.Map; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityState.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityState.java similarity index 78% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityState.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityState.java index 670060cc..a001c151 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/capacity/CapacityState.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/CapacityState.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.capacity; +package nl.hauntedmc.proxyfeatures.framework.admission; /** Runtime admission state of one backend. */ public enum CapacityState { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/QueueAdmissionPort.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/QueueAdmissionPort.java new file mode 100644 index 00000000..52f710fe --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/QueueAdmissionPort.java @@ -0,0 +1,20 @@ +package nl.hauntedmc.proxyfeatures.framework.admission; + +import com.velocitypowered.api.proxy.Player; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + +import java.util.UUID; + +/** Runtime-only collaboration port between the admission engine and Queue. */ +public interface QueueAdmissionPort { + boolean enqueueDenied( + Player player, + ServerId server, + CapacityDenialReason reason, + CapacityRequest admissionContext + ); + + void capacityChanged(ServerId server); + + boolean consumeCancelledAdvance(UUID playerId, ServerId targetServer); +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/RestartCoordinationPort.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/RestartCoordinationPort.java new file mode 100644 index 00000000..05d29160 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/admission/RestartCoordinationPort.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.framework.admission; + +/** Runtime-only port used to bind restart lifecycle coordination to the admission engine. */ +public interface RestartCoordinationPort { + void attachAdmission(CapacityAPI capacity); + + void detachAdmission(CapacityAPI capacity); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/command/FeatureCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/FeatureCommand.java similarity index 88% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/command/FeatureCommand.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/FeatureCommand.java index 03011cf5..f0742f79 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/command/FeatureCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/FeatureCommand.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.command; +package nl.hauntedmc.proxyfeatures.framework.command; import com.velocitypowered.api.command.SimpleCommand; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommand.java index 9050f1e5..0c9f5974 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommand.java @@ -11,8 +11,8 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.framework.loader.FeatureDescriptor; import nl.hauntedmc.proxyfeatures.framework.loader.disable.FeatureDisableResponse; import nl.hauntedmc.proxyfeatures.framework.loader.enable.FeatureEnableResponse; @@ -170,7 +170,7 @@ void handleInfo(CommandSource sender, String featureName) { var reg = plugin.getFeatureLoadManager().getFeatureRegistry(); // Direct lookup (exact) among loaded - VelocityBaseFeature loaded = reg.getLoadedFeature(featureName); + VelocityBaseFeature loaded = reg.getLoadedFeature(featureName); // Case-insensitive fallback among loaded if (loaded == null) { @@ -410,12 +410,12 @@ void handleReloadLocal(CommandSource sender, String feature) { } void sendPluginStatus(CommandSource sender) { - List> loaded = plugin.getFeatureLoadManager().getFeatureRegistry().getLoadedFeatures(); + List loaded = plugin.getFeatureLoadManager().getFeatureRegistry().getLoadedFeatures(); List cmds = new ArrayList<>(); int loadedCount = loaded.size(); int tasks = 0, listeners = 0, commands = 0, conns = 0; - for (VelocityBaseFeature f : loaded) { + for (VelocityBaseFeature f : loaded) { var registered = f.getLifecycleManager().getCommandManager().getRegisteredCommands(); commands += (registered != null ? registered.size() : 0); if (registered != null) cmds.addAll(registered.keySet()); @@ -436,7 +436,7 @@ void sendPluginStatus(CommandSource sender) { /* ============================ Lists & Rendering ============================ */ void listLoadedFeaturesOneLine(CommandSource sender, boolean withVersion) { - List> loaded = new ArrayList<>(plugin.getFeatureLoadManager() + List loaded = new ArrayList<>(plugin.getFeatureLoadManager() .getFeatureRegistry().getLoadedFeatures()); // Alphabetize by feature name (case-insensitive, null-safe) @@ -452,7 +452,7 @@ void listLoadedFeaturesOneLine(CommandSource sender, boolean withVersion) { Component list = Component.empty(); for (int i = 0; i < loaded.size(); i++) { - VelocityBaseFeature f = loaded.get(i); + VelocityBaseFeature f = loaded.get(i); String name = Objects.toString(f.getFeatureName(), "?"); String version = Objects.toString(f.getFeatureVersion(), "?"); diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/command/brigadier/BrigadierCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/brigadier/BrigadierCommand.java similarity index 93% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/command/brigadier/BrigadierCommand.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/brigadier/BrigadierCommand.java index b44f3356..f01b211a 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/command/brigadier/BrigadierCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/command/brigadier/BrigadierCommand.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.command.brigadier; +package nl.hauntedmc.proxyfeatures.framework.command.brigadier; import com.mojang.brigadier.tree.LiteralCommandNode; import com.velocitypowered.api.command.CommandSource; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/ConfigReloadResult.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/ConfigReloadResult.java new file mode 100644 index 00000000..3122b94a --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/ConfigReloadResult.java @@ -0,0 +1,4 @@ +package nl.hauntedmc.proxyfeatures.framework.config; + +/** Outcome of applying changed configuration to a running feature. */ +public enum ConfigReloadResult { APPLIED, RECREATE_REQUIRED } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandler.java index 71439293..fc0f1c88 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandler.java @@ -1,11 +1,13 @@ package nl.hauntedmc.proxyfeatures.framework.config; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import org.slf4j.Logger; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -33,13 +35,28 @@ public String featureName() { } public void injectDefaults(ConfigMap defaults) { - reconcileMismatchedKeyTypes(defaults); + validatePersistedTypes(defaults); - defaults.forEach((key, value) -> { - if (putIfAbsent(key, value)) { - logger.info("[ProxyFeatures] [Config] Added missing key '{}' for feature '{}'", key, featureName); + List> missing = defaults.entrySet().stream() + .filter(entry -> !node(entry.getKey()).isPresent()) + .toList(); + if (missing.isEmpty()) { + return; + } + + batch(transaction -> { + for (Map.Entry entry : missing) { + try { + transaction.putIfAbsent(entry.getKey(), entry.getValue()); + } catch (org.spongepowered.configurate.serialize.SerializationException exception) { + throw new IllegalStateException("Unable to add default configuration value '" + + entry.getKey() + "' for feature '" + featureName + "'", exception); + } } }); + for (Map.Entry entry : missing) { + logger.info("[ProxyFeatures] [Config] Added missing key '{}' for feature '{}'", entry.getKey(), featureName); + } } @Override @@ -63,8 +80,9 @@ public ConfigNode globalNode(String key) { return globals().node(key); } - private void reconcileMismatchedKeyTypes(ConfigMap defaults) { + private void validatePersistedTypes(ConfigMap defaults) { ConfigNode section = node(); + List mismatches = new ArrayList<>(); for (String topKey : section.keys()) { Kind expected = expectedKindForTopKey(topKey, defaults); if (expected == null) { @@ -74,10 +92,18 @@ private void reconcileMismatchedKeyTypes(ConfigMap defaults) { Object existing = node(topKey).raw(); Kind actual = classify(existing); if (actual != null && expected != actual) { - remove(topKey); - logger.info("[ProxyFeatures] [Config] Removed key '{}' from feature '{}' due to schema change", topKey, featureName); + mismatches.add("'" + topKey + "' expected " + expected + " but found " + actual); } } + + if (!mismatches.isEmpty()) { + logger.error( + "[ProxyFeatures] [Config] Feature '{}' has incompatible persisted values; values were preserved: {}", + featureName, + String.join(", ", mismatches) + ); + throw new FeatureConfigurationException(featureName, mismatches); + } } private Kind expectedKindForTopKey(String topKey, ConfigMap defaults) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigurationException.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigurationException.java new file mode 100644 index 00000000..f2b943a0 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigurationException.java @@ -0,0 +1,28 @@ +package nl.hauntedmc.proxyfeatures.framework.config; + +import java.util.List; +import java.util.Objects; + +/** Raised when persisted feature configuration is incompatible with the feature schema. */ +public final class FeatureConfigurationException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private final String featureName; + private final String[] mismatches; + + public FeatureConfigurationException(String featureName, List mismatches) { + super("Invalid configuration for feature '" + Objects.requireNonNull(featureName, "featureName") + + "': " + String.join(", ", List.copyOf(mismatches)) + + ". Existing values were preserved."); + this.featureName = featureName; + this.mismatches = List.copyOf(mismatches).toArray(String[]::new); + } + + public String featureName() { + return featureName; + } + + public List mismatches() { + return List.of(mismatches); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureStoragePaths.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureStoragePaths.java index 9d7d004e..223aa3a6 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureStoragePaths.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureStoragePaths.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.framework.config; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; import java.util.Objects; import java.util.regex.Pattern; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandler.java index fd1d043b..3f482d0f 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandler.java @@ -1,12 +1,13 @@ package nl.hauntedmc.proxyfeatures.framework.config; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import org.slf4j.Logger; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -75,12 +76,28 @@ public ConfigNode globalNode(String key) { } private void injectGlobalDefaults(Map defaults) { - defaults.forEach((key, value) -> { - String path = "global." + key; - if (putIfAbsent(path, value)) { - logger.info("[ProxyFeatures] [Config] Added missing global key '{}'", path); + List> missing = defaults.entrySet().stream() + .filter(entry -> !node("global." + entry.getKey()).isPresent()) + .toList(); + if (missing.isEmpty()) { + return; + } + + batch(transaction -> { + for (Map.Entry entry : missing) { + try { + transaction.putIfAbsent("global." + entry.getKey(), entry.getValue()); + } catch (org.spongepowered.configurate.serialize.SerializationException exception) { + throw new IllegalStateException( + "Unable to add default global configuration value '" + entry.getKey() + "'", + exception + ); + } } }); + for (Map.Entry entry : missing) { + logger.info("[ProxyFeatures] [Config] Added missing global key 'global.{}'", entry.getKey()); + } } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/extension/DefaultMotdExtensions.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/extension/DefaultMotdExtensions.java new file mode 100644 index 00000000..1ef06ec3 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/extension/DefaultMotdExtensions.java @@ -0,0 +1,70 @@ +package nl.hauntedmc.proxyfeatures.framework.extension; + +import nl.hauntedmc.proxyfeatures.api.extension.ExtensionRegistration; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContribution; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContext; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContributor; +import nl.hauntedmc.proxyfeatures.api.extension.MotdExtensions; + +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Thread-safe, lifecycle-owned MOTD contribution registry. */ +public final class DefaultMotdExtensions implements MotdExtensions { + private record Entry(int priority, MotdContributor contributor) { + } + + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + @Override + public ExtensionRegistration register(String owner, int priority, MotdContributor contributor) { + String key = requireOwner(owner); + Entry entry = new Entry(priority, Objects.requireNonNull(contributor, "contributor")); + if (entries.putIfAbsent(key, entry) != null) { + throw new IllegalStateException("A MOTD contributor is already registered by " + key); + } + AtomicBoolean closed = new AtomicBoolean(); + return () -> { + if (closed.compareAndSet(false, true)) { + entries.remove(key, entry); + } + }; + } + + public Optional resolve(MotdContext context) { + Objects.requireNonNull(context, "context"); + return entries.entrySet().stream() + .sorted(Comparator.>comparingInt(value -> value.getValue().priority()) + .reversed().thenComparing(Map.Entry::getKey)) + .map(value -> contribute(value.getValue(), context)) + .flatMap(Optional::stream) + .findFirst(); + } + + public int size() { + return entries.size(); + } + + private static Optional contribute(Entry entry, MotdContext context) { + try { + Optional contribution = entry.contributor().contribute(context); + return contribution == null ? Optional.empty() : contribution.filter(value -> + value.firstLine().isPresent() || value.secondLine().isPresent()); + } catch (RuntimeException ignored) { + return Optional.empty(); + } + } + + private static String requireOwner(String owner) { + Objects.requireNonNull(owner, "owner"); + String normalized = owner.trim().toLowerCase(java.util.Locale.ROOT); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("owner must not be blank"); + } + return normalized; + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/ActivatableFeature.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/ActivatableFeature.java new file mode 100644 index 00000000..adb63675 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/ActivatableFeature.java @@ -0,0 +1,6 @@ +package nl.hauntedmc.proxyfeatures.framework.feature; + +/** Optional second startup phase invoked after reload state has been restored. */ +public interface ActivatableFeature { + void activate(); +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/Feature.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/Feature.java similarity index 82% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/Feature.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/Feature.java index b043f2c5..b348e8b1 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/Feature.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/Feature.java @@ -1,8 +1,8 @@ -package nl.hauntedmc.proxyfeatures.api.feature; +package nl.hauntedmc.proxyfeatures.framework.feature; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureContext.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureContext.java new file mode 100644 index 00000000..d3a2a76f --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureContext.java @@ -0,0 +1,44 @@ +package nl.hauntedmc.proxyfeatures.framework.feature; + +import nl.hauntedmc.proxyfeatures.ProxyFeatures; +import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; +import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; + +import java.util.List; +import java.util.Objects; + +/** Immutable runtime resources and descriptor data owned by one feature instance. */ +public record FeatureContext( + ProxyFeatures plugin, + String featureName, + String featureVersion, + List featureDependencies, + List pluginDependencies, + FeatureConfigHandler configHandler, + FeatureLifecycleManager lifecycleManager, + FeatureLogger logger, + LocalizationHandler localizationHandler +) { + public FeatureContext { + Objects.requireNonNull(plugin, "plugin"); + featureName = requireText(featureName, "featureName"); + featureVersion = requireText(featureVersion, "featureVersion"); + featureDependencies = List.copyOf(Objects.requireNonNull(featureDependencies, "featureDependencies")); + pluginDependencies = List.copyOf(Objects.requireNonNull(pluginDependencies, "pluginDependencies")); + Objects.requireNonNull(configHandler, "configHandler"); + Objects.requireNonNull(lifecycleManager, "lifecycleManager"); + Objects.requireNonNull(logger, "logger"); + Objects.requireNonNull(localizationHandler, "localizationHandler"); + } + + private static String requireText(String value, String fieldName) { + Objects.requireNonNull(value, fieldName); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return normalized; + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactory.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactory.java index d88f89b3..1a40e85b 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactory.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactory.java @@ -1,8 +1,7 @@ package nl.hauntedmc.proxyfeatures.framework.feature; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.loader.FeatureDescriptor; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.config.FeatureStoragePaths; import nl.hauntedmc.proxyfeatures.framework.config.MainConfigHandler; @@ -11,6 +10,7 @@ import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; +import java.util.List; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; @@ -53,13 +53,16 @@ public FeatureScopeFactory( this.loggerFactory = Objects.requireNonNull(loggerFactory, "loggerFactory"); } - public FeatureContext createContext(T meta) { - Objects.requireNonNull(meta, "meta"); - FeatureScope scope = getScope(meta.getFeatureName()); - FeatureLifecycleManager lifecycleManager = lifecycleManagerFactory.apply(meta.getFeatureName()); - return new FeatureContext<>( + public FeatureContext createContext(FeatureDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor"); + FeatureScope scope = getScope(descriptor.featureName()); + FeatureLifecycleManager lifecycleManager = lifecycleManagerFactory.apply(descriptor.registryName()); + return new FeatureContext( plugin, - meta, + descriptor.featureName(), + descriptor.featureVersion(), + List.copyOf(descriptor.featureDependencies()), + List.copyOf(descriptor.pluginDependencies()), scope.configHandler(), lifecycleManager, scope.logger(), diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeature.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/VelocityBaseFeature.java similarity index 57% rename from proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeature.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/VelocityBaseFeature.java index 240eacf4..75fce31a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeature.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/VelocityBaseFeature.java @@ -1,47 +1,47 @@ -package nl.hauntedmc.proxyfeatures.features; +package nl.hauntedmc.proxyfeatures.framework.feature; import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataprovider.database.DataAccess; import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.Feature; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.config.ConfigReloadResult; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureDataManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import java.util.List; import java.util.Optional; -public abstract class VelocityBaseFeature implements Feature { +/** Framework-owned base class for Velocity feature implementations. */ +public abstract class VelocityBaseFeature implements Feature { - private final FeatureContext context; + private final FeatureContext context; - protected VelocityBaseFeature(FeatureContext context) { + protected VelocityBaseFeature(FeatureContext context) { this.context = context; } public String getFeatureName() { - return context.meta().getFeatureName(); + return context.featureName(); } public String getFeatureVersion() { - return context.meta().getFeatureVersion(); + return context.featureVersion(); } public List getDependencies() { - return context.meta().getDependencies(); + return context.featureDependencies(); } public List getPluginDependencies() { - return context.meta().getPluginDependencies(); + return context.pluginDependencies(); } - public FeatureContext getContext() { + public FeatureContext getContext() { return context; } @@ -81,7 +81,10 @@ protected Optional createSystemOrmContext(String identifier, Class Optional registerRedisMessagingDataAccess(String identifier, Class expectedDataAccessType) { + protected Optional registerRedisMessagingDataAccess( + String identifier, + Class expectedDataAccessType + ) { return dataManager().registerRedisMessagingDataAccess(identifier, expectedDataAccessType); } @@ -108,48 +111,64 @@ public LocalizationHandler getLocalizationHandler() { return context.localizationHandler(); } - /** - * Each feature should define its default settings. - */ - public abstract ConfigMap getDefaultConfig(); + /** Resolves an optional public capability through the stable plugin-owned registry. */ + public final Optional findCapability(Class capabilityType) { + return getPlugin().capabilities().reference(capabilityType).get(); + } + /** Resolves a required public capability or fails with a descriptive startup error. */ + public final T requireCapability(Class capabilityType) { + return findCapability(capabilityType).orElseThrow(() -> new IllegalStateException( + "Required capability is unavailable for " + getFeatureName() + ": " + capabilityType.getName() + )); + } + /** Resolves a runtime-only collaboration port owned by another feature. */ + public final Optional findInternalService(Class serviceType) { + return getPlugin().getInternalServiceRegistry().find(serviceType); + } + /** Resolves a required runtime-only collaboration port. */ + public final T requireInternalService(Class serviceType) { + return getPlugin().getInternalServiceRegistry().require(serviceType); + } - /** - * Each feature should define its default messages. - */ + public abstract ConfigMap getDefaultConfig(); public abstract MessageMap getDefaultMessages(); - - - /** - * Feature initialization logic (must be implemented by each feature). - */ public abstract void initialize(); - - /** - * Feature disable logic (must be implemented by each feature). - */ public abstract void disable(); + /** Applies newly loaded configuration; the default safely requires recreation. */ + public ConfigReloadResult applyConfiguration() { + return ConfigReloadResult.RECREATE_REQUIRED; + } + /** - * Properly unloads the feature using the lifecycle manager. + * Stops ingress, withdraws callable capability surfaces, then lets the implementation release + * its own state. This ordering prevents a consumer from reaching a half-disabled provider. */ public void cleanup() { getPlugin().getLogger().info("Disabling {}", getFeatureName()); Throwable failure = null; + try { + getLifecycleManager().quiesce(); + } catch (Throwable t) { + failure = t; + } + + try { + getLifecycleManager().getApiManager().deactivateServices(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } try { disable(); } catch (Throwable t) { - failure = t; + failure = appendFailure(failure, t); } try { getLifecycleManager().cleanup(); } catch (Throwable t) { - if (failure == null) { - failure = t; - } else { - failure.addSuppressed(t); - } + failure = appendFailure(failure, t); } if (failure != null) { @@ -157,6 +176,14 @@ public void cleanup() { } } + private static Throwable appendFailure(Throwable current, Throwable additional) { + if (current == null) { + return additional; + } + current.addSuppressed(additional); + return current; + } + @SuppressWarnings("unchecked") private static void throwUnchecked(Throwable throwable) throws E { throw (E) throwable; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/stateful/SnapshotState.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/stateful/SnapshotState.java similarity index 67% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/stateful/SnapshotState.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/stateful/SnapshotState.java index 912211bf..dea73ecf 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/stateful/SnapshotState.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/stateful/SnapshotState.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.feature.stateful; +package nl.hauntedmc.proxyfeatures.framework.feature.stateful; /** * Marker for framework-managed feature state that may survive a full feature reload. diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/stateful/StatefulFeature.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/stateful/StatefulFeature.java similarity index 90% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/stateful/StatefulFeature.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/stateful/StatefulFeature.java index f4de5597..206805b1 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/feature/stateful/StatefulFeature.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/feature/stateful/StatefulFeature.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.feature.stateful; +package nl.hauntedmc.proxyfeatures.framework.feature.stateful; import java.util.Optional; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/CommandOwnershipRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/CommandOwnershipRegistry.java new file mode 100644 index 00000000..1edd6573 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/CommandOwnershipRegistry.java @@ -0,0 +1,87 @@ +package nl.hauntedmc.proxyfeatures.framework.lifecycle; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Plugin-wide ownership registry for feature command names and aliases. */ +public final class CommandOwnershipRegistry { + private record Owner(String featureName, String commandName) { + } + + private final Map owners = new ConcurrentHashMap<>(); + + public Registration claim(String featureName, String commandName, Collection aliases) { + Owner owner = new Owner(requireText(featureName, "featureName"), requireText(commandName, "commandName")); + LinkedHashSet normalized = new LinkedHashSet<>(); + normalized.add(normalize(commandName)); + for (String alias : Objects.requireNonNull(aliases, "aliases")) { + normalized.add(normalize(alias)); + } + + synchronized (owners) { + for (String alias : normalized) { + Owner current = owners.get(alias); + if (current != null && !current.equals(owner)) { + throw new CommandRegistrationException( + "Command alias '" + alias + "' for feature '" + featureName + + "' is already owned by feature '" + current.featureName + + "' command '" + current.commandName + "'" + ); + } + } + normalized.forEach(alias -> owners.put(alias, owner)); + } + return new Registration(owner, SetSnapshot.copyOf(normalized)); + } + + public int size() { + return owners.size(); + } + + public final class Registration implements AutoCloseable { + private final Owner owner; + private final Collection aliases; + private final AtomicBoolean closed = new AtomicBoolean(); + + private Registration(Owner owner, Collection aliases) { + this.owner = owner; + this.aliases = aliases; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + synchronized (owners) { + aliases.forEach(alias -> owners.remove(alias, owner)); + } + } + } + + private static String normalize(String value) { + return requireText(value, "command alias").toLowerCase(Locale.ROOT); + } + + private static String requireText(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } + + private static final class SetSnapshot { + private SetSnapshot() { + } + + private static Collection copyOf(Collection values) { + return java.util.Set.copyOf(values); + } + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/CommandRegistrationException.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/CommandRegistrationException.java new file mode 100644 index 00000000..4f65ebff --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/CommandRegistrationException.java @@ -0,0 +1,14 @@ +package nl.hauntedmc.proxyfeatures.framework.lifecycle; + +/** Raised when a feature-owned command cannot be registered completely. */ +public final class CommandRegistrationException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + public CommandRegistrationException(String message) { + super(message); + } + + public CommandRegistrationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManager.java index 0ed8f47c..a09b62db 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManager.java @@ -1,116 +1,231 @@ package nl.hauntedmc.proxyfeatures.framework.lifecycle; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceHandle; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.framework.service.CapabilityRegistration; +import nl.hauntedmc.proxyfeatures.framework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.proxyfeatures.framework.service.InternalServiceRegistry; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.function.Supplier; -/** - * Tracks feature-owned API/service registrations and detaches them on cleanup. - */ +/** Stages feature capabilities and ingress hooks until post-restoration activation. */ public class FeatureApiManager { - private final Map, Object> registeredServices = new LinkedHashMap<>(); - private final Map, FeatureServiceHandle> dataRegistryServices = new LinkedHashMap<>(); - private String ownerPlugin; - private String ownerFeature; - private Supplier> dataRegistrySupplier = Optional::empty; - - /** - * Binds DataRegistryApi-backed service publication to this feature owner. - */ - public synchronized void bindDataRegistryCatalog( - String ownerPlugin, - String ownerFeature, - Supplier> dataRegistrySupplier + private enum RegistryKind { PUBLIC, INTERNAL } + + private final Map, ServiceDefinition> serviceDefinitions = new LinkedHashMap<>(); + private final Map, CapabilityRegistration> activeRegistrations = new LinkedHashMap<>(); + private final List activationHooks = new ArrayList<>(); + private DefaultCapabilityRegistry registry; + private InternalServiceRegistry internalRegistry; + private FeatureId owner; + private boolean active; + private FeatureResourceState state = FeatureResourceState.OPEN; + + public synchronized void bindRegistry( + DefaultCapabilityRegistry registry, + InternalServiceRegistry internalRegistry, + String ownerFeature ) { - this.ownerPlugin = requireText(ownerPlugin, "ownerPlugin"); - this.ownerFeature = requireText(ownerFeature, "ownerFeature"); - this.dataRegistrySupplier = Objects.requireNonNull(dataRegistrySupplier, "dataRegistrySupplier"); + requireOpen(); + if (!serviceDefinitions.isEmpty() || !activeRegistrations.isEmpty() || !activationHooks.isEmpty()) { + throw new IllegalStateException("Feature API manager cannot be rebound after resources were registered"); + } + this.registry = Objects.requireNonNull(registry, "registry"); + this.internalRegistry = Objects.requireNonNull(internalRegistry, "internalRegistry"); + this.owner = FeatureId.of(requireText(ownerFeature, "ownerFeature")); + } + + public synchronized void registerInternalService(Class type, T instance) { + register(type, instance, RegistryKind.INTERNAL); } public synchronized void registerService(Class type, T instance) { - Objects.requireNonNull(type, "type"); - Objects.requireNonNull(instance, "instance"); + register(type, instance, RegistryKind.PUBLIC); + } - Object previous = registeredServices.get(type); - FeatureServiceHandle previousHandle = dataRegistryServices.get(type); - if (previous == instance && previousHandle != null) { - return; + /** Registers ingress work that may only start after reload state restoration. */ + public synchronized void registerActivationHook(Runnable activationHook) { + requireOpen(); + if (active) { + throw new IllegalStateException("Activation hooks cannot be added after feature activation"); } + activationHooks.add(Objects.requireNonNull(activationHook, "activationHook")); + } + + public synchronized void activateServices() { + requireOpen(); + requireBound(); + if (active) return; - FeatureServiceHandle handle = registerWithDataRegistry(type, instance); - registeredServices.put(type, instance); + for (Runnable activationHook : List.copyOf(activationHooks)) { + activationHook.run(); + } - dataRegistryServices.remove(type); - if (handle != null) { - dataRegistryServices.put(type, handle); + Map, CapabilityRegistration> published = new LinkedHashMap<>(); + try { + for (Map.Entry, ServiceDefinition> entry : serviceDefinitions.entrySet()) { + published.put(entry.getKey(), publish(entry.getKey(), entry.getValue())); + } + } catch (Throwable activationFailure) { + closeRegistrations(published, activationFailure); + throwUnchecked(activationFailure); } - if (previousHandle != null) { - previousHandle.close(); + activeRegistrations.putAll(published); + active = true; + } + + public synchronized void deactivateServices() { + active = false; + Throwable failure = closeRegistrations(activeRegistrations, null); + activeRegistrations.clear(); + if (failure != null) throwUnchecked(failure); + } + + public synchronized void quiesce() { + if (state == FeatureResourceState.OPEN) { + state = FeatureResourceState.QUIESCING; } } public synchronized void unregisterService(Class type) { Objects.requireNonNull(type, "type"); - - registeredServices.remove(type); - closeDataRegistryHandle(type); + serviceDefinitions.remove(type); + CapabilityRegistration registration = activeRegistrations.remove(type); + if (registration != null) registration.close(); } public synchronized void unregisterAllServices() { - if (registeredServices.isEmpty() && dataRegistryServices.isEmpty()) { - return; + quiesce(); + Throwable failure = null; + try { + deactivateServices(); + } catch (Throwable deactivationFailure) { + failure = deactivationFailure; + } finally { + serviceDefinitions.clear(); + activationHooks.clear(); + state = FeatureResourceState.CLOSED; } + if (failure != null) throwUnchecked(failure); + } + + public synchronized int getRegisteredServiceCount() { + return serviceDefinitions.size(); + } + + public synchronized int getActivationHookCount() { + return activationHooks.size(); + } + + public synchronized boolean isActive() { + return active; + } + + public synchronized FeatureResourceState state() { + return state; + } - for (var entry : new LinkedHashMap<>(registeredServices).entrySet()) { - closeDataRegistryHandle(entry.getKey()); + private void register(Class type, T instance, RegistryKind kind) { + requireOpen(); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(instance, "instance"); + requireBound(); + + ServiceDefinition previous = serviceDefinitions.get(type); + if (previous != null && previous.instance() == instance && previous.kind() == kind) return; + + ServiceDefinition replacement = new ServiceDefinition(kind, instance); + if (!active) { + serviceDefinitions.put(type, replacement); + return; + } + if (previous != null && previous.kind() != kind) { + throw new IllegalStateException( + "Active service cannot change registry kind without deactivation: " + type.getName() + ); } - registeredServices.clear(); - dataRegistryServices.values().forEach(FeatureServiceHandle::close); - dataRegistryServices.clear(); + + CapabilityRegistration replacementRegistration = previous == null + ? publish(type, replacement) + : replace(type, replacement); + CapabilityRegistration previousRegistration = activeRegistrations.put(type, replacementRegistration); + serviceDefinitions.put(type, replacement); + if (previousRegistration != null) previousRegistration.close(); } - public synchronized int getRegisteredServiceCount() { - return registeredServices.size(); + private CapabilityRegistration publish(Class type, ServiceDefinition definition) { + return switch (definition.kind()) { + case PUBLIC -> publishPublic(type, definition.instance()); + case INTERNAL -> publishInternal(type, definition.instance()); + }; + } + + private CapabilityRegistration replace(Class type, ServiceDefinition definition) { + return switch (definition.kind()) { + case PUBLIC -> replacePublic(type, definition.instance()); + case INTERNAL -> replaceInternal(type, definition.instance()); + }; } - private FeatureServiceHandle registerWithDataRegistry(Class type, T instance) { - if (ownerPlugin == null || ownerFeature == null) { - return null; + private CapabilityRegistration publishPublic(Class type, Object instance) { + return registry.register(owner, type, type.cast(instance)); + } + + private CapabilityRegistration publishInternal(Class type, Object instance) { + return internalRegistry.register(owner, type, type.cast(instance)); + } + + private CapabilityRegistration replacePublic(Class type, Object instance) { + return registry.replace(owner, type, type.cast(instance)); + } + + private CapabilityRegistration replaceInternal(Class type, Object instance) { + return internalRegistry.replace(owner, type, type.cast(instance)); + } + + private void requireBound() { + if (registry == null || internalRegistry == null || owner == null) { + throw new IllegalStateException("Feature API manager is not bound to capability registries"); } - return currentDataRegistry() - .map(dataRegistry -> dataRegistry.featureServices().register( - ownerPlugin, - ownerFeature, - type, - instance - )) - .orElse(null); } - private Optional currentDataRegistry() { - Optional dataRegistry = dataRegistrySupplier.get(); - return dataRegistry == null ? Optional.empty() : dataRegistry; + private void requireOpen() { + if (state != FeatureResourceState.OPEN) { + throw new IllegalStateException("Feature API manager is " + state); + } } - private void closeDataRegistryHandle(Class type) { - FeatureServiceHandle handle = dataRegistryServices.remove(type); - if (handle != null) { - handle.close(); + private static Throwable closeRegistrations( + Map, CapabilityRegistration> registrations, + Throwable failure + ) { + List values = new ArrayList<>(registrations.values()); + for (int index = values.size() - 1; index >= 0; index--) { + try { + values.get(index).close(); + } catch (Throwable closeFailure) { + if (failure == null) failure = closeFailure; + else failure.addSuppressed(closeFailure); + } } + return failure; } private static String requireText(String value, String fieldName) { - Objects.requireNonNull(value, fieldName); - String normalized = value.trim(); - if (normalized.isEmpty()) { - throw new IllegalArgumentException(fieldName + " must not be blank"); - } + String normalized = Objects.requireNonNull(value, fieldName).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(fieldName + " must not be blank"); return normalized; } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable throwable) throws E { + throw (E) throwable; + } + + private record ServiceDefinition(RegistryKind kind, Object instance) { + } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManager.java index def1d19c..90ae96f8 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManager.java @@ -1,16 +1,15 @@ package nl.hauntedmc.proxyfeatures.framework.lifecycle; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheDirectory; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheDirectory; import java.io.File; import java.util.Objects; -/** - * Manages the top-level cache folder and hands out per-feature directories. - */ +/** Manages the top-level cache folder and per-feature cache directories. */ public class FeatureCacheManager { private final File baseFolder; + private FeatureResourceState state = FeatureResourceState.OPEN; public FeatureCacheManager(ProxyFeatures plugin) { Objects.requireNonNull(plugin, "plugin"); @@ -25,24 +24,30 @@ public FeatureCacheManager(ProxyFeatures plugin) { } created = true; } - if (created) { - plugin.getLogger().info("Created cache folder at {}", baseFolder); - } + if (created) plugin.getLogger().info("Created cache folder at {}", baseFolder); } - /** - * Get (or create) the cache subdirectory for this feature + identifier. - * Example: - * getCacheDirectory("voteRewards", "queue") - * ⇒ plugins/.../cache/voteRewards-queue/ - */ - public CacheDirectory getCacheDirectory(String featureName, String cacheId) { + public synchronized CacheDirectory getCacheDirectory(String featureName, String cacheId) { + requireOpen(); return new CacheDirectory(baseFolder, featureName, cacheId); } - /** - * Global cleanup can still sweep across all subfolders if desired. - */ - public void cleanupAll() { + public synchronized void quiesce() { + if (state == FeatureResourceState.OPEN) state = FeatureResourceState.QUIESCING; + } + + public synchronized void cleanupAll() { + quiesce(); + state = FeatureResourceState.CLOSED; + } + + public synchronized FeatureResourceState state() { + return state; + } + + private void requireOpen() { + if (state != FeatureResourceState.OPEN) { + throw new IllegalStateException("Cache manager is " + state); + } } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManager.java index acddb84d..62c0dd78 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManager.java @@ -5,78 +5,104 @@ import com.velocitypowered.api.command.CommandMeta; import com.velocitypowered.api.command.CommandSource; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; - -import java.util.*; - -/** - * Registers/unregisters SimpleCommand and Brigadier commands at runtime. - */ +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Registers and unregisters feature-owned commands at runtime. */ public class FeatureCommandManager { private final ProxyFeatures plugin; private final CommandManager commandManager; + private final CommandOwnershipRegistry ownershipRegistry; + private String featureName; + private FeatureResourceState state = FeatureResourceState.OPEN; - // Simple (Velocity SimpleCommand) commands - private final Map registeredCommands = new HashMap<>(); - private final Map simpleMetas = new HashMap<>(); - - // Brigadier root commands (our API interface -> Meta for clean unregister) - private final Map registeredBrigadierCommands = new HashMap<>(); - private final Map brigadierMetas = new HashMap<>(); - private final Map ownedAliases = new HashMap<>(); + private final Map registeredCommands = new LinkedHashMap<>(); + private final Map simpleMetas = new LinkedHashMap<>(); + private final Map simpleOwnership = new LinkedHashMap<>(); + private final Map registeredBrigadierCommands = new LinkedHashMap<>(); + private final Map brigadierMetas = new LinkedHashMap<>(); + private final Map brigadierOwnership = new LinkedHashMap<>(); public FeatureCommandManager(ProxyFeatures plugin) { - this.plugin = plugin; - this.commandManager = plugin.getCommandManager(); + this(plugin, plugin.getCommandOwnershipRegistry(), "unbound"); + } + + public FeatureCommandManager( + ProxyFeatures plugin, + CommandOwnershipRegistry ownershipRegistry, + String featureName + ) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.commandManager = Objects.requireNonNull(plugin.getCommandManager(), "commandManager"); + this.ownershipRegistry = Objects.requireNonNull(ownershipRegistry, "ownershipRegistry"); + this.featureName = requireText(featureName, "featureName"); + } + + public synchronized void bindToFeature(String featureName) { + requireOpen(); + if (!registeredCommands.isEmpty() || !registeredBrigadierCommands.isEmpty()) { + throw new IllegalStateException("Command manager cannot be rebound after command registration"); + } + this.featureName = requireText(featureName, "featureName"); } - /* ========================== SimpleCommand ========================== */ + public synchronized void quiesce() { + if (state == FeatureResourceState.OPEN) state = FeatureResourceState.QUIESCING; + } - /** - * Registers a SimpleCommand dynamically at runtime. - */ - public void registerFeatureCommand(FeatureCommand command) { - final String commandName = command.getName(); + public synchronized FeatureResourceState state() { + return state; + } + public synchronized void registerFeatureCommand(FeatureCommand command) { + requireOpen(); + Objects.requireNonNull(command, "command"); + String commandName = requireText(command.getName(), "command name"); if (registeredCommands.containsKey(commandName)) { - plugin.getLogger().warn("Command {} is already registered.", commandName); - return; + throw new CommandRegistrationException("Feature '" + featureName + + "' attempted to register command '" + commandName + "' twice"); } + + List aliases = sanitizeAliases(command.getAliases(), commandName); + CommandOwnershipRegistry.Registration ownership = ownershipRegistry.claim(featureName, commandName, aliases); try { - List aliases = sanitizeAliases(command.getAliases(), commandName); - String collision = findOwnedAliasCollision(commandName, aliases); - if (collision != null) { - plugin.getLogger().warn("Command alias '{}' is already owned by another feature command. Skipping {}.", collision, commandName); - return; - } CommandMeta meta = commandManager.metaBuilder(commandName) .aliases(aliases.toArray(String[]::new)) .plugin(plugin) .build(); - commandManager.register(meta, command); registeredCommands.put(commandName, command); simpleMetas.put(commandName, meta); - claimAliases(commandName, meta); - + simpleOwnership.put(commandName, ownership); plugin.getLogger().info("Registered command: {}", commandName); - } catch (Exception t) { - plugin.getLogger().warn("Failed to register command {}: {}", commandName, t.getMessage()); + } catch (Throwable failure) { + ownership.close(); + throw new CommandRegistrationException( + "Failed to register required command '" + commandName + + "' for feature '" + featureName + "'", + failure + ); } } - /** - * Unregisters a SimpleCommand dynamically. - */ - public void unregisterCommand(String commandName) { - if (!registeredCommands.containsKey(commandName)) { - plugin.getLogger().warn("Command {} is not registered.", commandName); - return; - } - FeatureCommand removed = registeredCommands.remove(commandName); - CommandMeta meta = simpleMetas.remove(commandName); + public synchronized void unregisterCommand(String commandName) { + FeatureCommand removed = registeredCommands.get(commandName); + if (removed == null) return; + CommandMeta meta = simpleMetas.get(commandName); + CommandOwnershipRegistry.Registration ownership = simpleOwnership.get(commandName); try { if (meta != null) { commandManager.unregister(meta); @@ -86,150 +112,153 @@ public void unregisterCommand(String commandName) { commandManager.unregister(alias); } } - } catch (Exception t) { - plugin.getLogger().warn("Failed to unregister {}: {}", commandName, t.getMessage()); - } finally { - releaseAliases(commandName, meta, removed == null ? List.of() : sanitizeAliases(removed.getAliases(), commandName)); - plugin.getLogger().info("Unregistered command: {}", commandName); + } catch (Throwable unregisterFailure) { + throw new CommandRegistrationException("Failed to unregister command '" + commandName + "'", unregisterFailure); } + registeredCommands.remove(commandName); + simpleMetas.remove(commandName); + simpleOwnership.remove(commandName); + if (ownership != null) { + ownership.close(); + } + plugin.getLogger().info("Unregistered command: {}", commandName); } - /** - * Unregisters all SimpleCommand registrations safely. - */ - public void unregisterAllCommands() { - List names = new ArrayList<>(registeredCommands.keySet()); - for (String name : names) { - unregisterCommand(name); + public synchronized void unregisterAllCommands() { + quiesce(); + Throwable failure = null; + for (String name : new ArrayList<>(registeredCommands.keySet())) { + try { + unregisterCommand(name); + } catch (Throwable cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + } + if (failure == null && registeredCommands.isEmpty() && registeredBrigadierCommands.isEmpty()) { + state = FeatureResourceState.CLOSED; } + throwIfPresent(failure); } - /* ============================ Brigadier ============================ */ - - /** - * Registers a Brigadier root command dynamically. - * The command tree is provided by our API interface and wrapped into Velocity's BrigadierCommand. - */ - public void registerBrigadierCommand(BrigadierCommand command) { - final String key = command.name(); - - if (registeredBrigadierCommands.containsKey(key)) { - plugin.getLogger().warn("[Brigadier] Already registered: {}", key); - return; - } - List aliases = sanitizeAliases(command.aliases(), key); - String collision = findOwnedAliasCollision(key, aliases); - if (collision != null) { - plugin.getLogger().warn("[Brigadier] Alias '{}' is already owned by another feature command. Skipping /{}.", collision, key); - return; + public synchronized void registerBrigadierCommand(BrigadierCommand command) { + requireOpen(); + Objects.requireNonNull(command, "command"); + String commandName = requireText(command.name(), "command name"); + if (registeredBrigadierCommands.containsKey(commandName)) { + throw new CommandRegistrationException("Feature '" + featureName + + "' attempted to register Brigadier command '" + commandName + "' twice"); } + List aliases = sanitizeAliases(command.aliases(), commandName); + CommandOwnershipRegistry.Registration ownership = ownershipRegistry.claim(featureName, commandName, aliases); try { - // Build the literal node from the feature and wrap it for Velocity LiteralCommandNode node = command.buildTree(); - com.velocitypowered.api.command.BrigadierCommand velocityCmd = + com.velocitypowered.api.command.BrigadierCommand velocityCommand = new com.velocitypowered.api.command.BrigadierCommand(node); - - // Use the Brigadier-aware metaBuilder; add aliases + plugin for proper ownership - CommandMeta meta = commandManager.metaBuilder(velocityCmd) + CommandMeta meta = commandManager.metaBuilder(velocityCommand) .aliases(aliases.toArray(String[]::new)) .plugin(plugin) .build(); - - commandManager.register(meta, velocityCmd); - - registeredBrigadierCommands.put(key, command); - brigadierMetas.put(key, meta); - claimAliases(key, meta); - - plugin.getLogger().info("[Brigadier] Registered /{} ({} aliases)", key, aliases.size()); - } catch (Exception t) { - plugin.getLogger().warn("[Brigadier] Failed to register /{}: {}", key, t.getMessage()); + commandManager.register(meta, velocityCommand); + registeredBrigadierCommands.put(commandName, command); + brigadierMetas.put(commandName, meta); + brigadierOwnership.put(commandName, ownership); + plugin.getLogger().info("[Brigadier] Registered /{} ({} aliases)", commandName, aliases.size()); + } catch (Throwable failure) { + ownership.close(); + throw new CommandRegistrationException( + "Failed to register required Brigadier command '" + commandName + + "' for feature '" + featureName + "'", + failure + ); } } - /** - * Unregister a single Brigadier root command by name. - */ - public void unregisterBrigadierCommand(String name) { - BrigadierCommand removed = registeredBrigadierCommands.remove(name); - if (removed == null) { - plugin.getLogger().warn("[Brigadier] Not registered: {}", name); - return; - } - CommandMeta meta = brigadierMetas.remove(name); + public synchronized void unregisterBrigadierCommand(String commandName) { + BrigadierCommand removed = registeredBrigadierCommands.get(commandName); + if (removed == null) return; + CommandMeta meta = brigadierMetas.get(commandName); + CommandOwnershipRegistry.Registration ownership = brigadierOwnership.get(commandName); try { if (meta != null) { commandManager.unregister(meta); } else { - // Fallback: try by alias (primary) - commandManager.unregister(name); - for (String a : removed.aliases()) { - commandManager.unregister(a); + commandManager.unregister(commandName); + for (String alias : sanitizeAliases(removed.aliases(), commandName)) { + commandManager.unregister(alias); } } - plugin.getLogger().info("[Brigadier] Unregistered /{}", name); - } catch (Exception t) { - plugin.getLogger().warn("[Brigadier] detach failed for /{}: {}", name, t.getMessage()); - } finally { - releaseAliases(name, meta, sanitizeAliases(removed.aliases(), name)); + } catch (Throwable unregisterFailure) { + throw new CommandRegistrationException( + "Failed to unregister Brigadier command '" + commandName + "'", + unregisterFailure + ); + } + registeredBrigadierCommands.remove(commandName); + brigadierMetas.remove(commandName); + brigadierOwnership.remove(commandName); + if (ownership != null) { + ownership.close(); } + plugin.getLogger().info("[Brigadier] Unregistered /{}", commandName); } - /** - * HARD-unregister all Brigadier root commands owned by this feature. - */ - public void unregisterAllBrigadierCommands() { - if (registeredBrigadierCommands.isEmpty()) return; - - List snapshot = new ArrayList<>(registeredBrigadierCommands.keySet()); - for (String name : snapshot) { - unregisterBrigadierCommand(name); + public synchronized void unregisterAllBrigadierCommands() { + quiesce(); + Throwable failure = null; + for (String name : new ArrayList<>(registeredBrigadierCommands.keySet())) { + try { + unregisterBrigadierCommand(name); + } catch (Throwable cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + } + if (failure == null && registeredCommands.isEmpty() && registeredBrigadierCommands.isEmpty()) { + state = FeatureResourceState.CLOSED; } + throwIfPresent(failure); } - /* ========================== Combined helpers ========================= */ - - public Map getRegisteredCommands() { - return Collections.unmodifiableMap(registeredCommands); + public synchronized Map getRegisteredCommands() { + return Collections.unmodifiableMap(new LinkedHashMap<>(registeredCommands)); } - public int getRegisteredCommandCount() { + public synchronized int getRegisteredCommandCount() { return registeredCommands.size(); } - public Map getRegisteredBrigadierCommands() { - return Collections.unmodifiableMap(registeredBrigadierCommands); + public synchronized Map getRegisteredBrigadierCommands() { + return Collections.unmodifiableMap(new LinkedHashMap<>(registeredBrigadierCommands)); } - public int getRegisteredBrigadierCommandCount() { + public synchronized int getRegisteredBrigadierCommandCount() { return registeredBrigadierCommands.size(); } - public int getTotalRegisteredCommandCount() { + public synchronized int getTotalRegisteredCommandCount() { return registeredCommands.size() + registeredBrigadierCommands.size(); } - public Set getAllRegisteredCommandNames() { + public synchronized Set getAllRegisteredCommandNames() { LinkedHashSet names = new LinkedHashSet<>(); names.addAll(registeredCommands.keySet()); names.addAll(registeredBrigadierCommands.keySet()); return Collections.unmodifiableSet(names); } - private static List sanitizeAliases(Collection aliases, String commandName) { - if (aliases == null || aliases.isEmpty()) { - return List.of(); + private void requireOpen() { + if (state != FeatureResourceState.OPEN) { + throw new IllegalStateException("Command manager is " + state); } + } + + private static List sanitizeAliases(Collection aliases, String commandName) { + if (aliases == null || aliases.isEmpty()) return List.of(); Map sanitized = new LinkedHashMap<>(); for (String alias : aliases) { - if (alias == null) { - continue; - } + if (alias == null) continue; String trimmed = alias.trim(); - if (trimmed.isEmpty() || trimmed.equalsIgnoreCase(commandName)) { - continue; - } + if (trimmed.isEmpty() || trimmed.equalsIgnoreCase(commandName)) continue; sanitized.putIfAbsent(trimmed.toLowerCase(Locale.ROOT), trimmed); } return List.copyOf(sanitized.values()); @@ -239,34 +268,24 @@ private static List sanitizeAliases(String[] aliases, String commandName return sanitizeAliases(aliases == null ? List.of() : Arrays.asList(aliases), commandName); } - private String findOwnedAliasCollision(String commandName, Collection aliases) { - for (String alias : allAliases(commandName, aliases)) { - String owner = ownedAliases.get(alias.toLowerCase(Locale.ROOT)); - if (owner != null && !owner.equals(commandName)) { - return alias; - } - } - return null; + private static String requireText(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; } - private void claimAliases(String commandName, CommandMeta meta) { - for (String alias : meta.getAliases()) { - ownedAliases.put(alias.toLowerCase(Locale.ROOT), commandName); - } + private static Throwable appendFailure(Throwable failure, Throwable addition) { + if (failure == null) return addition; + failure.addSuppressed(addition); + return failure; } - private void releaseAliases(String commandName, CommandMeta meta, Collection sanitizedAliases) { - Collection aliases = meta != null ? meta.getAliases() : allAliases(commandName, sanitizedAliases); - for (String alias : aliases) { - ownedAliases.remove(alias.toLowerCase(Locale.ROOT), commandName); - } + private static void throwIfPresent(Throwable failure) { + if (failure != null) throwUnchecked(failure); } - private static Collection allAliases(String commandName, Collection sanitizedAliases) { - LinkedHashSet aliases = new LinkedHashSet<>(); - aliases.add(commandName); - aliases.addAll(sanitizedAliases); - return aliases; + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable throwable) throws E { + throw (E) throwable; } - } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureDataManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureDataManager.java index 65526d7a..332ca968 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureDataManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureDataManager.java @@ -33,38 +33,46 @@ public class FeatureDataManager { private final ProxyFeatures plugin; private final Supplier dataProviderApiSupplier; private final LoggerAdapter ormLogger; + private final boolean fixedBoundFacade; private final ConcurrentHashMap connectionsByIdentifier = new ConcurrentHashMap<>(); private final ConcurrentHashMap ormContextsByIdentifier = new ConcurrentHashMap<>(); private volatile DataProviderAPI boundDataProviderApi; private volatile DataProviderScope dataProviderScope; private volatile String lastOrmContextIdentifier; + private volatile FeatureResourceState state = FeatureResourceState.OPEN; private String featureName; private boolean dataProviderInitialized; - private boolean closed; public FeatureDataManager(ProxyFeatures plugin) { - this(plugin, () -> resolveApiSafely(plugin)); + this(plugin, () -> resolveApiSafely(plugin), false); } FeatureDataManager(ProxyFeatures plugin, DataProviderAPI dataProviderAPI) { - this(plugin, () -> dataProviderAPI); - // Package-private construction is used by tests that supply an already bound facade. + this(plugin, () -> dataProviderAPI, true); this.boundDataProviderApi = dataProviderAPI; } - private FeatureDataManager(ProxyFeatures plugin, Supplier dataProviderApiSupplier) { + private FeatureDataManager( + ProxyFeatures plugin, + Supplier dataProviderApiSupplier, + boolean fixedBoundFacade + ) { this.plugin = Objects.requireNonNull(plugin, "plugin"); - this.dataProviderApiSupplier = Objects.requireNonNull(dataProviderApiSupplier, "DataProvider API supplier cannot be null."); + this.dataProviderApiSupplier = Objects.requireNonNull( + dataProviderApiSupplier, + "DataProvider API supplier cannot be null." + ); + this.fixedBoundFacade = fixedBoundFacade; this.ormLogger = new ProxyLoggerAdapter(plugin); } - public void bindToFeature(String featureName) { + public synchronized void bindToFeature(String featureName) { String normalizedFeatureName = normalizeFeatureName(featureName); if (normalizedFeatureName == null) { this.featureName = null; this.dataProviderInitialized = false; - this.closed = false; + this.state = FeatureResourceState.OPEN; return; } @@ -77,7 +85,10 @@ public void bindToFeature(String featureName) { + "' while data resources are still active." ); } - if (normalizedFeatureName.equals(this.featureName) && dataProviderInitialized && dataProviderScope != null) { + if (normalizedFeatureName.equals(this.featureName) + && dataProviderInitialized + && dataProviderScope != null + && state == FeatureResourceState.OPEN) { return; } if (!hasActiveResources()) { @@ -86,7 +97,7 @@ public void bindToFeature(String featureName) { this.featureName = normalizedFeatureName; this.dataProviderInitialized = false; - this.closed = false; + this.state = FeatureResourceState.OPEN; } public void initializeForFeature(String featureName) { @@ -94,7 +105,18 @@ public void initializeForFeature(String featureName) { initializeBoundFeature(); } + public void quiesce() { + if (state == FeatureResourceState.OPEN) { + state = FeatureResourceState.QUIESCING; + } + } + + public FeatureResourceState state() { + return state; + } + private boolean initializeBoundFeature() { + requireOpen(); if (featureName == null || featureName.isBlank()) { plugin.getLogger().error("Feature name cannot be null or blank."); return false; @@ -113,14 +135,15 @@ private boolean initializeBoundFeature() { return false; } dataProviderInitialized = true; - plugin.getLogger().info( - "DataProvider scope initialized for feature '{}'.", - featureName - ); + plugin.getLogger().info("DataProvider scope initialized for feature '{}'.", featureName); return true; } - public Optional registerDatabaseConnection(String identifier, DatabaseType databaseType, String connectionName) { + public Optional registerDatabaseConnection( + String identifier, + DatabaseType databaseType, + String connectionName + ) { if (!hasText(identifier) || databaseType == null || !hasText(connectionName)) { plugin.getLogger().error( "Invalid database registration request (identifier='{}', type={}, connection='{}').", @@ -139,7 +162,6 @@ public Optional registerDatabaseConnection(String identifier, if (existing != null) { boolean sameBinding = existing.databaseType == databaseType && existing.connectionName.equals(connectionName); - if (sameBinding) { return Optional.of(existing.provider); } @@ -153,14 +175,14 @@ public Optional registerDatabaseConnection(String identifier, return Optional.empty(); } registered = scope.registerDatabaseOrThrow(databaseType, connectionName); - } catch (Exception ex) { + } catch (Exception exception) { plugin.getLogger().error( - "Failed to register database '{}' (type={}, connection='{}') for feature '{}'.", - identifier, - databaseType, - connectionName, - featureName, - ex + "Failed to register database '{}' (type={}, connection='{}') for feature '{}'.", + identifier, + databaseType, + connectionName, + featureName, + exception ); return Optional.empty(); } @@ -176,15 +198,14 @@ public Optional registerDatabaseConnection(String identifier, return Optional.empty(); } - DatabaseProvider provider = registered; - RegisteredConnection newRegistration = new RegisteredConnection(databaseType, connectionName, provider); + RegisteredConnection newRegistration = new RegisteredConnection(databaseType, connectionName, registered); RegisteredConnection replaced = connectionsByIdentifier.put(identifier, newRegistration); if (replaced != null && replaced != newRegistration) { releaseConnection(replaced, identifier); } plugin.getLogger().info("Successfully registered connection '{}' of type {}", identifier, databaseType); - return Optional.of(provider); + return Optional.of(registered); } public Optional getDatabaseProvider(String identifier) { @@ -192,10 +213,7 @@ public Optional getDatabaseProvider(String identifier) { return Optional.empty(); } RegisteredConnection registration = connectionsByIdentifier.get(identifier); - if (registration == null) { - return Optional.empty(); - } - return Optional.of(registration.provider); + return registration == null ? Optional.empty() : Optional.of(registration.provider); } public Optional registerDataAccess( @@ -223,14 +241,22 @@ public Optional registerDataAccess( }); } - public Optional getDataAccess(String identifier, Class expectedDataAccessType) { + public Optional getDataAccess( + String identifier, + Class expectedDataAccessType + ) { if (!hasText(identifier) || expectedDataAccessType == null) { return Optional.empty(); } - return getDatabaseProvider(identifier).flatMap(provider -> getTypedDataAccess(provider, expectedDataAccessType)); + return getDatabaseProvider(identifier) + .flatMap(provider -> getTypedDataAccess(provider, expectedDataAccessType)); } - public Optional createMySqlOrmContext(String identifier, String connectionName, Class... entityClasses) { + public Optional createMySqlOrmContext( + String identifier, + String connectionName, + Class... entityClasses + ) { return registerDatabaseConnection(identifier, DatabaseType.MYSQL, connectionName) .flatMap(ignored -> createOrmContext(identifier, entityClasses)); } @@ -251,8 +277,15 @@ public Optional createSystemOrmContext(Class... entityClasses) { return createSystemOrmContext(DEFAULT_SYSTEM_ORM_IDENTIFIER, entityClasses); } - public Optional registerRedisMessagingDataAccess(String identifier, Class expectedDataAccessType) { - return registerRedisMessagingDataAccess(identifier, DEFAULT_REDIS_MESSAGING_CONNECTION, expectedDataAccessType); + public Optional registerRedisMessagingDataAccess( + String identifier, + Class expectedDataAccessType + ) { + return registerRedisMessagingDataAccess( + identifier, + DEFAULT_REDIS_MESSAGING_CONNECTION, + expectedDataAccessType + ); } public Optional registerRedisMessagingDataAccess( @@ -329,18 +362,15 @@ public Optional createOrmContext(String identifier, Class... enti replaceOrmContext(identifier, ormContext); plugin.getLogger().info("Created ORMContext for identifier '{}'", identifier); return Optional.of(ormContext); - } catch (Exception ex) { - plugin.getLogger().error("Failed to create ORMContext for identifier '{}'.", identifier, ex); + } catch (Exception exception) { + plugin.getLogger().error("Failed to create ORMContext for identifier '{}'.", identifier, exception); return Optional.empty(); } } public Optional getOrmContext() { String identifier = lastOrmContextIdentifier; - if (identifier == null) { - return Optional.empty(); - } - return getOrmContext(identifier); + return identifier == null ? Optional.empty() : getOrmContext(identifier); } public Optional getOrmContext(String identifier) { @@ -350,17 +380,32 @@ public Optional getOrmContext(String identifier) { return Optional.ofNullable(ormContextsByIdentifier.get(identifier)); } - public void closeAllDataResources() { + /** Closes every resource, aggregates failures, and discards runtime provider facades. */ + public synchronized void closeAllDataResources() { + quiesce(); + Throwable failure = null; for (var entry : ormContextsByIdentifier.entrySet()) { - shutdownOrmContext(entry.getKey(), entry.getValue()); + try { + shutdownOrmContextOrThrow(entry.getValue()); + } catch (Throwable resourceFailure) { + failure = appendFailure(failure, resourceFailure); + } } - + // A provider scope can already have been invalidated by its owning plugin during shutdown. + // Cleanup must still discard local state and release the rest of this feature's resources. closeDataProviderScope(); + connectionsByIdentifier.clear(); ormContextsByIdentifier.clear(); lastOrmContextIdentifier = null; dataProviderInitialized = false; - closed = true; + if (!fixedBoundFacade) { + boundDataProviderApi = null; + } + state = FeatureResourceState.CLOSED; + if (failure != null) { + throwUnchecked(failure); + } } public int getActiveConnectionCount() { @@ -376,9 +421,9 @@ ORMContext newOrmContext(DataSource dataSource, Class... entityClasses) { private String resolveOrmSchemaMode() { if (plugin.getConfigHandler() != null) { String configured = plugin.getConfigHandler().getGlobalSetting( - ORM_SCHEMA_MODE_CONFIG_KEY, - String.class, - DEFAULT_ORM_SCHEMA_MODE + ORM_SCHEMA_MODE_CONFIG_KEY, + String.class, + DEFAULT_ORM_SCHEMA_MODE ); if (configured != null && !configured.isBlank()) { return configured.trim(); @@ -396,19 +441,16 @@ private static DataProviderAPI resolveApiSafely(ProxyFeatures plugin) { .map(DataProviderApiSupplier.class::cast) .map(DataProviderApiSupplier::dataProviderApi) .orElse(null); - } catch (RuntimeException ex) { + } catch (RuntimeException exception) { if (plugin != null) { - plugin.getLogger().warn("DataProviderAPI unavailable: {}", ex.getMessage()); + plugin.getLogger().warn("DataProviderAPI unavailable: {}", exception.getMessage()); } return null; } } private boolean isReady() { - if (closed) { - plugin.getLogger().error("DataProvider is closed for feature '{}'.", featureName); - return false; - } + requireOpen(); if (featureName == null) { plugin.getLogger().error("Feature name is not set. Did you bind the feature scope correctly?"); return false; @@ -419,6 +461,13 @@ private boolean isReady() { return true; } + private void requireOpen() { + FeatureResourceState current = state; + if (current != FeatureResourceState.OPEN) { + throw new IllegalStateException("Data manager is " + current + " for feature '" + featureName + "'"); + } + } + private boolean hasActiveResources() { return !connectionsByIdentifier.isEmpty() || !ormContextsByIdentifier.isEmpty(); } @@ -438,8 +487,14 @@ private void shutdownOrmContext(String identifier, ORMContext ormContext) { try { ormContext.shutdown(); plugin.getLogger().info("ORMContext '{}' has been shut down.", identifier); - } catch (Exception ex) { - plugin.getLogger().error("Failed to shut down ORMContext '{}'.", identifier, ex); + } catch (Exception exception) { + plugin.getLogger().error("Failed to shut down ORMContext '{}'.", identifier, exception); + } + } + + private static void shutdownOrmContextOrThrow(ORMContext ormContext) { + if (ormContext != null) { + ormContext.shutdown(); } } @@ -475,33 +530,31 @@ private Optional getTypedDataAccess( return expectedDataAccessType.isInstance(dataAccess) ? Optional.of(expectedDataAccessType.cast(dataAccess)) : Optional.empty(); - } catch (RuntimeException ex) { - plugin.getLogger().warn("Failed to access database provider data access: {}", ex.getMessage()); + } catch (RuntimeException exception) { + plugin.getLogger().warn("Failed to access database provider data access: {}", exception.getMessage()); return Optional.empty(); } } private void releaseConnection(RegisteredConnection registration, String identifier) { - if (registration == null) { - return; + if (registration != null) { + unregisterDatabase(registration.databaseType, registration.connectionName, identifier); } - unregisterDatabase(registration.databaseType, registration.connectionName, identifier); } private void unregisterDatabase(DatabaseType databaseType, String connectionName, String identifier) { try { DataProviderScope scope = dataProviderScope; - if (scope == null) { - return; + if (scope != null) { + scope.unregisterDatabase(databaseType, connectionName); } - scope.unregisterDatabase(databaseType, connectionName); - } catch (Exception ex) { + } catch (Exception exception) { plugin.getLogger().error( "Failed to unregister connection '{}' (type={}, connection='{}').", identifier, databaseType, connectionName, - ex + exception ); } } @@ -538,12 +591,25 @@ private Optional getDataProviderApi() { boundDataProviderApi = boundApi; } return Optional.ofNullable(boundApi); - } catch (RuntimeException ex) { - plugin.getLogger().warn("DataProviderAPI unavailable: {}", ex.getMessage()); + } catch (RuntimeException exception) { + plugin.getLogger().warn("DataProviderAPI unavailable: {}", exception.getMessage()); return Optional.empty(); } } + private static Throwable appendFailure(Throwable current, Throwable additional) { + if (current == null) { + return additional; + } + current.addSuppressed(additional); + return current; + } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable throwable) throws E { + throw (E) throwable; + } + private record RegisteredConnection( DatabaseType databaseType, String connectionName, @@ -568,27 +634,18 @@ public void log(LogLevel level, String message, Throwable throwable) { } private void logInfo(String message, Throwable throwable) { - if (throwable == null) { - logger.info(message); - } else { - logger.info(message, throwable); - } + if (throwable == null) logger.info(message); + else logger.info(message, throwable); } private void logWarn(String message, Throwable throwable) { - if (throwable == null) { - logger.warn(message); - } else { - logger.warn(message, throwable); - } + if (throwable == null) logger.warn(message); + else logger.warn(message, throwable); } private void logError(String message, Throwable throwable) { - if (throwable == null) { - logger.error(message); - } else { - logger.error(message, throwable); - } + if (throwable == null) logger.error(message); + else logger.error(message, throwable); } } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleFactory.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleFactory.java index ec734acc..2f12fbbb 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleFactory.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleFactory.java @@ -78,12 +78,13 @@ public FeatureLifecycleManager createLifecycleManager() { public FeatureLifecycleManager createLifecycleManager(String featureName) { FeatureLifecycleManager lifecycleManager = createLifecycleManager(); + lifecycleManager.getCommandManager().bindToFeature(featureName); lifecycleManager.getDataManager().bindToFeature(featureName); if (plugin != null) { - lifecycleManager.getApiManager().bindDataRegistryCatalog( - "ProxyFeatures", - featureName, - plugin::getDataRegistry + lifecycleManager.getApiManager().bindRegistry( + plugin.getCapabilityRegistry(), + plugin.getInternalServiceRegistry(), + featureName ); } return lifecycleManager; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManager.java index ae19ae78..ea818556 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManager.java @@ -10,6 +10,7 @@ public class FeatureLifecycleManager { private final FeatureDataManager dataManager; private final FeatureCacheManager cacheManager; private final FeatureApiManager apiManager; + private FeatureResourceState state = FeatureResourceState.OPEN; public FeatureLifecycleManager( FeatureTaskManager taskManager, @@ -27,53 +28,29 @@ public FeatureLifecycleManager( this.apiManager = Objects.requireNonNull(apiManager, "apiManager"); } - /** - * Provides access to the task manager. - */ - public FeatureTaskManager getTaskManager() { - return taskManager; - } - - /** - * Provides access to the command manager. - */ - public FeatureCommandManager getCommandManager() { - return commandManager; - } - - /** - * Provides access to the listener manager. - */ - public FeatureListenerManager getListenerManager() { - return listenerManager; - } - - /** - * Provides access to the data manager. - */ - public FeatureDataManager getDataManager() { - return dataManager; - } + public FeatureTaskManager getTaskManager() { return taskManager; } + public FeatureCommandManager getCommandManager() { return commandManager; } + public FeatureListenerManager getListenerManager() { return listenerManager; } + public FeatureDataManager getDataManager() { return dataManager; } + public FeatureCacheManager getCacheManager() { return cacheManager; } + public FeatureApiManager getApiManager() { return apiManager; } - /** - * Access to the cache manager for this feature. - */ - public FeatureCacheManager getCacheManager() { - return cacheManager; + public synchronized FeatureResourceState state() { + return state; } - /** - * Provides access to the feature-scoped API/service registry. - */ - public FeatureApiManager getApiManager() { - return apiManager; + /** Quiesces every ingress point before releasing any feature-owned resource. */ + public synchronized void quiesce() { + if (state != FeatureResourceState.OPEN) return; + state = FeatureResourceState.QUIESCING; + Throwable failure = quiesceResources(null); + if (failure != null) throwUnchecked(failure); } - /** - * Cleans up all registered listeners, tasks, and commands. - */ - public void cleanup() { - Throwable failure = null; + /** Releases every feature-owned resource after ingress has been quiesced. */ + public synchronized void cleanup() { + if (state == FeatureResourceState.CLOSED) return; + Throwable failure = state == FeatureResourceState.OPEN ? quiesceForCleanup() : null; failure = runCleanupStep(failure, listenerManager::unregisterAllListeners); failure = runCleanupStep(failure, taskManager::cancelAllTasks); @@ -83,20 +60,31 @@ public void cleanup() { failure = runCleanupStep(failure, dataManager::closeAllDataResources); failure = runCleanupStep(failure, cacheManager::cleanupAll); - if (failure != null) { - throwUnchecked(failure); - } + state = FeatureResourceState.CLOSED; + if (failure != null) throwUnchecked(failure); + } + + private Throwable quiesceForCleanup() { + state = FeatureResourceState.QUIESCING; + return quiesceResources(null); + } + + private Throwable quiesceResources(Throwable failure) { + failure = runCleanupStep(failure, listenerManager::quiesce); + failure = runCleanupStep(failure, taskManager::quiesce); + failure = runCleanupStep(failure, commandManager::quiesce); + failure = runCleanupStep(failure, apiManager::quiesce); + failure = runCleanupStep(failure, dataManager::quiesce); + return runCleanupStep(failure, cacheManager::quiesce); } private static Throwable runCleanupStep(Throwable failure, Runnable step) { try { step.run(); return failure; - } catch (Throwable t) { - if (failure == null) { - return t; - } - failure.addSuppressed(t); + } catch (Throwable cleanupFailure) { + if (failure == null) return cleanupFailure; + failure.addSuppressed(cleanupFailure); return failure; } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManager.java index d90cfd6e..188dc471 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManager.java @@ -4,38 +4,81 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; public class FeatureListenerManager { private final ProxyFeatures plugin; private final List registeredListeners = new ArrayList<>(); + private FeatureResourceState state = FeatureResourceState.OPEN; public FeatureListenerManager(ProxyFeatures plugin) { - this.plugin = plugin; + this.plugin = Objects.requireNonNull(plugin, "plugin"); } - /** - * Registers an event listener and tracks it for later removal. - */ - public void registerListener(Object listener) { - plugin.getEventManager().register(plugin, listener); - registeredListeners.add(listener); + /** Registers an event listener and tracks it for later removal. */ + public synchronized void registerListener(Object listener) { + requireOpen(); + Object required = Objects.requireNonNull(listener, "listener"); + plugin.getEventManager().register(plugin, required); + registeredListeners.add(required); } - /** - * Unregisters all event listeners that have been registered. - */ - public void unregisterAllListeners() { - for (Object listener : registeredListeners) { - plugin.getEventManager().unregisterListener(plugin, listener); + public synchronized void quiesce() { + if (state == FeatureResourceState.OPEN) { + state = FeatureResourceState.QUIESCING; } - registeredListeners.clear(); } - /** - * Returns the number of registered listeners. - */ - public int getRegisteredListenerCount() { + /** Unregisters every listener and aggregates failures without skipping later listeners. */ + public synchronized void unregisterAllListeners() { + quiesce(); + Throwable failure = null; + for (Object listener : new ArrayList<>(registeredListeners)) { + boolean unregistered = false; + try { + plugin.getEventManager().unregisterListener(plugin, listener); + unregistered = true; + } catch (Throwable listenerFailure) { + failure = appendFailure(failure, listenerFailure); + } finally { + if (unregistered) { + registeredListeners.remove(listener); + } + } + } + if (failure == null && registeredListeners.isEmpty()) { + state = FeatureResourceState.CLOSED; + } + throwIfPresent(failure); + } + + public synchronized int getRegisteredListenerCount() { return registeredListeners.size(); } + + public synchronized FeatureResourceState state() { + return state; + } + + private void requireOpen() { + if (state != FeatureResourceState.OPEN) { + throw new IllegalStateException("Listener manager is " + state); + } + } + + private static Throwable appendFailure(Throwable current, Throwable additional) { + if (current == null) return additional; + current.addSuppressed(additional); + return current; + } + + private static void throwIfPresent(Throwable failure) { + if (failure != null) throwUnchecked(failure); + } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable throwable) throws E { + throw (E) throwable; + } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureResourceState.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureResourceState.java new file mode 100644 index 00000000..bb565c74 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureResourceState.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.framework.lifecycle; + +/** Lifecycle state shared by feature-scoped resource managers. */ +public enum FeatureResourceState { + OPEN, + QUIESCING, + CLOSED +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureTaskManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureTaskManager.java index 256cc127..9d57bd00 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureTaskManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureTaskManager.java @@ -4,172 +4,218 @@ import nl.hauntedmc.proxyfeatures.ProxyFeatures; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; -/** - * Centralized scheduler for feature-scoped tasks (Velocity). - * Goals: - * - Track every scheduled task so we can cancel all on feature shutdown. - * - For one-shot tasks, automatically remove the finished task from tracking. - * - Safe to modify from any thread (CopyOnWriteArrayList). - * Time units: - * - Uses {@link Duration} for delays and periods. - */ +/** Tracks, quiesces, cancels, and drains feature-scoped Velocity tasks. */ public class FeatureTaskManager { + private static final Duration DRAIN_TIMEOUT = Duration.ofSeconds(30); + private final ProxyFeatures plugin; private final List scheduledTasks = new CopyOnWriteArrayList<>(); + private final AtomicReference state = + new AtomicReference<>(FeatureResourceState.OPEN); + private final AtomicInteger inFlight = new AtomicInteger(); + private final ReentrantLock drainLock = new ReentrantLock(); + private final Condition drained = drainLock.newCondition(); public FeatureTaskManager(ProxyFeatures plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); } - /* ---------------------------------------------------------------------- - * Public API — thin wrappers over generic helpers - * ---------------------------------------------------------------------- */ - - /** - * Schedules a one-time task to run immediately. - */ public ScheduledTask scheduleTask(Runnable task) { Objects.requireNonNull(task, "task"); - return scheduleOnce(r -> plugin.getScheduler() - .buildTask(plugin, r) - .schedule(), task); + requireOpen(); + return scheduleOnce(r -> plugin.getScheduler().buildTask(plugin, r).schedule(), task); } - /** - * Runs a one-time task with a delay. - */ public ScheduledTask scheduleDelayedTask(Runnable task, Duration delay) { Objects.requireNonNull(task, "task"); - Duration d = clampDelay(delay); - return scheduleOnce(r -> plugin.getScheduler() - .buildTask(plugin, r) - .delay(d) - .schedule(), task); + requireOpen(); + Duration clamped = clampDelay(delay); + return scheduleOnce(r -> plugin.getScheduler().buildTask(plugin, r).delay(clamped).schedule(), task); } - /** - * Runs a repeating task with no initial delay (first run ASAP). - */ public ScheduledTask scheduleRepeatingTask(Runnable task, Duration period) { Objects.requireNonNull(task, "task"); - Duration p = clampPeriod(period); - return scheduleRepeating(r -> plugin.getScheduler() - .buildTask(plugin, r) - .delay(Duration.ZERO) // start immediately - .repeat(p) + requireOpen(); + Duration clamped = clampPeriod(period); + return scheduleRepeating(r -> plugin.getScheduler().buildTask(plugin, r) + .delay(Duration.ZERO) + .repeat(clamped) .schedule(), task); } - /** - * Runs a repeating task with an initial delay. - */ public ScheduledTask scheduleRepeatingTask(Runnable task, Duration delay, Duration period) { Objects.requireNonNull(task, "task"); - Duration d = clampDelay(delay); - Duration p = clampPeriod(period); - return scheduleRepeating(r -> plugin.getScheduler() - .buildTask(plugin, r) - .delay(d) - .repeat(p) + requireOpen(); + Duration clampedDelay = clampDelay(delay); + Duration clampedPeriod = clampPeriod(period); + return scheduleRepeating(r -> plugin.getScheduler().buildTask(plugin, r) + .delay(clampedDelay) + .repeat(clampedPeriod) .schedule(), task); } - /* ---------------------------------------------------------------------- - * Management - * ---------------------------------------------------------------------- */ - - /** - * Cancels a specific task and removes it from tracking. - */ public void cancelTask(ScheduledTask task) { - if (task != null) { - task.cancel(); - scheduledTasks.remove(task); - } + if (task == null) return; + task.cancel(); + scheduledTasks.remove(task); + } + + public void quiesce() { + state.compareAndSet(FeatureResourceState.OPEN, FeatureResourceState.QUIESCING); } - /** - * Cancels all scheduled tasks. - */ + /** Cancels all handles, aggregates cancellation failures, and waits for running callbacks. */ public void cancelAllTasks() { - for (ScheduledTask task : scheduledTasks) { - task.cancel(); + quiesce(); + Throwable failure = null; + for (ScheduledTask task : new ArrayList<>(scheduledTasks)) { + try { + task.cancel(); + scheduledTasks.remove(task); + } catch (Throwable cancellationFailure) { + failure = appendFailure(failure, cancellationFailure); + } } - scheduledTasks.clear(); + try { + awaitDrain(DRAIN_TIMEOUT); + } catch (Throwable drainFailure) { + failure = appendFailure(failure, drainFailure); + } finally { + if (failure == null && scheduledTasks.isEmpty()) { + state.set(FeatureResourceState.CLOSED); + } + } + throwIfPresent(failure); } - /** - * Returns the number of active tasks. - */ public int getActiveTaskCount() { return scheduledTasks.size(); } - /* ---------------------------------------------------------------------- - * Internals — generic helpers to maximize reuse - * ---------------------------------------------------------------------- */ + public int getInFlightTaskCount() { + return inFlight.get(); + } + + public FeatureResourceState state() { + return state.get(); + } - /** - * One-shot scheduling wrapper that: - * - wraps the runnable to auto-remove itself on completion - * - tracks the ScheduledTask handle - */ private ScheduledTask scheduleOnce(Function submitter, Runnable task) { - AtomicReference ref = new AtomicReference<>(); - AtomicBoolean completed = new AtomicBoolean(false); + AtomicReference reference = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(); Runnable wrapped = () -> { try { - task.run(); + runTracked(task); } finally { completed.set(true); - ScheduledTask scheduled = ref.get(); - if (scheduled != null) { - scheduledTasks.remove(scheduled); - } + ScheduledTask scheduled = reference.get(); + if (scheduled != null) scheduledTasks.remove(scheduled); } }; + requireOpen(); ScheduledTask scheduled = submitter.apply(wrapped); - ref.set(scheduled); - scheduledTasks.add(scheduled); - if (completed.get()) { - scheduledTasks.remove(scheduled); + if (state.get() != FeatureResourceState.OPEN) { + scheduled.cancel(); + throw new IllegalStateException("Task manager began quiescing while scheduling a task"); } + reference.set(scheduled); + scheduledTasks.add(scheduled); + if (completed.get()) scheduledTasks.remove(scheduled); return scheduled; } - /** - * Repeating scheduling wrapper that: - * - does NOT auto-remove (call cancelTask / cancelAllTasks to remove) - * - tracks the ScheduledTask handle - */ private ScheduledTask scheduleRepeating(Function submitter, Runnable task) { - ScheduledTask scheduled = submitter.apply(task); + requireOpen(); + ScheduledTask scheduled = submitter.apply(() -> runTracked(task)); + if (state.get() != FeatureResourceState.OPEN) { + scheduled.cancel(); + throw new IllegalStateException("Task manager began quiescing while scheduling a task"); + } scheduledTasks.add(scheduled); return scheduled; } - /** - * Clamp delay to >= 0 (negative becomes ZERO). - */ - private static Duration clampDelay(Duration d) { - if (d == null || d.isNegative()) return Duration.ZERO; - return d; + private void runTracked(Runnable task) { + inFlight.incrementAndGet(); + try { + task.run(); + } finally { + if (inFlight.decrementAndGet() == 0) { + drainLock.lock(); + try { + drained.signalAll(); + } finally { + drainLock.unlock(); + } + } + } + } + + private void awaitDrain(Duration timeout) { + long remaining = timeout.toNanos(); + boolean interrupted = false; + drainLock.lock(); + try { + while (inFlight.get() > 0 && remaining > 0L) { + long started = System.nanoTime(); + try { + remaining = drained.awaitNanos(remaining); + } catch (InterruptedException ignored) { + interrupted = true; + remaining -= System.nanoTime() - started; + } + } + if (inFlight.get() > 0) { + throw new IllegalStateException("Timed out draining " + inFlight.get() + + " feature task(s) after " + timeout.toSeconds() + " seconds"); + } + } finally { + drainLock.unlock(); + if (interrupted) Thread.currentThread().interrupt(); + } } - /** - * Clamp period to at least 1 second to avoid accidental hot-loop schedules. - */ - private static Duration clampPeriod(Duration p) { - if (p == null || p.isZero() || p.isNegative()) return Duration.ofMillis(1000); - return p; + private void requireOpen() { + FeatureResourceState current = state.get(); + if (current != FeatureResourceState.OPEN) { + throw new IllegalStateException("Task manager is " + current); + } } + private static Duration clampDelay(Duration duration) { + if (duration == null || duration.isNegative()) return Duration.ZERO; + return duration; + } + + private static Duration clampPeriod(Duration period) { + if (period == null || period.isZero() || period.isNegative()) return Duration.ofSeconds(1); + return period; + } + + private static Throwable appendFailure(Throwable current, Throwable additional) { + if (current == null) return additional; + current.addSuppressed(additional); + return current; + } + + private static void throwIfPresent(Throwable failure) { + if (failure != null) throwUnchecked(failure); + } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable throwable) throws E { + throw (E) throwable; + } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/LifecycleCoordinator.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/LifecycleCoordinator.java new file mode 100644 index 00000000..f6d3c45b --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/LifecycleCoordinator.java @@ -0,0 +1,27 @@ +package nl.hauntedmc.proxyfeatures.framework.lifecycle; + +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +/** Serializes mutations to the feature graph for the lifetime of the proxy plugin. */ +public final class LifecycleCoordinator { + private final ReentrantLock operationLock = new ReentrantLock(true); + + public void runExclusive(Runnable operation) { + callExclusive(() -> { + operation.run(); + return null; + }); + } + + public T callExclusive(Supplier operation) { + Objects.requireNonNull(operation, "operation"); + operationLock.lock(); + try { + return operation.get(); + } finally { + operationLock.unlock(); + } + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/BuiltInFeatures.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/BuiltInFeatures.java new file mode 100644 index 00000000..253c4a87 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/BuiltInFeatures.java @@ -0,0 +1,384 @@ +package nl.hauntedmc.proxyfeatures.framework.loader; + +import nl.hauntedmc.proxyfeatures.api.feature.FeatureClassification; +import nl.hauntedmc.proxyfeatures.api.extension.MotdExtensions; +import nl.hauntedmc.proxyfeatures.features.announcer.Announcer; +import nl.hauntedmc.proxyfeatures.features.antibot.AntiBot; +import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; +import nl.hauntedmc.proxyfeatures.features.broadcast.Broadcast; +import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; +import nl.hauntedmc.proxyfeatures.features.clientinfo.ClientInfo; +import nl.hauntedmc.proxyfeatures.features.commandhider.CommandHider; +import nl.hauntedmc.proxyfeatures.features.commandlogger.CommandLogger; +import nl.hauntedmc.proxyfeatures.features.commandrelay.CommandRelay; +import nl.hauntedmc.proxyfeatures.features.connectioninfo.ConnectionInfo; +import nl.hauntedmc.proxyfeatures.features.friends.Friends; +import nl.hauntedmc.proxyfeatures.features.hlink.HLink; +import nl.hauntedmc.proxyfeatures.features.hub.Hub; +import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; +import nl.hauntedmc.proxyfeatures.features.messager.Messenger; +import nl.hauntedmc.proxyfeatures.features.motd.Motd; +import nl.hauntedmc.proxyfeatures.features.playercount.PlayerCount; +import nl.hauntedmc.proxyfeatures.features.playerinfo.PlayerInfo; +import nl.hauntedmc.proxyfeatures.features.playerlanguage.PlayerLanguage; +import nl.hauntedmc.proxyfeatures.features.playerlist.PlayerList; +import nl.hauntedmc.proxyfeatures.features.proxyinfo.ProxyInfo; +import nl.hauntedmc.proxyfeatures.features.queue.Queue; +import nl.hauntedmc.proxyfeatures.features.resourcepack.ResourcePack; +import nl.hauntedmc.proxyfeatures.features.restart.Restart; +import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; +import nl.hauntedmc.proxyfeatures.features.serverlinks.ServerLinks; +import nl.hauntedmc.proxyfeatures.features.slashserver.SlashServer; +import nl.hauntedmc.proxyfeatures.features.staffchat.StaffChat; +import nl.hauntedmc.proxyfeatures.features.textcommands.TextCommands; +import nl.hauntedmc.proxyfeatures.features.twofactor.TwoFactor; +import nl.hauntedmc.proxyfeatures.features.vanish.Vanish; +import nl.hauntedmc.proxyfeatures.features.versioncheck.VersionCheck; +import nl.hauntedmc.proxyfeatures.features.votifier.Votifier; +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionApi; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.MaintenanceApi; +import nl.hauntedmc.proxyfeatures.api.capability.player.NetworkLocationApi; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountApi; +import nl.hauntedmc.proxyfeatures.api.capability.player.PlayerLanguageApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.queue.QueueApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.RestartApi; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionsApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.TwoFactorApi; +import nl.hauntedmc.proxyfeatures.api.capability.operations.VersionApi; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.QueueAdmissionPort; +import nl.hauntedmc.proxyfeatures.framework.admission.RestartCoordinationPort; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +/** Authoritative typed manifest of every built-in feature shipped by ProxyFeatures 3.3.0. */ +public final class BuiltInFeatures { + + public enum StartupPhase { + FOUNDATION, + SECURITY, + DOMAIN, + CONTENT, + OPERATIONS, + INTEGRATION + } + + public record Definition( + String featureName, + String featureVersion, + Class implementationType, + Function constructor, + StartupPhase startupPhase, + FeatureClassification classification, + Set> requiredCapabilities, + Set> optionalCapabilities, + Set> providedCapabilities, + Set pluginDependencies, + Set> requiredInternalServices, + Set> optionalInternalServices, + Set> providedInternalServices + ) { + public Definition { + featureName = requireText(featureName, "featureName"); + featureVersion = requireText(featureVersion, "featureVersion"); + Objects.requireNonNull(implementationType, "implementationType"); + Objects.requireNonNull(constructor, "constructor"); + Objects.requireNonNull(startupPhase, "startupPhase"); + Objects.requireNonNull(classification, "classification"); + requiredCapabilities = immutableCapabilities(requiredCapabilities, "requiredCapabilities"); + optionalCapabilities = immutableCapabilities(optionalCapabilities, "optionalCapabilities"); + providedCapabilities = immutableCapabilities(providedCapabilities, "providedCapabilities"); + pluginDependencies = immutableText(pluginDependencies, "pluginDependencies"); + requiredInternalServices = immutableInternalServices(requiredInternalServices, "requiredInternalServices"); + optionalInternalServices = immutableInternalServices(optionalInternalServices, "optionalInternalServices"); + providedInternalServices = immutableInternalServices(providedInternalServices, "providedInternalServices"); + + ensureDisjoint(requiredCapabilities, optionalCapabilities, "required", "optional"); + ensureDisjoint(requiredCapabilities, providedCapabilities, "required", "provided"); + ensureDisjoint(optionalCapabilities, providedCapabilities, "optional", "provided"); + ensureDisjoint(requiredInternalServices, optionalInternalServices, "required", "optional"); + ensureDisjoint(requiredInternalServices, providedInternalServices, "required", "provided"); + ensureDisjoint(optionalInternalServices, providedInternalServices, "optional", "provided"); + validateClassification(classification, requiredCapabilities, optionalCapabilities, providedCapabilities); + } + + public FeatureDescriptor descriptor(Set featureDependencies) { + return new FeatureDescriptor( + featureName, + featureName, + featureVersion, + implementationType, + constructor, + featureDependencies, + pluginDependencies + ); + } + } + + private static final List DEFINITIONS = List.of( + feature("Announcer", "2.0.0", Announcer.class, Announcer::new, + StartupPhase.INTEGRATION, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("dataprovider", "dataregistry")), + feature("AntiBot", "1.0.0", AntiBot.class, AntiBot::new, + StartupPhase.SECURITY, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("AntiVPN", "1.1.0", AntiVPN.class, AntiVPN::new, + StartupPhase.SECURITY, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(), Set.of(NetworkLocationApi.class), Set.of("dataprovider", "dataregistry")), + feature("Broadcast", "1.0.0", Broadcast.class, Broadcast::new, + StartupPhase.CONTENT, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("Capacity", "1.4.0", Capacity.class, Capacity::new, + StartupPhase.FOUNDATION, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(MaintenanceApi.class, RestartApi.class, TwoFactorApi.class), + Set.of(AdmissionApi.class), Set.of()), + feature("ClientInfo", "1.1.0", ClientInfo.class, ClientInfo::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("dataprovider", "dataregistry")), + feature("CommandHider", "1.1.0", CommandHider.class, CommandHider::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("CommandLogger", "1.0.0", CommandLogger.class, CommandLogger::new, + StartupPhase.INTEGRATION, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("dataprovider", "dataregistry")), + feature("CommandRelay", "1.0.0", CommandRelay.class, CommandRelay::new, + StartupPhase.INTEGRATION, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("dataprovider")), + feature("ConnectionInfo", "1.0.0", ConnectionInfo.class, ConnectionInfo::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("Friends", "1.4.0", Friends.class, Friends::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(PresenceApi.class), Set.of(FriendshipApi.class), + Set.of("dataprovider", "dataregistry")), + feature("HLink", "1.1.0", HLink.class, HLink::new, + StartupPhase.INTEGRATION, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("luckperms")), + feature("Hub", "1.0.0", Hub.class, Hub::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("Maintenance", "1.0.0", Maintenance.class, Maintenance::new, + StartupPhase.OPERATIONS, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(MotdExtensions.class), Set.of(TwoFactorApi.class), + Set.of(MaintenanceApi.class), Set.of()), + feature("Messenger", "1.1.0", Messenger.class, Messenger::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_CONSUMER, + Set.of(), Set.of(FriendshipApi.class, PresenceApi.class), Set.of(), + Set.of("dataprovider", "dataregistry")), + feature("Motd", "1.2.0", Motd.class, Motd::new, + StartupPhase.CONTENT, FeatureClassification.CAPABILITY_CONSUMER, + Set.of(), Set.of(PresenceApi.class, VersionApi.class), Set.of(), Set.of()), + feature("PlayerCount", "1.0.0", PlayerCount.class, PlayerCount::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(PresenceApi.class), Set.of(PlayerCountApi.class), Set.of("dataprovider")), + feature("PlayerInfo", "1.0.0", PlayerInfo.class, PlayerInfo::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_CONSUMER, + Set.of(), Set.of(PlayerLanguageApi.class, SanctionsApi.class), Set.of(), + Set.of("dataprovider", "dataregistry")), + feature("PlayerLanguage", "1.0.0", PlayerLanguage.class, PlayerLanguage::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(NetworkLocationApi.class), Set.of(PlayerLanguageApi.class), + Set.of("dataprovider", "dataregistry")), + feature("PlayerList", "1.1.0", PlayerList.class, PlayerList::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_CONSUMER, + Set.of(), Set.of(PresenceApi.class), Set.of(), Set.of()), + feature("ProxyInfo", "1.0.0", ProxyInfo.class, ProxyInfo::new, + StartupPhase.OPERATIONS, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("Queue", "2.0.0", Queue.class, Queue::new, + StartupPhase.FOUNDATION, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(AdmissionApi.class), Set.of(), Set.of(QueueApi.class), Set.of()), + feature("ResourcePack", "1.0.0", ResourcePack.class, ResourcePack::new, + StartupPhase.CONTENT, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("Restart", "1.4.0", Restart.class, Restart::new, + StartupPhase.OPERATIONS, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(), Set.of(RestartApi.class), Set.of()), + feature("Sanctions", "1.1.1", Sanctions.class, Sanctions::new, + StartupPhase.SECURITY, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(), Set.of(SanctionsApi.class), Set.of("dataprovider", "dataregistry")), + feature("ServerLinks", "1.0.0", ServerLinks.class, ServerLinks::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("SlashServer", "1.0.0", SlashServer.class, SlashServer::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("StaffChat", "1.0.0", StaffChat.class, StaffChat::new, + StartupPhase.DOMAIN, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("dataprovider")), + feature("TextCommands", "1.1.0", TextCommands.class, TextCommands::new, + StartupPhase.CONTENT, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of()), + feature("TwoFactor", "1.0.0", TwoFactor.class, TwoFactor::new, + StartupPhase.SECURITY, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(), Set.of(TwoFactorApi.class), Set.of("dataprovider", "dataregistry")), + feature("Vanish", "1.0.0", Vanish.class, Vanish::new, + StartupPhase.DOMAIN, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(), Set.of(PresenceApi.class), Set.of("dataprovider")), + feature("VersionCheck", "1.1.0", VersionCheck.class, VersionCheck::new, + StartupPhase.OPERATIONS, FeatureClassification.CAPABILITY_PROVIDER, + Set.of(), Set.of(), Set.of(VersionApi.class), Set.of()), + feature("Votifier", "1.6.0", Votifier.class, Votifier::new, + StartupPhase.INTEGRATION, FeatureClassification.INTERNAL, + Set.of(), Set.of(), Set.of(), Set.of("dataprovider", "dataregistry")) + ); + + private BuiltInFeatures() { + } + + public static List definitions() { + return DEFINITIONS; + } + + private static Definition feature( + String name, + String version, + Class implementationType, + Function constructor, + StartupPhase startupPhase, + FeatureClassification classification, + Set> requiredCapabilities, + Set> optionalCapabilities, + Set> providedCapabilities, + Set pluginDependencies + ) { + return new Definition( + name, + version, + implementationType, + constructor, + startupPhase, + classification, + requiredCapabilities, + optionalCapabilities, + providedCapabilities, + pluginDependencies, + requiredInternalServices(name), + optionalInternalServices(name), + providedInternalServices(name) + ); + } + + + private static void validateClassification( + FeatureClassification classification, + Set> requiredCapabilities, + Set> optionalCapabilities, + Set> providedCapabilities + ) { + switch (classification) { + case CAPABILITY_PROVIDER -> { + if (providedCapabilities.isEmpty()) { + throw new IllegalArgumentException("Capability providers must declare a provided capability"); + } + } + case EXTENSION_PROVIDER -> { + if (providedCapabilities.isEmpty()) { + throw new IllegalArgumentException("Extension providers must declare a provided extension contract"); + } + } + case CAPABILITY_CONSUMER -> { + if (requiredCapabilities.isEmpty() && optionalCapabilities.isEmpty()) { + throw new IllegalArgumentException("Capability consumers must declare a consumed capability"); + } + if (!providedCapabilities.isEmpty()) { + throw new IllegalArgumentException("Capability consumers cannot declare provided capabilities"); + } + } + case INTERNAL -> { + if (!providedCapabilities.isEmpty()) { + throw new IllegalArgumentException("Internal features cannot declare public capabilities"); + } + } + } + } + + private static void ensureDisjoint( + Set> left, + Set> right, + String leftName, + String rightName + ) { + for (Class capability : left) { + if (right.contains(capability)) { + throw new IllegalArgumentException( + "Capability cannot be both " + leftName + " and " + rightName + ": " + capability.getName() + ); + } + } + } + + private static Set> immutableCapabilities(Set> values, String fieldName) { + Objects.requireNonNull(values, fieldName); + LinkedHashSet> copy = new LinkedHashSet<>(); + for (Class value : values) { + Class capability = Objects.requireNonNull(value, fieldName + " element"); + if (!capability.isInterface() + || !capability.getPackageName().startsWith("nl.hauntedmc.proxyfeatures.api.")) { + throw new IllegalArgumentException("Invalid public capability: " + capability.getName()); + } + copy.add(capability); + } + return Set.copyOf(copy); + } + + private static Set> immutableInternalServices(Set> values, String fieldName) { + Objects.requireNonNull(values, fieldName); + LinkedHashSet> copy = new LinkedHashSet<>(); + for (Class value : values) { + Class service = Objects.requireNonNull(value, fieldName + " element"); + if (!service.isInterface() || !service.getPackageName().startsWith("nl.hauntedmc.proxyfeatures.framework.")) { + throw new IllegalArgumentException("Invalid internal service: " + service.getName()); + } + copy.add(service); + } + return Set.copyOf(copy); + } + + private static Set> requiredInternalServices(String featureName) { + return "Queue".equals(featureName) ? Set.of(CapacityAPI.class) : Set.of(); + } + + private static Set> optionalInternalServices(String featureName) { + return switch (featureName) { + case "Capacity" -> Set.of(QueueAdmissionPort.class, RestartCoordinationPort.class); + case "Restart" -> Set.of(CapacityAPI.class); + default -> Set.of(); + }; + } + + private static Set> providedInternalServices(String featureName) { + return switch (featureName) { + case "Capacity" -> Set.of(CapacityAPI.class); + case "Queue" -> Set.of(QueueAdmissionPort.class); + case "Restart" -> Set.of(RestartCoordinationPort.class); + default -> Set.of(); + }; + } + + private static Set immutableText(Set values, String fieldName) { + Objects.requireNonNull(values, fieldName); + LinkedHashSet copy = new LinkedHashSet<>(); + for (String value : values) { + copy.add(requireText(value, fieldName + " element")); + } + return Set.copyOf(copy); + } + + private static String requireText(String value, String fieldName) { + Objects.requireNonNull(value, fieldName); + String clean = value.trim(); + if (clean.isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return clean; + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptor.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptor.java index 810f2abe..cec36436 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptor.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptor.java @@ -1,151 +1,100 @@ package nl.hauntedmc.proxyfeatures.framework.loader; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import java.util.Collections; import java.util.LinkedHashSet; -import java.util.List; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; + +/** Fully explicit runtime descriptor; no package scanning or reflective construction is involved. */ +public final class FeatureDescriptor { + private final String registryName; + private final String featureName; + private final String featureVersion; + private final Class implementationType; + private final Function constructor; + private final Set featureDependencies; + private final Set pluginDependencies; -public record FeatureDescriptor( - String registryName, - String featureClassName, - Class metaClass, - String featureName, - String featureVersion, - Set featureDependencies, - Set pluginDependencies -) { public FeatureDescriptor( String registryName, - String featureClassName, String featureName, String featureVersion, + Class implementationType, + Function constructor, Set featureDependencies, Set pluginDependencies ) { - this( - registryName, - featureClassName, - (Class) null, - featureName, - featureVersion, - featureDependencies, - pluginDependencies - ); - } - - public FeatureDescriptor( - String registryName, - String featureClassName, - BaseMeta meta, - String featureName, - String featureVersion, - Set featureDependencies, - Set pluginDependencies - ) { - this( - registryName, - featureClassName, - meta == null ? null : meta.getClass().asSubclass(BaseMeta.class), - featureName, - featureVersion, - featureDependencies, - pluginDependencies - ); - } - - public FeatureDescriptor( - String registryName, - String featureClassName, - Class metaClass, - String featureName, - String featureVersion, - Set featureDependencies, - Set pluginDependencies - ) { - this.registryName = registryName; - this.featureClassName = featureClassName; - this.featureName = featureName == null ? "" : featureName; - this.featureVersion = featureVersion == null ? "" : featureVersion; + this.registryName = requireText(registryName, "registryName"); + this.featureName = requireText(featureName, "featureName"); + this.featureVersion = requireText(featureVersion, "featureVersion"); + this.implementationType = Objects.requireNonNull(implementationType, "implementationType"); + this.constructor = Objects.requireNonNull(constructor, "constructor"); this.featureDependencies = normalizeDependencies(featureDependencies, registryName); this.pluginDependencies = normalizeDependencies(pluginDependencies, null); - this.metaClass = metaClass; } - public BaseMeta createMeta() { - if (metaClass == null) { - return createStaticMeta(); - } - - try { - return metaClass.getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException | LinkageError ignored) { - return createStaticMeta(); - } + public String registryName() { + return registryName; } - private BaseMeta createStaticMeta() { - return new StaticMeta(featureName, featureVersion, featureDependencies, pluginDependencies); + public String featureName() { + return featureName; } - private static Set normalizeDependencies(Set dependencies, String selfDependencyName) { - if (dependencies == null || dependencies.isEmpty()) { - return Set.of(); - } - - LinkedHashSet normalized = new LinkedHashSet<>(); - for (String dependency : dependencies) { - if (dependency == null) { - continue; - } - - String clean = dependency.trim(); - if (clean.isEmpty()) { - continue; - } + public String featureVersion() { + return featureVersion; + } - if (selfDependencyName != null && clean.equalsIgnoreCase(selfDependencyName)) { - continue; - } + public Class implementationType() { + return implementationType; + } - normalized.add(clean); - } + public Set featureDependencies() { + return featureDependencies; + } - if (normalized.isEmpty()) { - return Set.of(); - } - return Collections.unmodifiableSet(normalized); + public Set pluginDependencies() { + return pluginDependencies; } - private record StaticMeta( - String featureName, - String featureVersion, - List dependencies, - List pluginDependencies - ) implements BaseMeta { - private StaticMeta(String featureName, String featureVersion, Set dependencies, Set pluginDependencies) { - this(featureName, featureVersion, List.copyOf(dependencies), List.copyOf(pluginDependencies)); + public VelocityBaseFeature create(FeatureContext context) { + VelocityBaseFeature feature = constructor.apply(Objects.requireNonNull(context, "context")); + if (feature == null) { + throw new IllegalStateException("Feature constructor returned null: " + implementationType.getName()); } - - @Override - public String getFeatureName() { - return featureName; + if (!implementationType.isInstance(feature)) { + throw new IllegalStateException( + "Feature constructor returned " + feature.getClass().getName() + + " instead of " + implementationType.getName() + ); } + return feature; + } - @Override - public String getFeatureVersion() { - return featureVersion; + private static String requireText(String value, String fieldName) { + Objects.requireNonNull(value, fieldName); + String clean = value.trim(); + if (clean.isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); } + return clean; + } - @Override - public List getDependencies() { - return dependencies; + private static Set normalizeDependencies(Set dependencies, String selfDependencyName) { + if (dependencies == null || dependencies.isEmpty()) { + return Set.of(); } - - @Override - public List getPluginDependencies() { - return pluginDependencies; + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String dependency : dependencies) { + String clean = requireText(dependency, "dependency"); + if (selfDependencyName == null || !clean.equalsIgnoreCase(selfDependencyName)) { + normalized.add(clean); + } } + return normalized.isEmpty() ? Set.of() : Collections.unmodifiableSet(normalized); } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManager.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManager.java index e11fea1e..18a991e3 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManager.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManager.java @@ -1,13 +1,14 @@ package nl.hauntedmc.proxyfeatures.framework.loader; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.stateful.SnapshotState; -import nl.hauntedmc.proxyfeatures.api.feature.stateful.StatefulFeature; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureState; +import nl.hauntedmc.proxyfeatures.framework.feature.stateful.SnapshotState; +import nl.hauntedmc.proxyfeatures.framework.feature.stateful.StatefulFeature; import nl.hauntedmc.proxyfeatures.framework.feature.FeatureScopeFactory; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.FeatureFactory; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.LifecycleCoordinator; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.framework.config.MainConfigHandler; import nl.hauntedmc.proxyfeatures.framework.loader.dependency.DependencyCheckResult; import nl.hauntedmc.proxyfeatures.framework.loader.dependency.FeatureDependencyManager; @@ -35,6 +36,7 @@ private enum LoadOrderState { private final FeatureRegistry featureRegistry; private final FeatureDependencyManager dependencyManager; private final FeatureScopeFactory featureScopeFactory; + private final LifecycleCoordinator lifecycleCoordinator; public FeatureLoadManager(ProxyFeatures plugin, FeatureScopeFactory featureScopeFactory) { this.plugin = plugin; @@ -42,171 +44,188 @@ public FeatureLoadManager(ProxyFeatures plugin, FeatureScopeFactory featureScope this.featureRegistry = new FeatureRegistry(); this.dependencyManager = new FeatureDependencyManager(this, plugin); this.featureScopeFactory = featureScopeFactory; + LifecycleCoordinator coordinator = plugin.getLifecycleCoordinator(); + this.lifecycleCoordinator = coordinator == null ? new LifecycleCoordinator() : coordinator; discoverFeatures(); } private void discoverFeatures() { - plugin.getLogger().info("[FeatureScanner] Scanning for features..."); - try (var scanResult = new io.github.classgraph.ClassGraph() - .enableClassInfo() - .acceptPackages("nl.hauntedmc.proxyfeatures.features") - .scan()) { - scanResult.getSubclasses(VelocityBaseFeature.class.getName()).forEach(classInfo -> { - if (classInfo.isAbstract()) { - return; - } - - String registryName = classInfo.getSimpleName(); - String featureClassName = classInfo.getName(); - Optional descriptorOptional = buildDescriptor(registryName, featureClassName); - if (descriptorOptional.isEmpty()) { - return; - } + plugin.getLogger().info("Loading the explicit built-in feature manifest..."); + + Map, BuiltInFeatures.Definition> providers = capabilityProviders(); + Map, BuiltInFeatures.Definition> internalProviders = internalServiceProviders(); + validateCapabilityReferences(providers); + validateInternalServiceReferences(internalProviders); + List definitions = new ArrayList<>(BuiltInFeatures.definitions()); + definitions.sort(Comparator.comparing(BuiltInFeatures.Definition::startupPhase)); + + for (BuiltInFeatures.Definition definition : definitions) { + Set dependencies = resolveFeatureDependencies(definition, providers, internalProviders); + if (dependencies == null) { + continue; + } - FeatureDescriptor descriptor = descriptorOptional.get(); - String conflictingKey = findCaseInsensitiveMatch( + FeatureDescriptor descriptor = definition.descriptor(dependencies); + String conflictingKey = findCaseInsensitiveMatch( + descriptor.registryName(), + featureRegistry.getAvailableFeatures().keySet() + ); + if (conflictingKey != null) { + FeatureDescriptor existing = featureRegistry.getAvailableFeature(conflictingKey); + plugin.getLogger().error( + "Skipping feature implementation '{}' because feature key '{}' conflicts with '{}'.", + descriptor.implementationType().getName(), descriptor.registryName(), - featureRegistry.getAvailableFeatures().keySet() + existing.implementationType().getName() ); - if (conflictingKey != null) { - FeatureDescriptor existing = featureRegistry.getAvailableFeature(conflictingKey); - plugin.getLogger().error( - "Skipping feature class '{}' because feature key '{}' conflicts with '{}'.", - descriptor.featureClassName(), - descriptor.registryName(), - existing.featureClassName() - ); - return; - } + continue; + } - featureRegistry.registerAvailableFeature(descriptor); - }); + featureRegistry.registerAvailableFeature(descriptor); + plugin.getFeatureCatalog().register(toPublicDescriptor(descriptor, definition)); + plugin.getFeatureCatalog().setConfiguredEnabled( + FeatureId.of(descriptor.registryName()), mainConfigHandler.isFeatureEnabled(descriptor.registryName())); } pruneFeaturesWithMissingDependencies(); prepareFeatureStorage(); - plugin.getLogger().info("Discovered features: {}", featureRegistry.getAvailableFeatures().keySet()); } - private Optional buildDescriptor(String registryName, String featureClassName) { - Optional metaOptional = resolveMeta(featureClassName); - if (metaOptional.isEmpty()) { - int lastDot = featureClassName.lastIndexOf('.'); - String expectedMetaClass = lastDot < 0 - ? featureClassName + ".meta.Meta" - : featureClassName.substring(0, lastDot) + ".meta.Meta"; - plugin.getLogger().error( - "Skipping feature class '{}' because required meta class '{}' is missing or invalid.", - featureClassName, - expectedMetaClass - ); - return Optional.empty(); - } - - BaseMeta meta = metaOptional.get(); - String featureName = (meta.getFeatureName() == null || meta.getFeatureName().isBlank()) - ? registryName - : meta.getFeatureName().trim(); - if (featureName.isBlank()) { - plugin.getLogger().error( - "Skipping feature class '{}' because getFeatureName() produced an empty name.", - featureClassName - ); - return Optional.empty(); - } - - String featureKey = featureName; - if (!isValidFeatureKey(featureKey)) { - plugin.getLogger().error( - "Skipping feature class '{}' because getFeatureName() produced an invalid key: '{}'." - + " Allowed characters: letters, digits, '_' and '-'.", - featureClassName, - featureKey - ); - return Optional.empty(); - } - - String featureVersion = (meta.getFeatureVersion() == null || meta.getFeatureVersion().isBlank()) - ? "?" - : meta.getFeatureVersion(); - Set featureDependencies = normalizeFeatureDependencies(featureClassName, featureKey, meta.getDependencies()); - if (featureDependencies == null) { - return Optional.empty(); + private Map, BuiltInFeatures.Definition> capabilityProviders() { + Map, BuiltInFeatures.Definition> providers = new LinkedHashMap<>(); + for (BuiltInFeatures.Definition definition : BuiltInFeatures.definitions()) { + for (Class capability : definition.providedCapabilities()) { + BuiltInFeatures.Definition previous = providers.putIfAbsent(capability, definition); + if (previous != null) { + throw new IllegalStateException( + "Capability " + capability.getName() + " is provided by both " + + previous.featureName() + " and " + definition.featureName() + ); + } + } } - Set pluginDependencies = meta.getPluginDependencies() == null - ? Set.of() - : new LinkedHashSet<>(meta.getPluginDependencies()); + return Map.copyOf(providers); + } - return Optional.of(new FeatureDescriptor( - featureKey, - featureClassName, - meta, - featureName, - featureVersion, - featureDependencies, - pluginDependencies - )); - } - - private Optional resolveMeta(String featureClassName) { - int lastDot = featureClassName.lastIndexOf('.'); - if (lastDot < 0) { - return Optional.empty(); + private void validateCapabilityReferences(Map, BuiltInFeatures.Definition> providers) { + Set> bootstrapCapabilities = plugin.capabilities().availableTypes(); + for (BuiltInFeatures.Definition definition : BuiltInFeatures.definitions()) { + LinkedHashSet> referenced = new LinkedHashSet<>(definition.requiredCapabilities()); + referenced.addAll(definition.optionalCapabilities()); + for (Class capability : referenced) { + if (!providers.containsKey(capability) && !bootstrapCapabilities.contains(capability)) { + throw new IllegalStateException( + "Feature " + definition.featureName() + + " references capability without a provider: " + capability.getName() + ); + } + } } + } - String packageName = featureClassName.substring(0, lastDot); - String metaClassName = packageName + ".meta.Meta"; - - try { - Class metaClass = Class.forName(metaClassName, true, plugin.getClass().getClassLoader()); - if (!BaseMeta.class.isAssignableFrom(metaClass)) { - plugin.getLogger().warn("Meta class does not implement BaseMeta: {}", metaClassName); - return Optional.empty(); + private Map, BuiltInFeatures.Definition> internalServiceProviders() { + Map, BuiltInFeatures.Definition> providers = new LinkedHashMap<>(); + for (BuiltInFeatures.Definition definition : BuiltInFeatures.definitions()) { + for (Class service : definition.providedInternalServices()) { + BuiltInFeatures.Definition previous = providers.putIfAbsent(service, definition); + if (previous != null) { + throw new IllegalStateException("Internal service " + service.getName() + " is provided by both " + + previous.featureName() + " and " + definition.featureName()); + } } - - BaseMeta meta = (BaseMeta) metaClass.getDeclaredConstructor().newInstance(); - return Optional.of(meta); - } catch (ClassNotFoundException e) { - plugin.getLogger().warn("Meta class not found: {}", metaClassName); - return Optional.empty(); - } catch (ReflectiveOperationException | LinkageError t) { - plugin.getLogger().warn("Could not resolve meta for {}", featureClassName, t); - return Optional.empty(); } + return Map.copyOf(providers); } - private Set normalizeFeatureDependencies(String featureClassName, String featureKey, Collection dependencies) { - if (dependencies == null || dependencies.isEmpty()) { - return Set.of(); + private void validateInternalServiceReferences(Map, BuiltInFeatures.Definition> providers) { + for (BuiltInFeatures.Definition definition : BuiltInFeatures.definitions()) { + for (Class service : definition.requiredInternalServices()) { + if (!providers.containsKey(service)) { + throw new IllegalStateException("Feature " + definition.featureName() + + " requires internal service without a provider: " + service.getName()); + } + } } + } - LinkedHashSet normalized = new LinkedHashSet<>(); - for (String rawDependency : dependencies) { - String dependencyKey = rawDependency == null ? "" : rawDependency.trim(); - if (dependencyKey.isEmpty()) { + private Set resolveFeatureDependencies( + BuiltInFeatures.Definition definition, + Map, BuiltInFeatures.Definition> providers, + Map, BuiltInFeatures.Definition> internalProviders + ) { + LinkedHashSet dependencies = new LinkedHashSet<>(); + for (Class requiredCapability : definition.requiredCapabilities()) { + BuiltInFeatures.Definition provider = providers.get(requiredCapability); + if (provider == null) { + if (plugin.capabilities().availableTypes().contains(requiredCapability)) { + continue; + } plugin.getLogger().error( - "Skipping feature class '{}' because dependency name is invalid: '{}'.", - featureClassName, - rawDependency + "Skipping feature '{}' because required capability '{}' is unavailable.", + definition.featureName(), + requiredCapability.getName() ); return null; } - if (!isValidFeatureKey(dependencyKey)) { - plugin.getLogger().error( - "Skipping feature class '{}' because dependency key is invalid: '{}'." - + " Allowed characters: letters, digits, '_' and '-'.", - featureClassName, - dependencyKey - ); + if (!provider.featureName().equalsIgnoreCase(definition.featureName())) { + dependencies.add(provider.featureName()); + } + } + for (Class requiredService : definition.requiredInternalServices()) { + BuiltInFeatures.Definition provider = internalProviders.get(requiredService); + if (provider == null) { + plugin.getLogger().error("Skipping feature '{}' because required internal service '{}' is unavailable.", + definition.featureName(), requiredService.getName()); return null; } - - if (!dependencyKey.equalsIgnoreCase(featureKey)) { - normalized.add(dependencyKey); + if (!provider.featureName().equalsIgnoreCase(definition.featureName())) { + dependencies.add(provider.featureName()); } } - return normalized; + return Set.copyOf(dependencies); + } + + private static nl.hauntedmc.proxyfeatures.api.feature.FeatureDescriptor toPublicDescriptor( + FeatureDescriptor descriptor, + BuiltInFeatures.Definition definition + ) { + Set dependencies = descriptor.featureDependencies().stream() + .map(FeatureId::of) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Set capabilities = definition.providedCapabilities().stream() + .map(FeatureLoadManager::capabilityId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return new nl.hauntedmc.proxyfeatures.api.feature.FeatureDescriptor( + FeatureId.of(descriptor.registryName()), + descriptor.featureName(), + descriptor.featureVersion(), + definition.classification(), + dependencies, + capabilities, + roles(definition) + ); + } + + private static Set roles(BuiltInFeatures.Definition definition) { + java.util.EnumSet roles = + java.util.EnumSet.noneOf(nl.hauntedmc.proxyfeatures.api.feature.FeatureRole.class); + if (!definition.providedCapabilities().isEmpty()) roles.add(nl.hauntedmc.proxyfeatures.api.feature.FeatureRole.CAPABILITY_PROVIDER); + if (!definition.requiredCapabilities().isEmpty() || !definition.optionalCapabilities().isEmpty()) { + roles.add(nl.hauntedmc.proxyfeatures.api.feature.FeatureRole.CAPABILITY_CONSUMER); + } + if (definition.classification() == nl.hauntedmc.proxyfeatures.api.feature.FeatureClassification.EXTENSION_PROVIDER) { + roles.add(nl.hauntedmc.proxyfeatures.api.feature.FeatureRole.EXTENSION_PROVIDER); + } + // Built-ins are all discoverable/configurable through the operator command surface. + roles.add(nl.hauntedmc.proxyfeatures.api.feature.FeatureRole.OPERATOR_FACING); + return Set.copyOf(roles); + } + + private static String capabilityId(Class capability) { + String simple = capability.getSimpleName(); + String base = simple.endsWith("Api") ? simple.substring(0, simple.length() - 3) : simple; + return "proxyfeatures:" + base.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase(java.util.Locale.ROOT); } private void pruneFeaturesWithMissingDependencies() { @@ -223,6 +242,11 @@ private void pruneFeaturesWithMissingDependencies() { if (!missingDependencies.isEmpty()) { featureRegistry.deregisterAvailableFeature(descriptor.registryName()); + plugin.getFeatureCatalog().setUnavailableDependencies( + FeatureId.of(descriptor.registryName()), + missingDependencies.stream().map(FeatureId::of) + .collect(java.util.stream.Collectors.toUnmodifiableSet()) + ); changed = true; plugin.getLogger().error( "Skipping feature '{}' ({}) because dependency feature(s) are unavailable: {}", @@ -236,29 +260,43 @@ private void pruneFeaturesWithMissingDependencies() { } private void prepareFeatureStorage() { - for (FeatureDescriptor descriptor : new ArrayList<>(featureRegistry.getAvailableFeatures().values())) { - FeatureContext context = createFeatureContext(descriptor); - if (context == null) { - plugin.getLogger().warn("Skipping storage preparation for feature '{}': unable to create feature context.", descriptor.registryName()); - continue; - } - - VelocityBaseFeature template = FeatureFactory.createFeature(descriptor.featureClassName(), context); - if (template == null) { - plugin.getLogger().warn("Skipping storage preparation for feature '{}': unable to instantiate template.", descriptor.registryName()); - continue; + for (FeatureDescriptor descriptor : featureRegistry.getAvailableFeatures().values()) { + FeatureContext context = createFeatureContext(descriptor); + Throwable failure = null; + try { + VelocityBaseFeature feature = descriptor.create(context); + feature.getConfigHandler().injectDefaults(feature.getDefaultConfig()); + feature.getLocalizationHandler().registerDefaultMessages(feature.getDefaultMessages()); + } catch (Throwable preparationFailure) { + failure = preparationFailure; + } finally { + try { + context.lifecycleManager().cleanup(); + } catch (Throwable cleanupFailure) { + if (failure == null) { + failure = cleanupFailure; + } else { + failure.addSuppressed(cleanupFailure); + } + } } - try { - template.getConfigHandler().injectDefaults(template.getDefaultConfig()); - template.getLocalizationHandler().registerDefaultMessages(template.getDefaultMessages()); - } catch (Throwable t) { - plugin.getLogger().error("Failed preparing config/localization storage for feature '{}'.", descriptor.registryName(), t); + if (failure != null) { + plugin.getFeatureCatalog().fail(FeatureId.of(descriptor.registryName()), "preparation", failure); + plugin.getLogger().error( + "Failed preparing config/localization storage for feature '{}'.", + descriptor.registryName(), + failure + ); } } } public void initializeFeatures() { + lifecycleCoordinator.runExclusive(this::initializeFeaturesLocked); + } + + private synchronized void initializeFeaturesLocked() { List loadOrder = new ArrayList<>(); Map states = new HashMap<>(); Set skippedFeatures = new LinkedHashSet<>(); @@ -369,14 +407,14 @@ public String resolveFeatureKey(String inputName) { return descriptor.registryName(); } - String simpleClassName = simpleClassName(descriptor.featureClassName()); + String simpleClassName = descriptor.implementationType().getSimpleName(); if (candidate.equalsIgnoreCase(simpleClassName)) { return descriptor.registryName(); } } for (String loadedKey : featureRegistry.getLoadedFeatureNames()) { - VelocityBaseFeature loadedFeature = featureRegistry.getLoadedFeature(loadedKey); + VelocityBaseFeature loadedFeature = featureRegistry.getLoadedFeature(loadedKey); if (loadedFeature == null) { continue; } @@ -399,27 +437,6 @@ private String findCaseInsensitiveMatch(String candidate, Collection val return null; } - private String simpleClassName(String className) { - if (className == null || className.isBlank()) { - return ""; - } - int lastDot = className.lastIndexOf('.'); - return lastDot < 0 ? className : className.substring(lastDot + 1); - } - - private boolean isValidFeatureKey(String value) { - if (value == null || value.isBlank()) { - return false; - } - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (!Character.isLetterOrDigit(c) && c != '_' && c != '-') { - return false; - } - } - return true; - } - private DependencyCheckResult diagnoseDependenciesRecursively(String featureName) { String featureKey = resolveFeatureKey(featureName); if (featureKey == null) { @@ -472,6 +489,10 @@ private FeatureDescriptor requireAvailableDescriptor(String inputName) { } public FeatureEnableResponse enableFeature(String featureName) { + return lifecycleCoordinator.callExclusive(() -> enableFeatureLocked(featureName)); + } + + private synchronized FeatureEnableResponse enableFeatureLocked(String featureName) { FeatureDescriptor descriptor = requireAvailableDescriptor(featureName); if (descriptor == null) { plugin.getLogger().warn("Feature not found: {}", featureName); @@ -502,10 +523,12 @@ public FeatureEnableResponse enableFeature(String featureName) { boolean previousEnabled = mainConfigHandler.isFeatureEnabled(featureKey); mainConfigHandler.setFeatureEnabled(featureKey, true); + plugin.getFeatureCatalog().setConfiguredEnabled(FeatureId.of(featureKey), true); boolean loaded = loadFeature(featureKey); if (!loaded) { mainConfigHandler.setFeatureEnabled(featureKey, previousEnabled); + plugin.getFeatureCatalog().setConfiguredEnabled(FeatureId.of(featureKey), previousEnabled); DependencyCheckResult postLoadDiag = diagnoseDependenciesRecursively(featureKey); if (!postLoadDiag.missingPluginDependencies().isEmpty()) { return new FeatureEnableResponse( @@ -528,39 +551,73 @@ public FeatureEnableResponse enableFeature(String featureName) { } public FeatureDisableResponse disableFeature(String featureName) { + return lifecycleCoordinator.callExclusive(() -> disableFeatureLocked(featureName)); + } + + private synchronized FeatureDisableResponse disableFeatureLocked(String featureName) { String featureKey = resolveFeatureKey(featureName); if (featureKey == null) { plugin.getLogger().warn("Feature not currently loaded: {}", featureName); return new FeatureDisableResponse(FeatureDisableResult.NOT_LOADED, featureName, Set.of()); } - VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureKey); + VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureKey); if (feature == null) { plugin.getLogger().warn("Feature not currently loaded: {}", featureKey); return new FeatureDisableResponse(FeatureDisableResult.NOT_LOADED, featureName, Set.of()); } - Set dependents = new LinkedHashSet<>(dependencyManager.getDependentFeatures(featureKey)); - for (String dependent : dependents) { - FeatureDisableResponse depResp = disableFeature(dependent); - if (!depResp.success()) { - plugin.getLogger().warn("Failed to disable dependent feature: {}", dependent); + LinkedHashSet disabledDependents = new LinkedHashSet<>(); + boolean dependentFailure = false; + for (String dependent : dependencyManager.getDependentFeatures(featureKey)) { + FeatureDisableResponse dependentResponse = disableFeature(dependent); + if (dependentResponse.feature() != null) { + disabledDependents.add(dependentResponse.feature()); + } + disabledDependents.addAll(dependentResponse.alsoDisabledDependents()); + if (!dependentResponse.success()) { + dependentFailure = true; + plugin.getLogger().warn("Failed to cleanly disable dependent feature: {}", dependent); } } + Throwable failure = null; + try { + plugin.getFeatureCatalog().transition(FeatureId.of(featureKey), FeatureState.STOPPING); + } catch (Throwable transitionFailure) { + failure = appendFailure(failure, transitionFailure); + } try { feature.cleanup(); + } catch (Throwable cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + try { mainConfigHandler.setFeatureEnabled(featureKey, false); + plugin.getFeatureCatalog().setConfiguredEnabled(FeatureId.of(featureKey), false); + } catch (Throwable persistenceFailure) { + failure = appendFailure(failure, persistenceFailure); + } finally { featureRegistry.deregisterLoadedFeature(featureKey); - plugin.getLogger().info("Feature disabled: {}", featureKey); - return new FeatureDisableResponse(FeatureDisableResult.SUCCESS, featureKey, dependents); - } catch (Throwable t) { - plugin.getLogger().error("Disable failed: {}", featureKey, t); - return new FeatureDisableResponse(FeatureDisableResult.FAILED, featureKey, dependents); } + + if (failure != null) { + plugin.getFeatureCatalog().fail(FeatureId.of(featureKey), "shutdown", failure); + plugin.getLogger().error("Disable failed after feature was removed from the active registry: {}", featureKey, failure); + return new FeatureDisableResponse(FeatureDisableResult.FAILED, featureKey, Set.copyOf(disabledDependents)); + } + + plugin.getFeatureCatalog().transition(FeatureId.of(featureKey), FeatureState.DISABLED); + plugin.getLogger().info("Feature disabled: {}", featureKey); + FeatureDisableResult result = dependentFailure ? FeatureDisableResult.FAILED : FeatureDisableResult.SUCCESS; + return new FeatureDisableResponse(result, featureKey, Set.copyOf(disabledDependents)); } public FeatureSoftReloadResponse softReloadFeature(String featureName) { + return lifecycleCoordinator.callExclusive(() -> softReloadFeatureLocked(featureName)); + } + + private synchronized FeatureSoftReloadResponse softReloadFeatureLocked(String featureName) { String featureKey = resolveFeatureKey(featureName); if (featureKey == null || !featureRegistry.isFeatureLoaded(featureKey)) { plugin.getLogger().warn("Feature not currently loaded: {}", featureName); @@ -568,9 +625,14 @@ public FeatureSoftReloadResponse softReloadFeature(String featureName) { } try { - VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureKey); + VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureKey); feature.getConfigHandler().reloadConfig(); feature.getLocalizationHandler().reloadLocalization(); + if (feature.applyConfiguration() == nl.hauntedmc.proxyfeatures.framework.config.ConfigReloadResult.RECREATE_REQUIRED) { + FeatureReloadResponse response = reloadFeatureLocked(featureKey); + return new FeatureSoftReloadResponse(response.success() + ? FeatureSoftReloadResult.SUCCESS : FeatureSoftReloadResult.FAILED, featureKey); + } plugin.getLogger().info("Feature {} soft reloaded.", featureKey); return new FeatureSoftReloadResponse(FeatureSoftReloadResult.SUCCESS, featureKey); } catch (Throwable t) { @@ -580,46 +642,203 @@ public FeatureSoftReloadResponse softReloadFeature(String featureName) { } public FeatureReloadResponse reloadFeature(String featureName) { + return lifecycleCoordinator.callExclusive(() -> reloadFeatureLocked(featureName)); + } + + private synchronized FeatureReloadResponse reloadFeatureLocked(String featureName) { String featureKey = resolveFeatureKey(featureName); if (featureKey == null || !featureRegistry.isFeatureLoaded(featureKey)) { plugin.getLogger().warn("Feature not currently loaded: {}", featureName); return new FeatureReloadResponse(FeatureReloadResult.NOT_LOADED, featureName, Set.of()); } - Set reloadedDependents = new LinkedHashSet<>(); + List reloadOrder; + Map reloadStates; try { - VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureKey); - Optional reloadState = captureReloadState(featureKey, feature); + reloadOrder = buildReloadOrder(featureKey); + reloadStates = captureReloadStates(reloadOrder); + } catch (Throwable failure) { + plugin.getFeatureCatalog().fail(FeatureId.of(featureKey), "reload-preparation", failure); + plugin.getLogger().error("Reload preparation failed for: {}", featureKey, failure); + return new FeatureReloadResponse(FeatureReloadResult.FAILED, featureKey, Set.of()); + } - feature.cleanup(); - featureRegistry.deregisterLoadedFeature(featureKey); + LinkedHashSet dependents = new LinkedHashSet<>(reloadOrder); + dependents.remove(featureKey); + + Throwable stopFailure = stopReloadGraph(reloadOrder); + if (stopFailure != null) { + boolean rolledBack = restoreReloadGraph(reloadOrder, reloadStates); + plugin.getLogger().error( + "Reload quiesce failed for feature graph rooted at '{}'; rollback success={}", + featureKey, + rolledBack, + stopFailure + ); + return new FeatureReloadResponse(FeatureReloadResult.FAILED, featureKey, dependents); + } + + if (startReloadGraph(reloadOrder, reloadStates)) { + plugin.getLogger().info("Feature graph rooted at '{}' reloaded: {}", featureKey, reloadOrder); + return new FeatureReloadResponse(FeatureReloadResult.SUCCESS, featureKey, dependents); + } + + Throwable replacementCleanupFailure = stopReloadGraph(reloadOrder); + if (replacementCleanupFailure != null) { + plugin.getLogger().error( + "Failed cleaning partially started replacement graph rooted at '{}'.", + featureKey, + replacementCleanupFailure + ); + } + + boolean rolledBack = restoreReloadGraph(reloadOrder, reloadStates); + plugin.getLogger().error( + "Reload failed for feature graph rooted at '{}'; rollback success={}", + featureKey, + rolledBack + ); + return new FeatureReloadResponse(FeatureReloadResult.FAILED, featureKey, dependents); + } - boolean hasReloaded = reloadState.isPresent() - ? loadFeature(featureKey, reloadState.get()) - : loadFeature(featureKey); - if (!hasReloaded) { - plugin.getLogger().error("Reload failed for: {} (feature did not load back)", featureKey); - return new FeatureReloadResponse(FeatureReloadResult.FAILED, featureKey, reloadedDependents); + private List buildReloadOrder(String rootFeature) { + LinkedHashSet affected = new LinkedHashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(rootFeature); + while (!pending.isEmpty()) { + String current = pending.removeFirst(); + if (!affected.add(current)) { + continue; } + dependencyManager.getDependentFeatures(current).stream() + .filter(featureRegistry::isFeatureLoaded) + .forEach(pending::addLast); + } - plugin.getLogger().info("Feature {} reloaded.", featureKey); + Map inDegree = new LinkedHashMap<>(); + for (String feature : affected) { + FeatureDescriptor descriptor = featureRegistry.getAvailableFeature(feature); + if (descriptor == null) { + throw new IllegalStateException("Feature descriptor is unavailable during reload: " + feature); + } + int dependenciesInGraph = 0; + for (String dependency : descriptor.featureDependencies()) { + String dependencyKey = resolveFeatureKey(dependency); + if (dependencyKey != null && affected.contains(dependencyKey)) { + dependenciesInGraph++; + } + } + inDegree.put(feature, dependenciesInGraph); + } - for (String dependent : dependencyManager.getDependentFeatures(featureKey)) { - plugin.getLogger().info("Reloading dependent feature: {}", dependent); - FeatureReloadResponse depResp = reloadFeature(dependent); - if (depResp.success()) { - reloadedDependents.add(dependent); + ArrayDeque ready = new ArrayDeque<>(); + inDegree.forEach((feature, degree) -> { + if (degree == 0) { + ready.addLast(feature); + } + }); + + List order = new ArrayList<>(affected.size()); + while (!ready.isEmpty()) { + String current = ready.removeFirst(); + order.add(current); + for (String candidate : affected) { + if (order.contains(candidate)) { + continue; + } + FeatureDescriptor descriptor = featureRegistry.getAvailableFeature(candidate); + boolean dependsOnCurrent = descriptor.featureDependencies().stream() + .map(this::resolveFeatureKey) + .anyMatch(current::equals); + if (!dependsOnCurrent) { + continue; + } + int remaining = inDegree.computeIfPresent(candidate, (ignored, degree) -> degree - 1); + if (remaining == 0) { + ready.addLast(candidate); } } + } - return new FeatureReloadResponse(FeatureReloadResult.SUCCESS, featureKey, reloadedDependents); - } catch (Throwable t) { - plugin.getLogger().error("Reload failed for: {}", featureKey, t); - return new FeatureReloadResponse(FeatureReloadResult.FAILED, featureKey, reloadedDependents); + if (order.size() != affected.size()) { + throw new IllegalStateException("Reload graph contains a dependency cycle: " + affected); + } + return List.copyOf(order); + } + + private Map captureReloadStates(List reloadOrder) { + Map states = new LinkedHashMap<>(); + for (String featureName : reloadOrder) { + VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureName); + captureReloadState(featureName, feature).ifPresent(state -> states.put(featureName, state)); + } + return Map.copyOf(states); + } + + private Throwable stopReloadGraph(List reloadOrder) { + Throwable failure = null; + ListIterator iterator = reloadOrder.listIterator(reloadOrder.size()); + while (iterator.hasPrevious()) { + String featureName = iterator.previous(); + VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureName); + if (feature == null) { + continue; + } + + Throwable featureFailure = null; + plugin.getFeatureCatalog().transition(FeatureId.of(featureName), FeatureState.STOPPING); + try { + feature.cleanup(); + } catch (Throwable cleanupFailure) { + featureFailure = cleanupFailure; + failure = appendFailure(failure, cleanupFailure); + } finally { + featureRegistry.deregisterLoadedFeature(featureName); + if (featureFailure == null) { + plugin.getFeatureCatalog().transition(FeatureId.of(featureName), FeatureState.DISABLED); + } else { + plugin.getFeatureCatalog().fail(FeatureId.of(featureName), "reload-shutdown", featureFailure); + } + } + } + return failure; + } + + private boolean startReloadGraph(List reloadOrder, Map reloadStates) { + for (String featureName : reloadOrder) { + if (!loadFeature(featureName, reloadStates.get(featureName))) { + return false; + } + } + return true; + } + + private boolean restoreReloadGraph(List reloadOrder, Map reloadStates) { + if (startReloadGraph(reloadOrder, reloadStates)) { + plugin.getLogger().warn("Restored previous feature graph after failed reload: {}", reloadOrder); + return true; + } + + Throwable cleanupFailure = stopReloadGraph(reloadOrder); + if (cleanupFailure != null) { + plugin.getLogger().error("Rollback cleanup also failed for graph: {}", reloadOrder, cleanupFailure); + } + return false; + } + + private static Throwable appendFailure(Throwable current, Throwable additional) { + if (current == null) { + return additional; } + current.addSuppressed(additional); + return current; } public boolean loadFeature(String featureName) { + return lifecycleCoordinator.callExclusive(() -> loadFeatureLocked(featureName)); + } + + private synchronized boolean loadFeatureLocked(String featureName) { return loadFeature(featureName, null); } @@ -642,6 +861,7 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { } boolean enabled = mainConfigHandler.isFeatureEnabled(featureKey); + plugin.getFeatureCatalog().setConfiguredEnabled(FeatureId.of(featureKey), enabled); if (!enabled) { return false; } @@ -663,53 +883,47 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { return false; } - FeatureContext context = createFeatureContext(descriptor); - if (context == null) { - return false; - } - - VelocityBaseFeature feature = FeatureFactory.createFeature(descriptor.featureClassName(), context); - if (feature == null) { - return false; - } - - feature.getConfigHandler().injectDefaults(feature.getDefaultConfig()); - feature.getLocalizationHandler().registerDefaultMessages(feature.getDefaultMessages()); - feature.getConfigHandler().reloadConfig(); - feature.getLocalizationHandler().reloadLocalization(); - + VelocityBaseFeature feature = null; + plugin.getFeatureCatalog().transition(FeatureId.of(featureKey), FeatureState.STARTING); try { + feature = descriptor.create(createFeatureContext(descriptor)); + + feature.getConfigHandler().injectDefaults(feature.getDefaultConfig()); + feature.getLocalizationHandler().registerDefaultMessages(feature.getDefaultMessages()); + feature.getConfigHandler().reloadConfig(); + feature.getLocalizationHandler().reloadLocalization(); feature.initialize(); - } catch (Throwable t) { - plugin.getLogger().error("Feature '{}' failed to initialize.", featureKey, t); - try { - feature.cleanup(); - } catch (Throwable cleanupError) { - plugin.getLogger().error("Feature '{}' failed to cleanup after initialization failure.", featureKey, cleanupError); - } - return false; - } - if (reloadState != null) { - try { + if (reloadState != null) { restoreReloadState(featureKey, feature, reloadState); - } catch (Throwable t) { - plugin.getLogger().error("Feature '{}' failed to restore reload state.", featureKey, t); + } + + feature.getLifecycleManager().getApiManager().activateServices(); + featureRegistry.registerLoadedFeature(featureKey, feature); + plugin.getFeatureCatalog().setUnavailableDependencies(FeatureId.of(featureKey), Set.of()); + plugin.getFeatureCatalog().transition(FeatureId.of(featureKey), FeatureState.ACTIVE); + plugin.getLogger().info("Feature loaded: {}", featureKey); + return true; + } catch (Throwable failure) { + plugin.getFeatureCatalog().fail(FeatureId.of(featureKey), "startup", failure); + plugin.getLogger().error("Feature '{}' failed to start.", featureKey, failure); + if (feature != null) { try { feature.cleanup(); } catch (Throwable cleanupError) { - plugin.getLogger().error("Feature '{}' failed to cleanup after reload-state restore failure.", featureKey, cleanupError); + plugin.getLogger().error( + "Feature '{}' failed to cleanup after its startup failure.", + featureKey, + cleanupError + ); } - return false; } + featureRegistry.deregisterLoadedFeature(featureKey); + return false; } - - featureRegistry.registerLoadedFeature(featureKey, feature); - plugin.getLogger().info("Feature loaded: {}", featureKey); - return true; } - private Optional captureReloadState(String featureKey, VelocityBaseFeature feature) { + private Optional captureReloadState(String featureKey, VelocityBaseFeature feature) { if (feature == null || !(feature instanceof StatefulFeature statefulFeature)) { return Optional.empty(); } @@ -724,7 +938,7 @@ private Optional captureReloadState(String featureKey, VelocityBa } } - private void restoreReloadState(String featureKey, VelocityBaseFeature feature, SnapshotState reloadState) { + private void restoreReloadState(String featureKey, VelocityBaseFeature feature, SnapshotState reloadState) { if (!(feature instanceof StatefulFeature statefulFeature)) { throw new IllegalStateException("Captured reload state exists, but feature does not implement StatefulFeature."); } @@ -760,20 +974,34 @@ public FeatureRegistry getFeatureRegistry() { } public void unloadAllFeatures() { + lifecycleCoordinator.runExclusive(this::unloadAllFeaturesLocked); + } + + private synchronized void unloadAllFeaturesLocked() { plugin.getLogger().info("Unloading all loaded features..."); List loadedFeatureNames = new ArrayList<>(featureRegistry.getLoadedFeatureNames()); + Collections.reverse(loadedFeatureNames); for (String featureName : loadedFeatureNames) { - VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureName); + VelocityBaseFeature feature = featureRegistry.getLoadedFeature(featureName); try { if (feature != null) { + plugin.getFeatureCatalog().transition(FeatureId.of(featureName), FeatureState.STOPPING); feature.cleanup(); } } catch (Throwable t) { + plugin.getFeatureCatalog().fail(FeatureId.of(featureName), "shutdown", t); plugin.getLogger().error("Failed to cleanup feature during unload: {}", featureName, t); } finally { featureRegistry.deregisterLoadedFeature(featureName); + if (plugin.getFeatureCatalog().find(FeatureId.of(featureName)) + .filter(snapshot -> snapshot.state() != FeatureState.FAILED) + .isPresent()) { + plugin.getFeatureCatalog().transition(FeatureId.of(featureName), FeatureState.DISABLED); + } } } + + featureScopeFactory.clearCachedScopes(); plugin.getLogger().info("All features have been unloaded."); } @@ -781,7 +1009,7 @@ private boolean isPluginLoaded(String pluginName) { return plugin.getPluginManager().getPlugin(pluginName).isPresent(); } - private FeatureContext createFeatureContext(FeatureDescriptor descriptor) { - return featureScopeFactory.createContext(descriptor.createMeta()); + private FeatureContext createFeatureContext(FeatureDescriptor descriptor) { + return featureScopeFactory.createContext(descriptor); } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistry.java index 04f4cce9..12c7c098 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistry.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistry.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.framework.loader; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import java.util.ArrayList; import java.util.Collections; @@ -12,49 +12,49 @@ import java.util.Set; public final class FeatureRegistry { - private final Map> loadedFeatures = new LinkedHashMap<>(); + private final Map loadedFeatures = new LinkedHashMap<>(); private final Map availableFeatures = new LinkedHashMap<>(); - public void registerAvailableFeature(FeatureDescriptor descriptor) { + public synchronized void registerAvailableFeature(FeatureDescriptor descriptor) { if (descriptor == null || descriptor.registryName() == null || descriptor.registryName().isBlank()) { return; } availableFeatures.put(descriptor.registryName(), descriptor); } - public void deregisterAvailableFeature(String featureName) { + public synchronized void deregisterAvailableFeature(String featureName) { availableFeatures.remove(featureName); } - public void registerLoadedFeature(String featureName, VelocityBaseFeature feature) { + public synchronized void registerLoadedFeature(String featureName, VelocityBaseFeature feature) { loadedFeatures.put(featureName, feature); } - public void deregisterLoadedFeature(String featureName) { + public synchronized void deregisterLoadedFeature(String featureName) { loadedFeatures.remove(featureName); } - public VelocityBaseFeature getLoadedFeature(String featureName) { + public synchronized VelocityBaseFeature getLoadedFeature(String featureName) { return loadedFeatures.get(featureName); } - public Set getLoadedFeatureNames() { + public synchronized Set getLoadedFeatureNames() { return Collections.unmodifiableSet(new LinkedHashSet<>(loadedFeatures.keySet())); } - public boolean isFeatureLoaded(String featureName) { + public synchronized boolean isFeatureLoaded(String featureName) { return loadedFeatures.containsKey(featureName); } - public Map getAvailableFeatures() { + public synchronized Map getAvailableFeatures() { return Collections.unmodifiableMap(new LinkedHashMap<>(availableFeatures)); } - public FeatureDescriptor getAvailableFeature(String featureName) { + public synchronized FeatureDescriptor getAvailableFeature(String featureName) { return availableFeatures.get(featureName); } - public List> getLoadedFeatures() { + public synchronized List getLoadedFeatures() { return new ArrayList<>(loadedFeatures.values()); } } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandler.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandler.java index bee432a7..3044108a 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandler.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandler.java @@ -5,17 +5,16 @@ import net.kyori.adventure.text.Component; import org.slf4j.Logger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; -import nl.hauntedmc.proxyfeatures.features.playerlanguage.api.LanguageAPI; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.api.capability.player.PlayerLanguageApi; import nl.hauntedmc.proxyfeatures.framework.config.FeatureStoragePaths; -import nl.hauntedmc.proxyfeatures.framework.service.FeatureServices; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.yaml.NodeStyle; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; @@ -47,9 +46,9 @@ public LocalizationHandler(ProxyFeatures plugin, ConfigService configService) { plugin.getLogger(), plugin.getClass().getClassLoader(), configService, - player -> FeatureServices - .find(plugin, LanguageAPI.class) - .map(api -> api.get(player.getUniqueId())) + player -> plugin.capabilities().reference(PlayerLanguageApi.class).get() + .flatMap(api -> api.resolvedLanguage(player.getUniqueId())) + .map(locale -> "nl".equalsIgnoreCase(locale.getLanguage()) ? Language.NL : Language.EN) .orElse(Language.NL) ); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/log/ConnectionLogHelper.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/log/ConnectionLogHelper.java index 4cc12994..aad831d6 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/log/ConnectionLogHelper.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/log/ConnectionLogHelper.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.proxy.InboundConnection; import com.velocitypowered.api.proxy.Player; import net.kyori.adventure.text.Component; -import nl.hauntedmc.proxyfeatures.api.util.text.format.ComponentFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.ComponentFormatter; import java.net.InetAddress; import java.net.InetSocketAddress; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/IpAddressUtil.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/network/IpAddressUtil.java similarity index 70% rename from proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/IpAddressUtil.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/network/IpAddressUtil.java index 89854c86..0cc7bddf 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/antibot/internal/IpAddressUtil.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/network/IpAddressUtil.java @@ -1,23 +1,17 @@ -package nl.hauntedmc.proxyfeatures.features.antibot.internal; +package nl.hauntedmc.proxyfeatures.framework.network; import java.net.InetAddress; import java.net.UnknownHostException; +/** Shared strict IP-literal parser. It never performs DNS resolution for hostnames. */ public final class IpAddressUtil { - private IpAddressUtil() { } public static InetAddress parseLiteral(String input) { - if (input == null || input.isBlank()) { - return null; - } - + if (input == null || input.isBlank()) return null; String candidate = stripBrackets(input.trim()); - if (candidate.isEmpty()) { - return null; - } - + if (candidate.isEmpty()) return null; if (candidate.indexOf(':') >= 0) { try { return InetAddress.getByName(candidate); @@ -25,12 +19,8 @@ public static InetAddress parseLiteral(String input) { return null; } } - byte[] ipv4 = parseIpv4(candidate); - if (ipv4 == null) { - return null; - } - + if (ipv4 == null) return null; try { return InetAddress.getByAddress(ipv4); } catch (UnknownHostException ignored) { @@ -45,36 +35,26 @@ public static String normalizeLiteral(String input) { private static byte[] parseIpv4(String candidate) { String[] parts = candidate.split("\\.", -1); - if (parts.length != 4) { - return null; - } - + if (parts.length != 4) return null; byte[] bytes = new byte[4]; for (int index = 0; index < parts.length; index++) { String part = parts[index]; - if (part.isEmpty() || part.length() > 3) { - return null; - } - + if (part.isEmpty() || part.length() > 3) return null; int value = 0; for (int charIndex = 0; charIndex < part.length(); charIndex++) { char character = part.charAt(charIndex); - if (!Character.isDigit(character)) { - return null; - } + if (!Character.isDigit(character)) return null; value = (value * 10) + (character - '0'); } - - if (value < 0 || value > 255) { - return null; - } + if (value > 255) return null; bytes[index] = (byte) value; } return bytes; } private static String stripBrackets(String candidate) { - if (candidate.length() >= 2 && candidate.charAt(0) == '[' && candidate.charAt(candidate.length() - 1) == ']') { + if (candidate.length() >= 2 && candidate.charAt(0) == '[' + && candidate.charAt(candidate.length() - 1) == ']') { return candidate.substring(1, candidate.length() - 1); } return candidate; diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/DataRegistryIdentityGate.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/DataRegistryIdentityGate.java index 18e50707..c38b37a5 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/DataRegistryIdentityGate.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/DataRegistryIdentityGate.java @@ -2,7 +2,7 @@ import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.dataregistry.api.player.PlayerData; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import java.util.Objects; import java.util.UUID; @@ -25,7 +25,7 @@ private DataRegistryIdentityGate() { * @param operationName short name included in failure logs. */ public static void runWhenReady( - VelocityBaseFeature feature, + VelocityBaseFeature feature, Player player, Consumer action, String operationName diff --git a/proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReference.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReference.java similarity index 100% rename from proxyfeatures-contracts/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReference.java rename to proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReference.java diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java index 793f0553..0e20b08b 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java @@ -4,8 +4,6 @@ import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.dataregistry.api.player.PlayerLookup; -import org.hibernate.Session; - import java.util.Collection; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -24,10 +22,9 @@ /** * Resolves immutable DataRegistry player snapshots without importing or managing DataRegistry entities. * - *

    Synchronous methods are compatibility helpers. They are cache-first and may use a bounded - * persisted fallback only from background threads. They never wait for persistence on a likely - * server, event-loop, or Netty thread. New code that needs offline-player correctness should use - * the explicit asynchronous methods.

    + *

    Synchronous methods are cache-first and may use a bounded persisted fallback only from + * background threads. They never wait for persistence on a likely server, event-loop, or Netty + * thread. Callers that require offline-player correctness should use the asynchronous methods.

    */ public final class PlayerReferenceResolver { @@ -198,11 +195,8 @@ public CompletionStage> findIdentityByIdentifierAsync(S : playerDirectory.findByIdentifier(normalized); } - /** - * Compatibility lookup by stable player id. Despite the historical method name, this method is - * cache-first and may use persistence from a background thread. - */ - public Optional findActiveIdentityById(Long playerId) { + /** Cache-first lookup by stable player id with a bounded background-thread fallback. */ + public Optional findIdentityById(Long playerId) { if (playerId == null || playerId <= 0L) { return Optional.empty(); } @@ -234,19 +228,19 @@ public CompletableFuture> whenReady(UUID uuid) { return playerDirectory.whenReady(uuid); } - public PlayerReference resolveManaged(Session ignored, UUID uuid) { + public PlayerReference resolveReference(UUID uuid) { return findByUuid(uuid).orElse(null); } - public PlayerReference resolveManaged(Session ignored, String uuid) { + public PlayerReference resolveReference(String uuid) { return findByUuid(uuid).orElse(null); } - public PlayerReference resolveManagedById(Session ignored, Long playerId) { + public PlayerReference resolveReferenceById(Long playerId) { if (playerId == null || playerId <= 0L) { return null; } - return findActiveIdentityById(playerId) + return findIdentityById(playerId) .map(PlayerReference::from) .orElseGet(() -> PlayerReference.byId(playerId)); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/security/audit/AbstractPlayerAuditLogService.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/security/audit/AbstractPlayerAuditLogService.java index 865205b9..590c81a9 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/security/audit/AbstractPlayerAuditLogService.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/security/audit/AbstractPlayerAuditLogService.java @@ -38,7 +38,7 @@ protected final void persist(String action, Consumer writer) { } protected final PlayerReference resolvePlayer(Session session, UUID uuid) { - return playerResolver.resolveManaged(session, uuid); + return playerResolver.resolveReference(uuid); } protected final String normalize(String value, int maxLength) { diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/CapabilityProviderGenerationAware.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/CapabilityProviderGenerationAware.java new file mode 100644 index 00000000..f9429f1f --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/CapabilityProviderGenerationAware.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +/** Internal hook for a public capability that needs to expose its publication generation in a DTO. */ +public interface CapabilityProviderGenerationAware { + + /** Called before the provider is made visible through the capability registry. */ + void providerGeneration(long generation); +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/CapabilityRegistration.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/CapabilityRegistration.java new file mode 100644 index 00000000..24e37e5e --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/CapabilityRegistration.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +/** Internal idempotent handle for one feature-owned capability registration. */ +@FunctionalInterface +public interface CapabilityRegistration extends AutoCloseable { + @Override + void close(); +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistry.java new file mode 100644 index 00000000..ded2e797 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistry.java @@ -0,0 +1,501 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.ApiFailureCode; +import nl.hauntedmc.proxyfeatures.api.ApiOperationException; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRef; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRegistry; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityUnavailableException; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityListener; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** Thread-safe, ProxyFeatures-owned capability registry. */ +public final class DefaultCapabilityRegistry implements CapabilityRegistry { + + private static final Duration DEFAULT_DRAIN_TIMEOUT = Duration.ofSeconds(5); + + private static final class Provider { + private final FeatureId owner; + private final Object instance; + private final long generation; + private final ReentrantLock lock = new ReentrantLock(); + private final Condition drained = lock.newCondition(); + private final Set asynchronousInvocations = ConcurrentHashMap.newKeySet(); + private final Duration drainTimeout; + private boolean accepting = true; + private int inFlight; + + private Provider(FeatureId owner, Object instance, long generation, Duration drainTimeout) { + this.owner = owner; + this.instance = instance; + this.generation = generation; + this.drainTimeout = drainTimeout; + } + + private InvocationLease tryAcquire() { + lock.lock(); + try { + if (!accepting) { + return null; + } + inFlight++; + return new InvocationLease(this); + } finally { + lock.unlock(); + } + } + + private void release() { + lock.lock(); + try { + inFlight--; + if (inFlight == 0) { + drained.signalAll(); + } + } finally { + lock.unlock(); + } + } + + /** + * Tracks an asynchronous invocation only while the provider still accepts work. This + * closes the acquire-to-track race with concurrent provider withdrawal. + */ + private boolean track(AsyncInvocation invocation) { + lock.lock(); + try { + if (!accepting) { + return false; + } + asynchronousInvocations.add(invocation); + return true; + } finally { + lock.unlock(); + } + } + + private void complete(AsyncInvocation invocation) { + asynchronousInvocations.remove(invocation); + } + + /** + * Completes a provider stage only while this provider is still accepting work. Holding the + * provider lock makes this completion linearize with withdrawal, so a stage cannot win the + * race after the provider has been withdrawn. + */ + private void completeAsync(AsyncInvocation invocation, Object value, Throwable failure) { + lock.lock(); + try { + if (accepting) { + invocation.complete(value, failure); + } else { + invocation.invalidate(); + } + } finally { + lock.unlock(); + } + } + + /** + * Rejects new invocations and gives synchronous work a finite drain window. Outstanding + * asynchronous calls are failed immediately and deliberately abandoned: their source + * stages may still complete, but their result is no longer observed by this registry. + */ + private Set stopAccepting() { + lock.lock(); + try { + accepting = false; + return Set.copyOf(asynchronousInvocations); + } finally { + lock.unlock(); + } + } + + private ApiOperationException stopAndAwaitDrain(Set pending) { + boolean interrupted = false; + pending.forEach(AsyncInvocation::invalidate); + + lock.lock(); + try { + long remainingNanos = drainTimeout.toNanos(); + while (inFlight > 0) { + if (remainingNanos <= 0) { + return new ApiOperationException( + ApiFailureCode.TIMEOUT, + "Timed out draining capability provider " + owner + " with " + inFlight + + " synchronous invocation(s) still running" + ); + } + try { + remainingNanos = drained.awaitNanos(remainingNanos); + } catch (InterruptedException ignored) { + interrupted = true; + } + } + } finally { + lock.unlock(); + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + return null; + } + } + + private static final class InvocationLease implements AutoCloseable { + private final Provider provider; + private final AtomicBoolean closed = new AtomicBoolean(); + + private InvocationLease(Provider provider) { + this.provider = provider; + } + + private Object instance() { + return provider.instance; + } + + private Provider provider() { + return provider; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + provider.release(); + } + } + } + + private static final class AsyncInvocation { + private final Class type; + private final Provider provider; + private final InvocationLease lease; + private final CompletableFuture result = new CompletableFuture<>(); + private final AtomicBoolean completed = new AtomicBoolean(); + + private AsyncInvocation(Class type, InvocationLease lease) { + this.type = type; + this.lease = lease; + this.provider = lease.provider(); + } + + private CompletionStage result() { + return result; + } + + private void complete(Object value, Throwable failure) { + if (!completed.compareAndSet(false, true)) { + return; + } + try { + if (failure == null) { + result.complete(value); + } else { + result.completeExceptionally(failure); + } + } finally { + provider.complete(this); + lease.close(); + } + } + + private void invalidate() { + // Do not attempt to cancel an arbitrary CompletionStage. Its provider owns that work; + // after withdrawal this registry abandons the stage and releases its lifecycle lease. + complete(null, new ApiOperationException( + ApiFailureCode.PROVIDER_RELOADED, + "ProxyFeatures capability provider reloaded: " + type.getName() + )); + } + } + + private record Withdrawal(Provider provider, Set pending) { + } + + private final ConcurrentHashMap, Provider> providers = new ConcurrentHashMap<>(); + private final ConcurrentHashMap, CapabilityRef> references = new ConcurrentHashMap<>(); + private final AtomicLong generations = new AtomicLong(); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private final Duration drainTimeout; + + public DefaultCapabilityRegistry() { + this(DEFAULT_DRAIN_TIMEOUT); + } + + DefaultCapabilityRegistry(Duration drainTimeout) { + this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout"); + if (drainTimeout.isNegative() || drainTimeout.isZero()) { + throw new IllegalArgumentException("drainTimeout must be positive"); + } + } + + public CapabilityRegistration register(FeatureId owner, Class type, T instance) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(instance, "instance"); + validateCapabilityType(type); + if (!type.isInstance(instance)) { + throw new IllegalArgumentException("Capability implementation does not implement " + type.getName()); + } + + Provider provider = new Provider(owner, instance, generations.incrementAndGet(), drainTimeout); + configureProviderGeneration(instance, provider.generation); + providers.compute(type, (ignored, current) -> { + if (current != null) { + throw new IllegalStateException( + "Capability " + type.getName() + " is already provided by " + current.owner + ); + } + return provider; + }); + notifyAvailable(type, provider.generation); + return registration(type, provider); + } + + public CapabilityRegistration replace(FeatureId owner, Class type, T instance) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(instance, "instance"); + validateCapabilityType(type); + if (!type.isInstance(instance)) { + throw new IllegalArgumentException("Capability implementation does not implement " + type.getName()); + } + + Provider replacement = new Provider(owner, instance, generations.incrementAndGet(), drainTimeout); + configureProviderGeneration(instance, replacement.generation); + Withdrawal[] withdrawal = new Withdrawal[1]; + providers.compute(type, (ignored, current) -> { + if (current == null) { + throw new IllegalStateException("Capability " + type.getName() + " is not currently registered"); + } + if (!current.owner.equals(owner)) { + throw new IllegalStateException( + "Capability " + type.getName() + " is provided by another owner: " + current.owner + ); + } + withdrawal[0] = new Withdrawal(current, current.stopAccepting()); + return replacement; + }); + // The replacement is already visible. Do not throw a drain timeout here: doing so would + // strand the replacement without the registration its owner needs to withdraw it later. + withdrawal[0].provider().stopAndAwaitDrain(withdrawal[0].pending()); + notifyReplaced(type, withdrawal[0].provider().generation, replacement.generation); + return registration(type, replacement); + } + + private CapabilityRegistration registration(Class type, Provider provider) { + AtomicBoolean closed = new AtomicBoolean(); + return () -> { + if (closed.compareAndSet(false, true)) { + Withdrawal[] withdrawal = new Withdrawal[1]; + providers.compute(type, (ignored, current) -> { + if (current != provider) { + return current; + } + withdrawal[0] = new Withdrawal(provider, provider.stopAccepting()); + return null; + }); + if (withdrawal[0] != null) { + try { + ApiOperationException timeout = withdrawal[0].provider() + .stopAndAwaitDrain(withdrawal[0].pending()); + if (timeout != null) { + throw timeout; + } + } finally { + notifyUnavailable(type, withdrawal[0].provider().generation); + } + } + } + }; + } + + @Override + public CapabilityRef reference(Class type) { + Objects.requireNonNull(type, "type"); + validateCapabilityType(type); + CapabilityRef reference = references.computeIfAbsent(type, DefaultCapabilityRef::new); + return typeSafeReference(type, reference); + } + + @Override + public Set> availableTypes() { + return Set.copyOf(new LinkedHashSet<>(providers.keySet())); + } + + @Override + public AutoCloseable subscribe(CapabilityListener listener) { + Objects.requireNonNull(listener, "listener"); + listeners.add(listener); + return () -> listeners.remove(listener); + } + + private void notifyAvailable(Class type, long generation) { + listeners.forEach(listener -> safely(() -> listener.available(type, generation))); + } + private void notifyUnavailable(Class type, long generation) { + listeners.forEach(listener -> safely(() -> listener.unavailable(type, generation))); + } + private void notifyReplaced(Class type, long previous, long next) { + listeners.forEach(listener -> safely(() -> listener.replaced(type, previous, next))); + } + private static void safely(Runnable callback) { try { callback.run(); } catch (RuntimeException ignored) { } } + + private static void configureProviderGeneration(Object instance, long generation) { + if (instance instanceof CapabilityProviderGenerationAware generationAware) { + generationAware.providerGeneration(generation); + } + } + + public Optional owner(Class type) { + Provider provider = providers.get(Objects.requireNonNull(type, "type")); + return provider == null ? Optional.empty() : Optional.of(provider.owner); + } + + private static void validateCapabilityType(Class type) { + if (!type.isInterface()) { + throw new IllegalArgumentException("Capability contract must be an interface: " + type.getName()); + } + if (!type.getPackageName().startsWith("nl.hauntedmc.proxyfeatures.api.")) { + throw new IllegalArgumentException("Capability contract must come from proxyfeatures-api: " + type.getName()); + } + + Class canonicalType; + try { + canonicalType = Class.forName( + type.getName(), + false, + CapabilityRegistry.class.getClassLoader() + ); + } catch (ClassNotFoundException missingApiType) { + throw new IllegalArgumentException( + "Capability contract is not part of the active proxyfeatures-api: " + type.getName(), + missingApiType + ); + } + if (canonicalType != type) { + throw new IllegalArgumentException( + "Capability contract was loaded from a duplicate proxyfeatures-api copy: " + type.getName() + ); + } + } + + int cachedReferenceCount() { + return references.size(); + } + + private InvocationLease acquire(Class type) { + while (true) { + Provider provider = providers.get(type); + if (provider == null) { + throw new CapabilityUnavailableException(type); + } + InvocationLease lease = provider.tryAcquire(); + if (lease != null) { + return lease; + } + } + } + + private Optional resolveProxy(DefaultCapabilityRef reference) { + return providers.containsKey(reference.type) ? Optional.of(reference.proxy) : Optional.empty(); + } + + @SuppressWarnings("unchecked") + private static CapabilityRef typeSafeReference(Class type, CapabilityRef reference) { + if (reference.type() != type) { + throw new IllegalStateException("Capability reference type mismatch"); + } + return (CapabilityRef) reference; + } + + private final class DefaultCapabilityRef implements CapabilityRef { + private final Class type; + private final T proxy; + + private DefaultCapabilityRef(Class type) { + this.type = type; + this.proxy = type.cast(Proxy.newProxyInstance( + type.getClassLoader(), + new Class[]{type}, + this::invoke + )); + } + + @Override + public Class type() { + return type; + } + + @Override + public Optional get() { + return resolveProxy(this); + } + + @Override + public OptionalLong generation() { + Provider provider = providers.get(type); + return provider == null ? OptionalLong.empty() : OptionalLong.of(provider.generation); + } + + private Object invoke(Object proxyInstance, Method method, Object[] arguments) throws Throwable { + if (method.getDeclaringClass() == Object.class) { + return invokeObjectMethod(proxyInstance, method, arguments); + } + + InvocationLease lease = acquire(type); + boolean async = false; + try { + Object result; + try { + result = method.invoke(lease.instance(), arguments); + } catch (InvocationTargetException invocationFailure) { + throw invocationFailure.getCause(); + } + + if (result instanceof CompletionStage stage) { + AsyncInvocation invocation = new AsyncInvocation(type, lease); + if (lease.provider().track(invocation)) { + stage.whenComplete((value, failure) -> + lease.provider().completeAsync(invocation, value, failure)); + } else { + invocation.invalidate(); + } + async = true; + return invocation.result(); + } + return result; + } finally { + if (!async) { + lease.close(); + } + } + } + + private Object invokeObjectMethod(Object proxyInstance, Method method, Object[] arguments) { + return switch (method.getName()) { + case "equals" -> proxyInstance == arguments[0]; + case "hashCode" -> System.identityHashCode(proxyInstance); + case "toString" -> "CapabilityRefProxy[" + type.getName() + "]"; + default -> throw new IllegalStateException("Unsupported Object method: " + method); + }; + } + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultFeatureCatalog.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultFeatureCatalog.java new file mode 100644 index 00000000..b09dd332 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultFeatureCatalog.java @@ -0,0 +1,197 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.feature.FeatureCatalog; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureDescriptor; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureSnapshot; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureState; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureFailure; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureCatalogListener; + +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Thread-safe public projection of the runtime feature lifecycle. */ +public final class DefaultFeatureCatalog implements FeatureCatalog { + + private static final int MAX_FAILURE_MESSAGE_LENGTH = 160; + + private record Entry(FeatureDescriptor descriptor, boolean configuredEnabled, FeatureState state, + Optional failure, Optional failureDetail, + Set unavailableDependencies, Instant lastTransitionAt, + Optional lastSuccessfulActivationAt, long generation) { + } + + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + private final Clock clock; + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + + public DefaultFeatureCatalog() { + this(Clock.systemUTC()); + } + + DefaultFeatureCatalog(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public void register(FeatureDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor"); + Instant now = clock.instant(); + Entry entry = new Entry(descriptor, false, FeatureState.DISABLED, Optional.empty(), + Optional.empty(), Set.of(), now, Optional.empty(), 0L); + entries.put(descriptor.id(), entry); + notifyChanged(entry); + } + + public void setConfiguredEnabled(FeatureId id, boolean enabled) { + Entry[] previous = new Entry[1]; + Entry next = entries.compute(requireKnown(id), (ignored, entry) -> { + previous[0] = requireEntry(id, entry); + if (entry.configuredEnabled() == enabled) { + return entry; + } + return new Entry(entry.descriptor(), enabled, entry.state(), entry.failure(), entry.failureDetail(), + entry.unavailableDependencies(), entry.lastTransitionAt(), entry.lastSuccessfulActivationAt(), + entry.generation() + 1); + }); + if (next != previous[0]) { + notifyChanged(next); + } + } + + /** Updates unavailable feature prerequisites without conflating them with disabled configuration. */ + public void setUnavailableDependencies(FeatureId id, Set unavailableDependencies) { + Set normalized = unavailableDependencies == null ? Set.of() : Set.copyOf(unavailableDependencies); + Entry[] previous = new Entry[1]; + Entry next = entries.compute(requireKnown(id), (ignored, entry) -> { + previous[0] = requireEntry(id, entry); + if (entry.unavailableDependencies().equals(normalized)) { + return entry; + } + return new Entry(entry.descriptor(), entry.configuredEnabled(), entry.state(), entry.failure(), + entry.failureDetail(), normalized, entry.lastTransitionAt(), entry.lastSuccessfulActivationAt(), + entry.generation() + 1); + }); + if (next != previous[0]) { + notifyChanged(next); + } + } + + public void transition(FeatureId id, FeatureState state) { + transition(id, state, Optional.empty()); + } + + public void fail(FeatureId id, Throwable failure) { + fail(id, "lifecycle", failure); + } + + /** Records a stable lifecycle phase so API consumers can distinguish startup from cleanup failures. */ + public void fail(FeatureId id, String phase, Throwable failure) { + Objects.requireNonNull(phase, "phase"); + Objects.requireNonNull(failure, "failure"); + String message = failure.getMessage(); + String safe = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + safe = safe.length() > MAX_FAILURE_MESSAGE_LENGTH + ? safe.substring(0, MAX_FAILURE_MESSAGE_LENGTH) + : safe; + transition(id, FeatureState.FAILED, Optional.of(safe), + Optional.of(new FeatureFailure(normalizePhase(phase), failure.getClass().getSimpleName(), Optional.of(safe)))); + } + + @Override + public Optional find(FeatureId id) { + Entry entry = entries.get(Objects.requireNonNull(id, "id")); + return entry == null ? Optional.empty() : Optional.of(snapshot(entry, clock.instant())); + } + + @Override + public List snapshot() { + Instant observedAt = clock.instant(); + return entries.values().stream() + .map(entry -> snapshot(entry, observedAt)) + .sorted(Comparator.comparing(value -> value.descriptor().id())) + .toList(); + } + + @Override + public AutoCloseable subscribe(FeatureCatalogListener listener) { + Objects.requireNonNull(listener, "listener"); + listeners.add(listener); + return () -> listeners.remove(listener); + } + + private void transition(FeatureId id, FeatureState state, Optional failure) { + transition(id, state, failure, Optional.empty()); + } + + private void transition(FeatureId id, FeatureState state, Optional failure, + Optional failureDetail) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(state, "state"); + Entry next = entries.compute(id, (ignored, current) -> { + if (current == null) { + throw new IllegalArgumentException("Unknown feature: " + id); + } + if (!isAllowed(current.state(), state)) { + throw new IllegalStateException("Invalid feature state transition: " + current.state() + " -> " + state); + } + Instant now = clock.instant(); + Optional activated = state == FeatureState.ACTIVE ? Optional.of(now) : current.lastSuccessfulActivationAt(); + return new Entry(current.descriptor(), current.configuredEnabled(), state, failure, failureDetail, + current.unavailableDependencies(), now, activated, current.generation() + 1); + }); + notifyChanged(next); + } + + private static FeatureSnapshot snapshot(Entry entry, Instant observedAt) { + return new FeatureSnapshot(entry.descriptor(), entry.configuredEnabled(), entry.state(), entry.failure(), + entry.failureDetail(), entry.unavailableDependencies(), entry.lastTransitionAt(), + entry.lastSuccessfulActivationAt(), entry.generation(), observedAt); + } + + private void notifyChanged(Entry entry) { + FeatureSnapshot snapshot = snapshot(entry, clock.instant()); + listeners.forEach(listener -> { + try { + listener.stateChanged(snapshot); + } catch (RuntimeException ignored) { + // Listener isolation is required for lifecycle progress. + } + }); + } + + private static String normalizePhase(String phase) { + String normalized = phase.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("phase must not be blank"); + } + return normalized; + } + + private static FeatureId requireKnown(FeatureId id) { return Objects.requireNonNull(id, "id"); } + + private static Entry requireEntry(FeatureId id, Entry entry) { + if (entry == null) { + throw new IllegalArgumentException("Unknown feature: " + id); + } + return entry; + } + + private static boolean isAllowed(FeatureState from, FeatureState to) { + if (from == to) return true; + return switch (from) { + case DISABLED -> to == FeatureState.STARTING || to == FeatureState.FAILED; + case STARTING -> to == FeatureState.ACTIVE || to == FeatureState.FAILED || to == FeatureState.STOPPING; + case ACTIVE -> to == FeatureState.STOPPING || to == FeatureState.FAILED; + case STOPPING -> to == FeatureState.DISABLED || to == FeatureState.FAILED; + case FAILED -> to == FeatureState.STARTING || to == FeatureState.DISABLED; + }; + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/FeatureServices.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/FeatureServices.java deleted file mode 100644 index 6bcec00c..00000000 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/FeatureServices.java +++ /dev/null @@ -1,58 +0,0 @@ -package nl.hauntedmc.proxyfeatures.framework.service; - -import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; - -import java.util.Objects; -import java.util.Optional; - -/** - * Resolves APIs exported by enabled features through DataRegistryApi's shared service catalog. - */ -public final class FeatureServices { - - private FeatureServices() { - } - - /** - * Finds an enabled feature service by API type. - */ - public static Optional find(ProxyFeatures plugin, Class apiType) { - Objects.requireNonNull(plugin, "plugin"); - Objects.requireNonNull(apiType, "apiType"); - return plugin.getDataRegistry() - .flatMap(dataRegistry -> dataRegistry.featureServices().find(apiType)); - } - - /** - * Resolves a required enabled feature service by API type. - * - * @throws IllegalStateException when DataRegistryApi is unavailable or the owning feature did not publish the API. - */ - public static T require(ProxyFeatures plugin, Class apiType) { - return find(plugin, apiType).orElseThrow(() -> missing(apiType)); - } - - /** - * Finds an enabled feature service by API type. - */ - public static Optional find(VelocityBaseFeature feature, Class apiType) { - Objects.requireNonNull(feature, "feature"); - return find(feature.getPlugin(), apiType); - } - - /** - * Resolves a required enabled feature service by API type. - * - * @throws IllegalStateException when DataRegistryApi is unavailable or the owning feature did not publish the API. - */ - public static T require(VelocityBaseFeature feature, Class apiType) { - Objects.requireNonNull(feature, "feature"); - return require(feature.getPlugin(), apiType); - } - - private static IllegalStateException missing(Class apiType) { - Objects.requireNonNull(apiType, "apiType"); - return new IllegalStateException("Feature service is not available: " + apiType.getName() + "."); - } -} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/InternalServiceRegistry.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/InternalServiceRegistry.java new file mode 100644 index 00000000..69cb3dd8 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/framework/service/InternalServiceRegistry.java @@ -0,0 +1,80 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Runtime-only registry for collaboration ports that must never be exposed as public API. */ +public final class InternalServiceRegistry { + private record Provider(FeatureId owner, Object instance) { + } + + private final ConcurrentHashMap, Provider> providers = new ConcurrentHashMap<>(); + + public CapabilityRegistration register(FeatureId owner, Class type, T instance) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(instance, "instance"); + if (!type.isInterface()) { + throw new IllegalArgumentException("Internal service contract must be an interface: " + type.getName()); + } + if (!type.isInstance(instance)) { + throw new IllegalArgumentException("Service implementation does not implement " + type.getName()); + } + Provider provider = new Provider(owner, instance); + providers.compute(type, (ignored, current) -> { + if (current != null) { + throw new IllegalStateException(type.getName() + " is already provided by " + current.owner()); + } + return provider; + }); + return registration(type, provider); + } + + public CapabilityRegistration replace(FeatureId owner, Class type, T instance) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(instance, "instance"); + if (!type.isInterface()) { + throw new IllegalArgumentException("Internal service contract must be an interface: " + type.getName()); + } + if (!type.isInstance(instance)) { + throw new IllegalArgumentException("Service implementation does not implement " + type.getName()); + } + + Provider replacement = new Provider(owner, instance); + providers.compute(type, (ignored, current) -> { + if (current == null) { + throw new IllegalStateException(type.getName() + " is not currently registered"); + } + if (!current.owner().equals(owner)) { + throw new IllegalStateException(type.getName() + " is provided by another owner: " + current.owner()); + } + return replacement; + }); + return registration(type, replacement); + } + + private CapabilityRegistration registration(Class type, Provider provider) { + AtomicBoolean closed = new AtomicBoolean(); + return () -> { + if (closed.compareAndSet(false, true)) { + providers.remove(type, provider); + } + }; + } + + public Optional find(Class type) { + Provider provider = providers.get(Objects.requireNonNull(type, "type")); + return provider == null ? Optional.empty() : Optional.of(type.cast(provider.instance())); + } + + public T require(Class type) { + return find(type).orElseThrow(() -> new IllegalStateException( + "Internal feature service is unavailable: " + type.getName() + )); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/resources/db/migration/V2__proxyfeatures_scalar_player_ids.sql b/proxyfeatures-platform-velocity/src/main/resources/db/migration/V2__proxyfeatures_scalar_player_ids.sql deleted file mode 100644 index c4104e29..00000000 --- a/proxyfeatures-platform-velocity/src/main/resources/db/migration/V2__proxyfeatures_scalar_player_ids.sql +++ /dev/null @@ -1,45 +0,0 @@ --- ProxyFeatures 2.11 / DataRegistry 1.11 compatibility migration (MySQL 8). --- Existing player-id values remain unchanged. Feature tables retain only those scalar ids; --- player names and UUIDs are resolved through DataRegistry's public API at read time. - -DELIMITER $$ -CREATE PROCEDURE proxyfeatures_drop_player_entity_foreign_keys() -BEGIN - DECLARE finished INTEGER DEFAULT 0; - DECLARE feature_table VARCHAR(64); - DECLARE foreign_key_name VARCHAR(64); - DECLARE foreign_keys CURSOR FOR - SELECT DISTINCT kcu.TABLE_NAME, kcu.CONSTRAINT_NAME - FROM information_schema.KEY_COLUMN_USAGE kcu - WHERE kcu.CONSTRAINT_SCHEMA = DATABASE() - AND kcu.REFERENCED_TABLE_NAME = 'player_entity' - AND kcu.TABLE_NAME IN ( - 'player_announcer_settings', 'player_antibot_logs', 'player_antivpn_logs', - 'player_clientinfo', 'player_clientinfo_channels', 'player_clientinfo_mods', - 'player_clientinfo_settings', 'player_command_executions', 'player_friend_settings', - 'player_friends', 'player_message_blocks', 'player_message_logs', - 'player_message_settings', 'player_sanctions', 'player_sanctions_security_logs', - 'player_twofactor_accounts', 'player_twofactor_logs', 'player_version_logs', - 'player_vote_monthly', 'player_vote_stats' - ); - DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished = 1; - - OPEN foreign_keys; - drop_loop: LOOP - FETCH foreign_keys INTO feature_table, foreign_key_name; - IF finished = 1 THEN - LEAVE drop_loop; - END IF; - SET @drop_fk = CONCAT( - 'ALTER TABLE `', REPLACE(feature_table, '`', '``'), - '` DROP FOREIGN KEY `', REPLACE(foreign_key_name, '`', '``'), '`' - ); - PREPARE statement FROM @drop_fk; - EXECUTE statement; - DEALLOCATE PREPARE statement; - END LOOP; - CLOSE foreign_keys; -END$$ -CALL proxyfeatures_drop_player_entity_foreign_keys()$$ -DROP PROCEDURE proxyfeatures_drop_player_entity_foreign_keys$$ -DELIMITER ; diff --git a/proxyfeatures-platform-velocity/src/main/resources/db/migration/V3__messenger_message_mode.sql b/proxyfeatures-platform-velocity/src/main/resources/db/migration/V3__messenger_message_mode.sql deleted file mode 100644 index 3a69513e..00000000 --- a/proxyfeatures-platform-velocity/src/main/resources/db/migration/V3__messenger_message_mode.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Add persisted Messenger privacy mode without imposing a database-level default. --- Existing rows remain NULL and are repaired to Messenger.default_message_mode --- when their settings are first loaded. -ALTER TABLE player_message_settings - ADD COLUMN message_mode VARCHAR(32) NULL; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/ProxyFeaturesRuntimeTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/ProxyFeaturesRuntimeTest.java new file mode 100644 index 00000000..7713639b --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/ProxyFeaturesRuntimeTest.java @@ -0,0 +1,117 @@ +package nl.hauntedmc.proxyfeatures; + +import com.velocitypowered.api.event.proxy.ProxyReloadEvent; +import com.velocitypowered.api.proxy.ProxyServer; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesApiVersion; +import nl.hauntedmc.proxyfeatures.framework.config.MainConfigHandler; +import nl.hauntedmc.proxyfeatures.framework.loader.FeatureLoadManager; +import nl.hauntedmc.proxyfeatures.framework.loader.FeatureRegistry; +import nl.hauntedmc.proxyfeatures.framework.loader.reload.FeatureReloadResponse; +import nl.hauntedmc.proxyfeatures.framework.loader.reload.FeatureReloadResult; +import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProxyFeaturesRuntimeTest { + + @TempDir + Path tempDir; + + @Test + void versionReportsSemanticApiAndRuntimeImplementationSeparately() { + ProxyFeatures plugin = plugin(); + + ProxyFeaturesApiVersion version = plugin.version(); + + assertEquals(ProxyFeaturesApiVersion.CURRENT, version.apiVersion()); + assertEquals("3.3.0", version.implementationVersion()); + } + + @Test + void globalReloadUsesTransactionalFeatureReloadAndResetsProviderCaches() throws Exception { + ProxyFeatures plugin = plugin(); + MainConfigHandler mainConfig = mock(MainConfigHandler.class); + LocalizationHandler localization = mock(LocalizationHandler.class); + FeatureLoadManager loadManager = mock(FeatureLoadManager.class); + FeatureRegistry registry = mock(FeatureRegistry.class); + PlayerReferenceResolver resolver = mock(PlayerReferenceResolver.class); + + when(loadManager.getFeatureRegistry()).thenReturn(registry); + when(registry.getLoadedFeatureNames()).thenReturn(Set.of("Vanish")); + when(registry.isFeatureLoaded("Vanish")).thenReturn(true); + when(registry.getAvailableFeatures()).thenReturn(Map.of()); + when(mainConfig.isFeatureEnabled("Vanish")).thenReturn(true); + when(loadManager.reloadFeature("Vanish")).thenReturn(new FeatureReloadResponse( + FeatureReloadResult.SUCCESS, + "Vanish", + Set.of() + )); + + set(plugin, "mainConfigHandler", mainConfig); + set(plugin, "localizationHandler", localization); + set(plugin, "featureLoadManager", loadManager); + set(plugin, "playerReferenceResolver", resolver); + + plugin.onProxyReload(mock(ProxyReloadEvent.class)); + + verify(mainConfig).reloadConfig(); + verify(localization).reloadLocalization(); + verify(loadManager).reloadFeature("Vanish"); + verify(loadManager, never()).unloadAllFeatures(); + assertNull(get(plugin, "playerReferenceResolver")); + } + + @Test + void invalidSharedConfigurationAbortsBeforeTouchingRuntime() throws Exception { + ProxyFeatures plugin = plugin(); + MainConfigHandler mainConfig = mock(MainConfigHandler.class); + LocalizationHandler localization = mock(LocalizationHandler.class); + FeatureLoadManager loadManager = mock(FeatureLoadManager.class); + doThrow(new IllegalStateException("invalid yaml")).when(mainConfig).reloadConfig(); + + set(plugin, "mainConfigHandler", mainConfig); + set(plugin, "localizationHandler", localization); + set(plugin, "featureLoadManager", loadManager); + + plugin.onProxyReload(mock(ProxyReloadEvent.class)); + + verify(loadManager, never()).unloadAllFeatures(); + verify(loadManager, never()).reloadFeature(org.mockito.ArgumentMatchers.anyString()); + verify(localization, never()).reloadLocalization(); + } + + private ProxyFeatures plugin() { + return new ProxyFeatures( + mock(ProxyServer.class), + ComponentLogger.logger("ProxyFeaturesRuntimeTest"), + tempDir + ); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static Object get(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/architecture/ArchitectureBoundaryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/architecture/ArchitectureBoundaryTest.java new file mode 100644 index 00000000..3ed9357d --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/architecture/ArchitectureBoundaryTest.java @@ -0,0 +1,173 @@ +package nl.hauntedmc.proxyfeatures.architecture; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ArchitectureBoundaryTest { + + private static final Path MODULE = Path.of("").toAbsolutePath().normalize(); + private static final Path REPOSITORY = MODULE.getParent(); + private static final Pattern FEATURE_IMPORT = Pattern.compile( + "import\\s+(?:static\\s+)?nl\\.hauntedmc\\.proxyfeatures\\.features\\.([a-z0-9]+)\\." + ); + + @Test + void featureImplementationsDoNotImportOtherFeatureImplementations() throws IOException { + Path featureRoot = MODULE.resolve( + "src/main/java/nl/hauntedmc/proxyfeatures/features" + ); + List violations = new ArrayList<>(); + + for (Path source : javaSources(featureRoot)) { + Path relative = featureRoot.relativize(source); + if (relative.getNameCount() < 2) { + continue; + } + String ownerFeature = relative.getName(0).toString(); + Matcher imports = FEATURE_IMPORT.matcher(Files.readString(source)); + while (imports.find()) { + String importedFeature = imports.group(1); + if (!ownerFeature.equals(importedFeature)) { + violations.add(relative + " imports feature " + importedFeature); + } + } + } + + assertEquals(List.of(), violations, + "Cross-feature behavior must use public capabilities or runtime collaboration ports"); + } + + @Test + void publicApiHasNoPlatformToolkitOrThirdPartyDependencies() throws IOException { + Path apiRoot = REPOSITORY.resolve("proxyfeatures-api/src/main/java"); + List violations = new ArrayList<>(); + + for (Path source : javaSources(apiRoot)) { + for (String line : Files.readAllLines(source)) { + String trimmed = line.trim(); + if (!trimmed.startsWith("import ")) { + continue; + } + if (!trimmed.startsWith("import java.") + && !trimmed.startsWith("import nl.hauntedmc.proxyfeatures.api.")) { + violations.add(apiRoot.relativize(source) + ": " + trimmed); + } + } + } + + assertEquals(List.of(), violations, "proxyfeatures-api must remain dependency-free"); + String apiPom = Files.readString(REPOSITORY.resolve("proxyfeatures-api/pom.xml")); + assertFalse(apiPom.contains("compile")); + assertFalse(apiPom.contains("provided")); + } + + @Test + void contractsContainOnlyWireMessagesAndNoRuntimePersistenceTypes() throws IOException { + Path contractsRoot = REPOSITORY.resolve("proxyfeatures-contracts/src/main/java"); + List sources = javaSources(contractsRoot); + + assertEquals(7, sources.size()); + for (Path source : sources) { + String relative = contractsRoot.relativize(source).toString(); + String text = Files.readString(source); + assertTrue(relative.contains("/messaging/"), relative); + assertFalse(text.contains("jakarta.persistence"), relative); + assertFalse(text.contains("proxyfeatures.features"), relative); + assertFalse(text.contains("proxyfeatures.framework"), relative); + } + } + + @Test + void obsoleteDiscoveryAndApiSystemsCannotReturn() throws IOException { + Set forbidden = Set.of( + "io.github.classgraph", + "ProxyFeaturesContext", + "FeatureServiceDirectory", + "RestartAdmissionAPI", + "BaseMeta", + "FeatureFactory", + "FeatureServices", + "InternalServices", + "DefaultProxyFeaturesApi" + ); + List violations = new ArrayList<>(); + + for (Path source : javaSources(MODULE.resolve("src/main/java"))) { + String text = Files.readString(source); + for (String token : forbidden) { + if (Pattern.compile("\\b" + Pattern.quote(token) + "\\b").matcher(text).find()) { + violations.add(MODULE.relativize(source) + " contains " + token); + } + } + } + + assertEquals(List.of(), violations); + } + + @Test + void frameworkDoesNotDependOnFeatureImplementationsOutsideCompositionRoot() throws IOException { + Path frameworkRoot = MODULE.resolve("src/main/java/nl/hauntedmc/proxyfeatures/framework"); + List violations = new ArrayList<>(); + + for (Path source : javaSources(frameworkRoot)) { + if (source.getFileName().toString().equals("BuiltInFeatures.java")) { + continue; + } + String text = Files.readString(source); + if (text.contains("nl.hauntedmc.proxyfeatures.features.")) { + violations.add(MODULE.relativize(source) + " depends on a feature implementation"); + } + } + + assertEquals(List.of(), violations, + "Only the built-in composition root may import concrete feature implementations"); + } + + @Test + void obsoleteMetadataAndGenericUtilityPackagesAreAbsent() throws IOException { + Path featureRoot = MODULE.resolve("src/main/java/nl/hauntedmc/proxyfeatures/features"); + List violations = new ArrayList<>(); + + try (Stream paths = Files.walk(featureRoot)) { + paths.filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().equals("meta")) + .forEach(path -> violations.add("obsolete feature metadata directory: " + path)); + } + + for (Path module : List.of( + REPOSITORY.resolve("proxyfeatures-toolkit/src/main/java"), + MODULE.resolve("src/main/java") + )) { + for (Path source : javaSources(module)) { + String text = Files.readString(source); + if (text.contains("nl.hauntedmc.proxyfeatures.toolkit.util")) { + violations.add(REPOSITORY.relativize(source) + " imports generic toolkit.util"); + } + } + } + + assertEquals(List.of(), violations); + } + + private static List javaSources(Path root) throws IOException { + try (Stream paths = Files.walk(root)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .sorted() + .toList(); + } + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/architecture/PlatformAcceptanceContractTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/architecture/PlatformAcceptanceContractTest.java new file mode 100644 index 00000000..d834ebd6 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/architecture/PlatformAcceptanceContractTest.java @@ -0,0 +1,47 @@ +package nl.hauntedmc.proxyfeatures.architecture; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlatformAcceptanceContractTest { + + @Test + void acceptanceExercisesSoftReloadBeforeLifecycleReload() throws IOException { + Path repository = Path.of("").toAbsolutePath().normalize().getParent(); + Path script = repository.resolve("proxyfeatures-platform-acceptance/run-platform-acceptance.sh"); + String content = Files.readString(script); + + int softReload = content.indexOf("printf 'capacity reload\\n'"); + int lifecycleReload = content.indexOf("printf 'proxyfeatures reload Capacity\\n'"); + int capabilityPass = content.indexOf("PROXYFEATURES_ACCEPTANCE_PASS platform=velocity"); + + assertTrue(softReload >= 0, "Acceptance must exercise Capacity's in-place config reload"); + assertTrue(lifecycleReload > softReload, + "Acceptance must exercise the framework lifecycle reload after the soft reload"); + assertTrue(capabilityPass > lifecycleReload, + "Capability replacement must be verified after the lifecycle reload"); + } + + @Test + void acceptanceUsesMavenRepositoryConfiguredByRuntimePom() throws IOException { + Path repository = Path.of("").toAbsolutePath().normalize().getParent(); + String script = Files.readString( + repository.resolve("proxyfeatures-platform-acceptance/run-platform-acceptance.sh") + ); + String runtimePom = Files.readString( + repository.resolve("proxyfeatures-platform-acceptance/runtime/pom.xml") + ); + + assertTrue(runtimePom.contains( + "${settings.localRepository}" + )); + assertTrue(script.contains("${MAVEN_REPO_LOCAL:?")); + assertFalse(script.contains("MAVEN_REPOSITORY")); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/FeatureFactoryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/FeatureFactoryTest.java deleted file mode 100644 index 3d2c986f..00000000 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/FeatureFactoryTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features; - -import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.commandhider.meta.Meta; -import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; -import nl.hauntedmc.proxyfeatures.framework.config.MainConfigHandler; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureApiManager; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureCacheManager; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureCommandManager; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureDataManager; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureListenerManager; -import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; -import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; -import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; -import nl.hauntedmc.proxyfeatures.testutil.ComponentLoggerRecorder; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class FeatureFactoryTest { - - @Test - void createFeatureReturnsNullAndLogsWhenClassIsMissing() { - ProxyFeatures plugin = mock(ProxyFeatures.class); - ComponentLoggerRecorder loggerRecorder = ComponentLoggerRecorder.create(); - when(plugin.getLogger()).thenReturn(loggerRecorder.logger()); - FeatureContext context = mockContext(plugin); - - VelocityBaseFeature created = FeatureFactory.createFeature(null, context); - assertNull(created); - assertTrue(loggerRecorder.hasStringArgumentContaining("error", "missing feature class name")); - } - - @Test - void createFeatureReturnsNullWhenConstructorSignatureDoesNotMatch() { - ProxyFeatures plugin = mock(ProxyFeatures.class); - ComponentLoggerRecorder loggerRecorder = ComponentLoggerRecorder.create(); - when(plugin.getLogger()).thenReturn(loggerRecorder.logger()); - FeatureContext context = mockContext(plugin); - - VelocityBaseFeature created = FeatureFactory.createFeature(NoProxyCtorFeature.class.getName(), context); - assertNull(created); - assertTrue(loggerRecorder.hasStringArgumentContaining("error", "Failed to instantiate feature")); - } - - @Test - void createFeatureInstantiatesFeatureWhenConstructorMatches(@TempDir Path tempDir) { - ProxyFeatures plugin = mock(ProxyFeatures.class); - when(plugin.getLogger()).thenReturn(ComponentLoggerRecorder.create().logger()); - when(plugin.getDataDirectory()).thenReturn(tempDir); - ConfigService service = new ConfigService(tempDir, LoggerFactory.getLogger(FeatureFactoryTest.class), getClass().getClassLoader()); - MainConfigHandler mainConfig = new MainConfigHandler(plugin, service); - LocalizationHandler localization = new LocalizationHandler(plugin, service); - when(plugin.getConfigHandler()).thenReturn(mainConfig); - when(plugin.getLocalizationHandler()).thenReturn(localization); - FeatureContext context = new FeatureContext<>( - plugin, - new Meta(), - mainConfig.openFeatureConfig("CommandHider"), - mockLifecycleManager(), - new FeatureLogger(plugin.getLogger(), "CommandHider"), - localization.openFeatureLocalization("CommandHider") - ); - - VelocityBaseFeature created = FeatureFactory.createFeature(ValidCtorFeature.class.getName(), context); - assertNotNull(created); - assertInstanceOf(ValidCtorFeature.class, created); - } - - private FeatureContext mockContext(ProxyFeatures plugin) { - return new FeatureContext<>( - plugin, - new Meta(), - mock(FeatureConfigHandler.class), - mockLifecycleManager(), - mock(FeatureLogger.class), - mock(LocalizationHandler.class) - ); - } - - private FeatureLifecycleManager mockLifecycleManager() { - return new FeatureLifecycleManager( - mock(FeatureTaskManager.class), - mock(FeatureCommandManager.class), - mock(FeatureListenerManager.class), - mock(FeatureDataManager.class), - mock(FeatureCacheManager.class), - mock(FeatureApiManager.class) - ); - } - - private static final class NoProxyCtorFeature extends VelocityBaseFeature { - NoProxyCtorFeature() { - super(mockContext()); - } - - @Override - public ConfigMap getDefaultConfig() { - return new ConfigMap(); - } - - @Override - public MessageMap getDefaultMessages() { - return new MessageMap(); - } - - @SuppressWarnings("unchecked") // Mockito cannot preserve the generic type token at runtime. - private static FeatureContext mockContext() { - return mock(FeatureContext.class); - } - - @Override - public void initialize() { - } - - @Override - public void disable() { - } - } - - private static final class ValidCtorFeature extends VelocityBaseFeature { - ValidCtorFeature(FeatureContext context) { - super(context); - } - - @Override - public ConfigMap getDefaultConfig() { - return new ConfigMap(); - } - - @Override - public MessageMap getDefaultMessages() { - return new MessageMap(); - } - - @Override - public void initialize() { - } - - @Override - public void disable() { - } - } -} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeatureTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeatureTest.java index 3485ffef..a3b07d08 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeatureTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/VelocityBaseFeatureTest.java @@ -2,24 +2,22 @@ import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureApiManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; class VelocityBaseFeatureTest { @@ -29,9 +27,14 @@ void cleanupAlwaysRunsLifecycleCleanupWhenDisableFails() { when(plugin.getLogger()).thenReturn(ComponentLogger.logger("VelocityBaseFeatureTest")); FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); - TestFeature feature = new TestFeature(new FeatureContext<>( + FeatureApiManager apiManager = mock(FeatureApiManager.class); + when(lifecycle.getApiManager()).thenReturn(apiManager); + TestFeature feature = new TestFeature(new FeatureContext( plugin, - new TestMeta(), + "Queue", + "1.0", + List.of(), + List.of(), mock(FeatureConfigHandler.class), lifecycle, mock(FeatureLogger.class), @@ -51,12 +54,17 @@ void cleanupSuppressesLifecycleFailureBehindDisableFailure() { when(plugin.getLogger()).thenReturn(ComponentLogger.logger("VelocityBaseFeatureTest")); FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); + FeatureApiManager apiManager = mock(FeatureApiManager.class); + when(lifecycle.getApiManager()).thenReturn(apiManager); RuntimeException lifecycleFailure = new RuntimeException("lifecycle"); doThrow(lifecycleFailure).when(lifecycle).cleanup(); - TestFeature feature = new TestFeature(new FeatureContext<>( + TestFeature feature = new TestFeature(new FeatureContext( plugin, - new TestMeta(), + "Queue", + "1.0", + List.of(), + List.of(), mock(FeatureConfigHandler.class), lifecycle, mock(FeatureLogger.class), @@ -71,10 +79,47 @@ void cleanupSuppressesLifecycleFailureBehindDisableFailure() { assertSame(lifecycleFailure, thrown.getSuppressed()[0]); } - private static final class TestFeature extends VelocityBaseFeature { + @Test + void cleanupWithdrawsServicesBeforeFeatureDisable() { + ProxyFeatures plugin = mock(ProxyFeatures.class); + when(plugin.getLogger()).thenReturn(ComponentLogger.logger("VelocityBaseFeatureTest")); + + FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); + FeatureApiManager apiManager = mock(FeatureApiManager.class); + when(lifecycle.getApiManager()).thenReturn(apiManager); + + AtomicBoolean servicesDeactivated = new AtomicBoolean(); + doAnswer(ignored -> { + servicesDeactivated.set(true); + return null; + }).when(apiManager).deactivateServices(); + TestFeature feature = new TestFeature(new FeatureContext( + plugin, + "Queue", + "1.0", + List.of(), + List.of(), + mock(FeatureConfigHandler.class), + lifecycle, + mock(FeatureLogger.class), + mock(LocalizationHandler.class) + )); + feature.disableAction = () -> assertTrue(servicesDeactivated.get()); + + feature.cleanup(); + + var order = inOrder(apiManager, lifecycle); + order.verify(apiManager).deactivateServices(); + order.verify(lifecycle).cleanup(); + assertTrue(feature.disabled); + } + + private static final class TestFeature extends VelocityBaseFeature { private RuntimeException disableFailure; + private Runnable disableAction; + private boolean disabled; - private TestFeature(FeatureContext context) { + private TestFeature(FeatureContext context) { super(context); } @@ -94,31 +139,13 @@ public void initialize() { @Override public void disable() { + disabled = true; + if (disableAction != null) { + disableAction.run(); + } if (disableFailure != null) { throw disableFailure; } } } - - private static final class TestMeta implements BaseMeta { - @Override - public String getFeatureName() { - return "Queue"; - } - - @Override - public String getFeatureVersion() { - return "1.0"; - } - - @Override - public List getDependencies() { - return List.of(); - } - - @Override - public List getPluginDependencies() { - return List.of(); - } - } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsServiceTest.java index 82500c2b..8a2e71fd 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/announcer/internal/AnnouncerSettingsServiceTest.java @@ -195,7 +195,7 @@ private static PlayerReferenceResolver resolverReturning( PlayerReference player ) { PlayerReferenceResolver resolver = mock(PlayerReferenceResolver.class); - when(resolver.resolveManaged(session, uuid)).thenReturn(player); + when(resolver.resolveReference(uuid)).thenReturn(player); return resolver; } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/AntiVPNServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/AntiVPNServiceTest.java index 82e22bdd..9cf45385 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/AntiVPNServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/AntiVPNServiceTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.internal; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.PersistentIpCache.CacheHit; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.PersistentIpCache.Source; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryServiceTest.java index f4266b37..f511b071 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/CountryServiceTest.java @@ -6,6 +6,8 @@ import java.util.Optional; import java.util.UUID; +import nl.hauntedmc.proxyfeatures.api.model.CountryCode; + import static org.junit.jupiter.api.Assertions.*; class CountryServiceTest { @@ -18,10 +20,10 @@ void stagePromoteAndClearFlowWorks() { service.stageForUsername("Remy", "nl"); service.promoteToUuid("REMY", uuid); - assertEquals(Optional.of("NL"), service.getCountry(uuid)); + assertEquals(Optional.of(CountryCode.of("NL")), service.countryCode(uuid)); service.clear(uuid); - assertEquals(Optional.empty(), service.getCountry(uuid)); + assertEquals(Optional.empty(), service.countryCode(uuid)); } @Test @@ -35,6 +37,6 @@ void invalidInputsAreIgnoredWithoutThrowing() { service.promoteToUuid("x", null); service.clear(null); - assertTrue(service.getCountry(uuid).isEmpty()); + assertTrue(service.countryCode(uuid).isEmpty()); } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/IpWhitelistTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/IpWhitelistTest.java index 82d26117..888c026c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/IpWhitelistTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/IpWhitelistTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.internal; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/NotificationServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/NotificationServiceTest.java index 36acda30..b95da199 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/NotificationServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/NotificationServiceTest.java @@ -4,7 +4,7 @@ import com.velocitypowered.api.proxy.ProxyServer; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCacheTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCacheTest.java index 3a80745b..6bee1d96 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCacheTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/PersistentIpCacheTest.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.internal; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ProviderRegistryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ProviderRegistryTest.java index 3d2c6371..432e1206 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ProviderRegistryTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ProviderRegistryTest.java @@ -1,12 +1,12 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.IPCheckResult; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider.ip2location.IP2LocationProvider; import nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider.proxycheck.ProxyCheckProvider; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -15,7 +15,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.*; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ip2location/IP2LocationProviderTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ip2location/IP2LocationProviderTest.java index 8b6bb97b..f64ae86c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ip2location/IP2LocationProviderTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/ip2location/IP2LocationProviderTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider.ip2location; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/proxycheck/ProxyCheckProviderTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/proxycheck/ProxyCheckProviderTest.java index 0824ee14..5398eba1 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/proxycheck/ProxyCheckProviderTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/antivpn/internal/provider/proxycheck/ProxyCheckProviderTest.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.antivpn.internal.provider.proxycheck; import com.google.gson.JsonObject; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.antivpn.AntiVPN; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommandTest.java index 535fd262..18fb9322 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/broadcast/command/BroadcastProxyCommandTest.java @@ -7,7 +7,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.broadcast.Broadcast; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; @@ -78,6 +78,29 @@ void titleModeUsesClampedTimingsFromConfig() throws Exception { assertEquals(Duration.ZERO, times.fadeOut()); } + @Test + void titleModeReadsUpdatedTimingsWithoutCommandReconstruction() throws Exception { + when(config.node()).thenReturn( + ConfigNode.ofRaw(Map.of("title_stay", 20), "root"), + ConfigNode.ofRaw(Map.of("title_stay", 60), "root") + ); + CommandSource source = mock(CommandSource.class); + Player target = mock(Player.class); + when(source.hasPermission("proxyfeatures.feature.broadcast.command.broadcastproxy")).thenReturn(true); + when(proxy.getAllPlayers()).thenReturn(List.of(target)); + + BroadcastProxyCommand command = new BroadcastProxyCommand(feature); + CommandDispatcher dispatcher = new CommandDispatcher<>(); + dispatcher.getRoot().addChild(command.buildTree()); + dispatcher.execute("broadcastproxy title First", source); + dispatcher.execute("broadcastproxy title Second", source); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Title.class); + verify(target, times(2)).showTitle(captor.capture()); + assertEquals(Duration.ofSeconds(1), captor.getAllValues().get(0).times().stay()); + assertEquals(Duration.ofSeconds(3), captor.getAllValues().get(1).times().stay()); + } + @Test void rootCommandWithoutModeShowsUsage() throws Exception { when(config.node()).thenReturn(ConfigNode.ofRaw(Map.of(), "root")); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommandTest.java index 67c04b78..5ef7566f 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/command/CapacityCommandTest.java @@ -6,7 +6,7 @@ import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; import nl.hauntedmc.proxyfeatures.features.capacity.internal.CapacityControlPlane; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigurationTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigurationTest.java index 77fa0ba5..3527ad45 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigurationTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/config/CapacityConfigurationTest.java @@ -1,9 +1,9 @@ package nl.hauntedmc.proxyfeatures.features.capacity.config; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import org.junit.jupiter.api.Test; import java.time.Duration; @@ -11,10 +11,7 @@ import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/AdmissionCapabilityLeaseAdapterTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/AdmissionCapabilityLeaseAdapterTest.java new file mode 100644 index 00000000..fc687677 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/AdmissionCapabilityLeaseAdapterTest.java @@ -0,0 +1,58 @@ +package nl.hauntedmc.proxyfeatures.features.capacity.internal; + +import nl.hauntedmc.proxyfeatures.api.capability.admission.LeaseState; +import nl.hauntedmc.proxyfeatures.framework.admission.AdmissionIntent; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityLease; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityLeaseState; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class AdmissionCapabilityLeaseAdapterTest { + + @Test + void exposesExactTerminalLeaseStateAndProviderGeneration() { + TestCapacityLease delegate = new TestCapacityLease(); + AdmissionCapability.LeaseAdapter adapter = new AdmissionCapability.LeaseAdapter(delegate, 42L); + + assertTrue(adapter.isActive()); + assertEquals(LeaseState.COMMITTED, adapter.commit().state()); + assertEquals(LeaseState.COMMITTED, adapter.state()); + assertFalse(adapter.isActive()); + assertEquals(LeaseState.COMMITTED, adapter.release().state()); + assertEquals(42L, adapter.providerGeneration()); + } + + @Test + void preservesReleaseAndExpiryOutcomes() { + TestCapacityLease released = new TestCapacityLease(); + AdmissionCapability.LeaseAdapter releaseAdapter = new AdmissionCapability.LeaseAdapter(released, 7L); + assertEquals(LeaseState.RELEASED, releaseAdapter.release().state()); + assertEquals(LeaseState.RELEASED, releaseAdapter.release().state()); + + TestCapacityLease expired = new TestCapacityLease(); + expired.state.set(CapacityLeaseState.EXPIRED); + AdmissionCapability.LeaseAdapter expiredAdapter = new AdmissionCapability.LeaseAdapter(expired, 8L); + assertEquals(LeaseState.EXPIRED, expiredAdapter.commit().state()); + assertEquals(LeaseState.EXPIRED, expiredAdapter.release().state()); + } + + private static final class TestCapacityLease implements CapacityLease { + private final UUID id = UUID.randomUUID(); + private final AtomicReference<CapacityLeaseState> state = new AtomicReference<>(CapacityLeaseState.ACTIVE); + + @Override public UUID id() { return id; } + @Override public UUID playerId() { return id; } + @Override public String targetServer() { return "survival"; } + @Override public AdmissionIntent intent() { return AdmissionIntent.NORMAL; } + @Override public Instant expiresAt() { return Instant.MAX; } + @Override public boolean isActive() { return state() == CapacityLeaseState.ACTIVE; } + @Override public CapacityLeaseState state() { return state.get(); } + @Override public boolean commit() { return state.compareAndSet(CapacityLeaseState.ACTIVE, CapacityLeaseState.COMMITTED); } + @Override public boolean release() { return state.compareAndSet(CapacityLeaseState.ACTIVE, CapacityLeaseState.RELEASED); } + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityServiceTest.java index 26c6de2a..c963851a 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacityServiceTest.java @@ -5,28 +5,18 @@ import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.ServerInfo; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.capacity.AdmissionIntent; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDecision; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDenialReason; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityRequest; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; import nl.hauntedmc.proxyfeatures.features.capacity.Capacity; import nl.hauntedmc.proxyfeatures.features.capacity.config.CapacityConfig; +import nl.hauntedmc.proxyfeatures.framework.admission.*; +import nl.hauntedmc.proxyfeatures.framework.service.InternalServiceRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.time.Duration; import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -47,6 +37,7 @@ void setUp() { when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); + when(plugin.getInternalServiceRegistry()).thenReturn(new InternalServiceRegistry()); when(plugin.getDataRegistry()).thenReturn(Optional.empty()); when(survival.getServerInfo()).thenReturn(survivalInfo); when(survivalInfo.getName()).thenReturn("survival"); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisherTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisherTest.java index 90839dbb..4f12f4aa 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisherTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/capacity/internal/CapacitySnapshotPublisherTest.java @@ -1,8 +1,8 @@ package nl.hauntedmc.proxyfeatures.features.capacity.internal; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityScopeSnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacitySnapshot; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityScopeSnapshot; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacitySnapshot; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; import nl.hauntedmc.proxyfeatures.contracts.messaging.CapacitySnapshotMessage; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorNotifyPolicyTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorNotifyPolicyTest.java index cf5b237f..2c14fc90 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorNotifyPolicyTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorNotifyPolicyTest.java @@ -5,7 +5,7 @@ import com.velocitypowered.api.proxy.player.PlayerSettings; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.clientinfo.ClientInfo; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorOutputConfigTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorOutputConfigTest.java index 43d70cdb..91f56b89 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorOutputConfigTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoAdvisorOutputConfigTest.java @@ -5,8 +5,8 @@ import com.velocitypowered.api.proxy.ProxyServer; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigTypes; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigTypes; import nl.hauntedmc.proxyfeatures.features.clientinfo.ClientInfo; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfigTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfigTest.java index dcbbc7b4..f2a286be 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfigTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoConfigTest.java @@ -1,9 +1,9 @@ package nl.hauntedmc.proxyfeatures.features.clientinfo.internal; import com.velocitypowered.api.proxy.player.PlayerSettings; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigTypes; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigView; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigTypes; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigView; import org.junit.jupiter.api.Test; import java.util.List; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsServiceTest.java index cf26d1f3..1cc312b0 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/clientinfo/internal/ClientInfoSettingsServiceTest.java @@ -150,7 +150,7 @@ private static PlayerReferenceResolver resolverReturning( PlayerReference player ) { PlayerReferenceResolver resolver = mock(PlayerReferenceResolver.class); - when(resolver.resolveManaged(session, uuid)).thenReturn(player); + when(resolver.resolveReference(uuid)).thenReturn(player); return resolver; } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogServiceTest.java index e5756400..febf0241 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandlogger/service/CommandLogServiceTest.java @@ -5,6 +5,7 @@ import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.proxyfeatures.features.commandlogger.CommandLogger; import nl.hauntedmc.proxyfeatures.features.commandlogger.entity.CommandExecutionEntity; @@ -36,7 +37,9 @@ void logsConsoleSourceWithoutPlayerReference() { }); CommandSource source = mock(CommandSource.class); - CommandLogService service = new CommandLogService(feature, mock(PlayerDirectory.class)); + CommandLogService service = new CommandLogService( + feature, new PlayerReferenceResolver(mock(PlayerDirectory.class)) + ); service.logProxyCommand(source, "velocity info"); @SuppressWarnings("unchecked") @@ -70,7 +73,9 @@ void skipsPlayerCommandWhenIdentityIsUnavailable() { when(source.getUsername()).thenReturn("Remy"); when(playerDirectory.whenReady(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); - CommandLogService service = new CommandLogService(feature, playerDirectory); + CommandLogService service = new CommandLogService( + feature, new PlayerReferenceResolver(playerDirectory) + ); service.logProxyCommand(source, "say hi"); verify(orm, never()).runInTransaction(any()); @@ -111,7 +116,9 @@ void logsPlayerCommandWithExistingManagedPlayerReferenceWithoutUpdatingUsername( return null; }); - CommandLogService service = new CommandLogService(feature, playerDirectory); + CommandLogService service = new CommandLogService( + feature, new PlayerReferenceResolver(playerDirectory) + ); service.logProxyCommand(source, "list"); verify(session, never()).merge(existing); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandlerTest.java index 21296bc5..1757316b 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/EventBusHandlerTest.java @@ -10,7 +10,6 @@ import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableSubscription; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; import nl.hauntedmc.proxyfeatures.contracts.messaging.CommandRelayMessage; import nl.hauntedmc.proxyfeatures.features.commandrelay.CommandRelay; import nl.hauntedmc.proxyfeatures.features.commandrelay.audit.CommandRelayAuditLogService; @@ -18,6 +17,7 @@ import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -79,7 +79,7 @@ void completedReplayIsAcknowledgedWithoutExecutingAgain() { DurableMessagingDataAccess redis = mock(DurableMessagingDataAccess.class); CommandRelay feature = featureWithWhitelist(List.of("say")); FileCacheStore store = mock(FileCacheStore.class); - when(store.listAll()).thenReturn(Map.of("command.done", mock(nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue.class))); + when(store.listAll()).thenReturn(Map.of("command.done", mock(nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue.class))); Consumer<DurableDelivery<CommandRelayMessage>> consumer = installConsumer(redis); EventBusHandler handler = handler(feature, redis, store); @@ -322,7 +322,7 @@ private static CommandRelay featureWithWhitelist(List<String> whitelist) { when(feature.getLifecycleManager()).thenReturn(lifecycle); when(feature.getPlugin()).thenReturn(plugin); when(lifecycle.getTaskManager()).thenReturn(tasks); - when(config.get("command_whitelist")).thenReturn(whitelist); + when(config.getList("command_whitelist", String.class, List.of())).thenReturn(whitelist); when(plugin.getProxy()).thenReturn(proxy); return feature; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedgerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedgerTest.java index 3b464914..5e4490eb 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedgerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/commandrelay/internal/ProcessedCommandLedgerTest.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.commandrelay.internal; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommandTest.java index 1c6f24c9..e27e5346 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/command/ConnectionInfoCommandTest.java @@ -23,10 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class ConnectionInfoCommandTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/internal/SessionHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/internal/SessionHandlerTest.java index bbbccc85..69a7f25d 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/internal/SessionHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/connectioninfo/internal/SessionHandlerTest.java @@ -5,7 +5,8 @@ import java.time.Instant; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class SessionHandlerTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/FriendsDefaultsTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/FriendsDefaultsTest.java index 33c9e3e8..3656d545 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/FriendsDefaultsTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/FriendsDefaultsTest.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.friends; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; -import nl.hauntedmc.proxyfeatures.features.friends.meta.Meta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.loader.BuiltInFeatures; import org.junit.jupiter.api.Test; import java.util.Map; @@ -25,6 +25,10 @@ void vanishPresenceNotificationsAreEnabledByDefault() { @Test void featureVersionReflectsThePresenceNotificationUpdate() { - assertEquals("1.4.0", new Meta().getFeatureVersion()); + assertEquals("1.4.0", BuiltInFeatures.definitions().stream() + .filter(definition -> definition.implementationType() == Friends.class) + .findFirst() + .orElseThrow() + .featureVersion()); } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImplTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImplTest.java index 37270c12..1ff5c4d6 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImplTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/api/FriendshipApiImplTest.java @@ -12,9 +12,7 @@ import java.util.UUID; import java.util.concurrent.CompletionStage; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsServiceTest.java index 3f19ee53..8d99425d 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/friends/entity/FriendsServiceTest.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.proxyfeatures.features.friends.Friends; import nl.hauntedmc.proxyfeatures.features.friends.support.FriendsCache; import org.hibernate.Session; @@ -32,7 +33,9 @@ void getOrCreateSettingsPersistsUsingTheScalarPlayerId() { return loader.get(); }); when(session.find(FriendSettingsEntity.class, 10L)).thenReturn(null); - FriendsService service = new FriendsService(feature, cache, mock(PlayerDirectory.class)); + FriendsService service = new FriendsService( + feature, cache, new PlayerReferenceResolver(mock(PlayerDirectory.class)) + ); FriendSettingsEntity settings = service.getOrCreateSettings(new PlayerRef(10L, "uuid", "name")); assertNotNull(settings); @@ -52,7 +55,9 @@ void createPendingPersistsWithoutDataRegistryEntitiesInTheSession() { ORMContext.TransactionCallback<?> callback = invocation.getArgument(0); return callback.execute(session); }); - FriendsService service = new FriendsService(feature, cache, mock(PlayerDirectory.class)); + FriendsService service = new FriendsService( + feature, cache, new PlayerReferenceResolver(mock(PlayerDirectory.class)) + ); boolean created = service.createPending( new PlayerRef(1L, "uuid-1", "One"), new PlayerRef(2L, "uuid-2", "Two") diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListenerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListenerTest.java index 59593ee4..31e70a81 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListenerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/maintenance/listener/MaintenanceConnectionListenerTest.java @@ -8,12 +8,11 @@ import com.velocitypowered.api.proxy.server.ServerInfo; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; +import nl.hauntedmc.proxyfeatures.api.capability.operations.TwoFactorApi; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; import nl.hauntedmc.proxyfeatures.features.maintenance.Maintenance; import nl.hauntedmc.proxyfeatures.features.maintenance.internal.MaintenanceHandler; -import nl.hauntedmc.proxyfeatures.features.twofactor.TwoFactor; -import nl.hauntedmc.proxyfeatures.features.twofactor.service.TwoFactorService; -import nl.hauntedmc.proxyfeatures.framework.loader.FeatureLoadManager; -import nl.hauntedmc.proxyfeatures.framework.loader.FeatureRegistry; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,7 +26,7 @@ class MaintenanceConnectionListenerTest { private Maintenance feature; private MaintenanceHandler handler; - private FeatureRegistry featureRegistry; + private MutableCapabilityRegistry capabilities; private MaintenanceConnectionListener listener; @BeforeEach @@ -35,12 +34,10 @@ void setUp() { feature = mock(Maintenance.class); handler = mock(MaintenanceHandler.class); ProxyFeatures plugin = mock(ProxyFeatures.class); - FeatureLoadManager featureLoadManager = mock(FeatureLoadManager.class); - featureRegistry = mock(FeatureRegistry.class); + capabilities = new MutableCapabilityRegistry(); when(feature.getHandler()).thenReturn(handler); when(feature.getPlugin()).thenReturn(plugin); - when(plugin.getFeatureLoadManager()).thenReturn(featureLoadManager); - when(featureLoadManager.getFeatureRegistry()).thenReturn(featureRegistry); + when(plugin.capabilities()).thenReturn(capabilities); listener = new MaintenanceConnectionListener(feature); } @@ -164,17 +161,15 @@ void twoFactorSecurityRouteIsNeverOverriddenByMaintenance() { RegisteredServer survival = server("survival"); RegisteredServer auth = server("auth"); ServerPreConnectEvent event = mock(ServerPreConnectEvent.class); - TwoFactor twoFactor = mock(TwoFactor.class); - TwoFactorService twoFactorService = mock(TwoFactorService.class); + TwoFactorApi twoFactor = mock(TwoFactorApi.class); when(event.getOriginalServer()).thenReturn(survival); when(event.getResult()).thenReturn(ServerPreConnectEvent.ServerResult.allowed(auth)); when(event.getPreviousServer()).thenReturn(null); when(event.getPlayer()).thenReturn(player); - doReturn(twoFactor).when(featureRegistry).getLoadedFeature("TwoFactor"); - when(twoFactor.getService()).thenReturn(twoFactorService); - when(twoFactorService.isLocked(player)).thenReturn(true); - when(twoFactor.isLockServer("auth")).thenReturn(true); + capabilities.register(TwoFactorApi.class, twoFactor); + when(twoFactor.isLocked(player.getUniqueId())).thenReturn(true); + when(twoFactor.isAuthenticationServer(ServerId.of("auth"))).thenReturn(true); when(handler.normalizeGamemodeName("auth")).thenReturn("auth"); when(handler.isGamemodeActive("auth")).thenReturn(true); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingModeCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingModeCommandTest.java index fbb68bd9..469486e6 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingModeCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingModeCommandTest.java @@ -6,9 +6,9 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessagingHandler; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -19,10 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class MessagingModeCommandTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingVanishCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingVanishCommandTest.java index 240bbe40..3335883c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingVanishCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/command/MessagingVanishCommandTest.java @@ -4,12 +4,12 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import net.kyori.adventure.text.Component; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.internal.MessagingHandler; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -31,7 +31,7 @@ class MessagingVanishCommandTest { private ProxyServer proxy; private MessagingHandler handler; private LocalizationHandler localization; - private VanishAPI vanishApi; + private PresenceApi vanishApi; private Player sender; private Player hiddenStaff; private UUID hiddenStaffId; @@ -44,8 +44,8 @@ void setUp() { handler = mock(MessagingHandler.class); localization = mock(LocalizationHandler.class); LocalizationHandler.MessageBuilder builder = mock(LocalizationHandler.MessageBuilder.class); - DataRegistryApi dataRegistry = mock(DataRegistryApi.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); - vanishApi = mock(VanishAPI.class); + MutableCapabilityRegistry capabilities = new MutableCapabilityRegistry(); + vanishApi = mock(PresenceApi.class); sender = mock(Player.class); hiddenStaff = mock(Player.class); hiddenStaffId = UUID.randomUUID(); @@ -54,9 +54,8 @@ void setUp() { when(feature.getHandler()).thenReturn(handler); when(feature.getLocalizationHandler()).thenReturn(localization); when(plugin.getProxy()).thenReturn(proxy); - when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); - when(dataRegistry.featureServices().find(VanishAPI.class)) - .thenReturn(Optional.of(vanishApi)); + when(plugin.capabilities()).thenReturn(capabilities); + capabilities.register(PresenceApi.class, vanishApi); when(localization.getMessage(anyString())).thenReturn(builder); when(builder.with(anyString(), anyString())).thenReturn(builder); when(builder.forAudience(any())).thenReturn(builder); @@ -69,8 +68,8 @@ void setUp() { when(hiddenStaff.getUsername()).thenReturn("HiddenStaff"); when(proxy.getPlayer("HiddenStaff")).thenReturn(Optional.of(hiddenStaff)); when(proxy.getPlayer(hiddenStaffId)).thenReturn(Optional.of(hiddenStaff)); - when(vanishApi.isVanished(hiddenStaffId)).thenReturn(true); - when(vanishApi.getAdjustedOnlinePlayers()).thenReturn(List.of()); + when(vanishApi.isHidden(hiddenStaffId)).thenReturn(true); + when(proxy.getAllPlayers()).thenReturn(List.of()); } @Test diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverterTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverterTest.java index 99c072e5..385fdea4 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverterTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/MessageModeConverterTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.messager.entity; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntityTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntityTest.java index ae6825f7..abc25366 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntityTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/entity/PlayerMessageSettingsEntityTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.messager.entity; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ class PlayerMessageSettingsEntityTest { @Test - void constructorLeavesModeAvailableForConfiguredDefault() { + void constructorUsesSafeNonNullDefault() { PlayerReference player = mock(PlayerReference.class); when(player.getId()).thenReturn(42L); @@ -19,9 +19,7 @@ void constructorLeavesModeAvailableForConfiguredDefault() { assertTrue(settings.isMsgToggle()); assertFalse(settings.isMsgSpy()); - assertTrue(settings.getStoredMessageMode().isEmpty()); - assertEquals(MessageMode.ALL, settings.getMessageMode(MessageMode.ALL)); - assertEquals(MessageMode.FRIENDS, settings.getMessageMode(MessageMode.FRIENDS)); + assertEquals(MessageMode.FRIENDS, settings.getMessageMode()); assertTrue(settings.getBlockedPlayerIds().isEmpty()); } @@ -55,16 +53,15 @@ void toggleSpyAndModeCanBeUpdated() { assertFalse(settings.isMsgToggle()); assertTrue(settings.isMsgSpy()); - assertEquals(MessageMode.ALL, settings.getStoredMessageMode().orElseThrow()); - assertEquals(MessageMode.ALL, settings.getMessageMode(MessageMode.FRIENDS)); + assertEquals(MessageMode.ALL, settings.getMessageMode()); } @Test - void noArgConstructorSupportsConfigurableFallbackState() { + void noArgConstructorUsesSafeNonNullDefault() { PlayerMessageSettingsEntity settings = new PlayerMessageSettingsEntity(); assertTrue(settings.isMsgToggle()); assertFalse(settings.isMsgSpy()); - assertTrue(settings.getStoredMessageMode().isEmpty()); + assertEquals(MessageMode.FRIENDS, settings.getMessageMode()); assertTrue(settings.getBlockedPlayerIds().isEmpty()); } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicyTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicyTest.java index c224902b..96036134 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicyTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagePrivacyPolicyTest.java @@ -1,15 +1,10 @@ package nl.hauntedmc.proxyfeatures.features.messager.internal; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import org.junit.jupiter.api.Test; -import static nl.hauntedmc.proxyfeatures.features.messager.internal.MessagePrivacyPolicy.Decision.ALLOW; -import static nl.hauntedmc.proxyfeatures.features.messager.internal.MessagePrivacyPolicy.Decision.BOTH_RESTRICTED; -import static nl.hauntedmc.proxyfeatures.features.messager.internal.MessagePrivacyPolicy.Decision.RECEIVER_RESTRICTED; -import static nl.hauntedmc.proxyfeatures.features.messager.internal.MessagePrivacyPolicy.Decision.SENDER_RESTRICTED; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static nl.hauntedmc.proxyfeatures.features.messager.internal.MessagePrivacyPolicy.Decision.*; +import static org.junit.jupiter.api.Assertions.*; class MessagePrivacyPolicyTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingFriendServiceAvailabilityTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingFriendServiceAvailabilityTest.java index 00321a73..b84c30e3 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingFriendServiceAvailabilityTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingFriendServiceAvailabilityTest.java @@ -10,13 +10,14 @@ import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.friends.FriendshipApi; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; import nl.hauntedmc.proxyfeatures.features.messager.history.PlayerMessageHistoryLogService; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.Session; import org.hibernate.query.Query; import org.junit.jupiter.api.Test; @@ -156,6 +157,7 @@ private static Fixture fixture( when(feature.getDefaultMessageMode()).thenReturn(MessageMode.FRIENDS); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(directory)); when(plugin.getLogger()).thenReturn(logger); when(dataRegistry.players()).thenReturn(playerData); when(playerData.identities()).thenReturn(directory); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerTest.java index a0bd9ee7..604f929e 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerTest.java @@ -12,12 +12,13 @@ import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.dataregistry.api.player.PlayerLookup; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.friends.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; import nl.hauntedmc.proxyfeatures.features.messager.history.PlayerMessageHistoryLogService; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.Session; import org.hibernate.query.Query; import org.junit.jupiter.api.Test; @@ -56,6 +57,7 @@ void blockDoesNotThrowWhenTargetPlayerReferenceIsMissing() { when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(playerDirectory)); when(dataRegistry.players()).thenReturn(players); when(players.identities()).thenReturn(playerDirectory); when(proxy.getAllPlayers()).thenReturn(List.of()); @@ -97,6 +99,7 @@ void toggleMessagingLoadsPersistedStateBeforeFlipping() { when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(playerDirectory)); when(dataRegistry.players()).thenReturn(players); when(players.identities()).thenReturn(playerDirectory); when(proxy.getAllPlayers()).thenReturn(List.of()); @@ -157,6 +160,7 @@ void deliveredReplyLogsHistoryAndDropsUnauthorizedSpy() { when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(playerDirectory)); when(dataRegistry.players()).thenReturn(players); when(players.identities()).thenReturn(playerDirectory); when(proxy.getAllPlayers()).thenReturn(List.of()); @@ -251,6 +255,7 @@ void offlineBlockedPlayerCanBeSuggestedResolvedAndUnblocked() { when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(playerDirectory)); when(dataRegistry.players()).thenReturn(players); when(players.identities()).thenReturn(playerDirectory); when(feature.getOrmContext()).thenReturn(orm); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerVanishVisibilityTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerVanishVisibilityTest.java index b564c6b4..5b6996c4 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerVanishVisibilityTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingHandlerVanishVisibilityTest.java @@ -4,13 +4,13 @@ import com.velocitypowered.api.proxy.ProxyServer; import net.kyori.adventure.text.Component; import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.friends.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.history.PlayerMessageHistoryLogService; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import org.junit.jupiter.api.Test; import java.util.Optional; @@ -32,12 +32,12 @@ void rawHandlerCallCannotDeliverToVanishedTarget() { Messenger feature = mock(Messenger.class); ProxyFeatures plugin = mock(ProxyFeatures.class); ProxyServer proxy = mock(ProxyServer.class); - DataRegistryApi dataRegistry = mock(DataRegistryApi.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + MutableCapabilityRegistry capabilities = new MutableCapabilityRegistry(); ORMContext orm = mock(ORMContext.class); PlayerMessageHistoryLogService history = mock(PlayerMessageHistoryLogService.class); LocalizationHandler localization = mock(LocalizationHandler.class); LocalizationHandler.MessageBuilder builder = mock(LocalizationHandler.MessageBuilder.class); - VanishAPI vanishApi = mock(VanishAPI.class); + PresenceApi vanishApi = mock(PresenceApi.class); Player sender = mock(Player.class); Player hiddenStaff = mock(Player.class); @SuppressWarnings("unchecked") @@ -50,16 +50,15 @@ void rawHandlerCallCannotDeliverToVanishedTarget() { when(feature.getMessageHistoryLogService()).thenReturn(history); when(feature.getLocalizationHandler()).thenReturn(localization); when(plugin.getProxy()).thenReturn(proxy); - when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); - when(dataRegistry.featureServices().find(VanishAPI.class)) - .thenReturn(Optional.of(vanishApi)); + when(plugin.capabilities()).thenReturn(capabilities); + capabilities.register(PresenceApi.class, vanishApi); when(localization.getMessage(anyString())).thenReturn(builder); when(builder.forAudience(any())).thenReturn(builder); when(builder.build()).thenReturn(Component.text("offline")); when(sender.getUniqueId()).thenReturn(senderId); when(hiddenStaff.getUniqueId()).thenReturn(hiddenStaffId); when(proxy.getPlayer(hiddenStaffId)).thenReturn(Optional.of(hiddenStaff)); - when(vanishApi.isVanished(hiddenStaffId)).thenReturn(true); + when(vanishApi.isHidden(hiddenStaffId)).thenReturn(true); MessagingHandler handler = new MessagingHandler(feature, friendshipResolver); handler.processPrivateMessage(sender, hiddenStaff, "Hello"); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingPlayerStateCleanupTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingPlayerStateCleanupTest.java index c63d3240..90f16489 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingPlayerStateCleanupTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingPlayerStateCleanupTest.java @@ -8,11 +8,12 @@ import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; import nl.hauntedmc.proxyfeatures.features.messager.history.PlayerMessageHistoryLogService; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.Session; import org.hibernate.query.Query; import org.junit.jupiter.api.Test; @@ -23,10 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class MessagingPlayerStateCleanupTest { @@ -59,6 +57,7 @@ void unloadingPlayerForcesPersistedPrivacyModeToReload() { when(feature.getDefaultMessageMode()).thenReturn(MessageMode.FRIENDS); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(directory)); when(dataRegistry.players()).thenReturn(playerData); when(playerData.identities()).thenReturn(directory); when(player.getUniqueId()).thenReturn(playerId); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsServiceTest.java index 43237362..01d7874e 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessagingSettingsServiceTest.java @@ -3,10 +3,11 @@ import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; -import nl.hauntedmc.proxyfeatures.api.messaging.MessageMode; +import nl.hauntedmc.proxyfeatures.features.messager.model.MessageMode; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; import nl.hauntedmc.proxyfeatures.features.messager.entity.PlayerMessageSettingsEntity; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.Session; import org.hibernate.query.Query; import org.junit.jupiter.api.Test; @@ -48,45 +49,14 @@ void loadSettingsKeepsKnownPersistedMode() { when(query.setParameter("pid", 10L)).thenReturn(query); when(query.uniqueResultOptional()).thenReturn(Optional.of(settings)); - PlayerMessageSettingsEntity loaded = new MessagingSettingsService(feature, directory) - .loadSettings(uuid, "Remy"); + PlayerMessageSettingsEntity loaded = service(feature, directory) + .loadSettings(uuid); assertSame(settings, loaded); - assertEquals(MessageMode.ALL, loaded.getStoredMessageMode().orElseThrow()); + assertEquals(MessageMode.ALL, loaded.getMessageMode()); verify(session, never()).merge(settings); } - @Test - void loadSettingsRepairsUnknownModeWithConfiguredDefault() { - Messenger feature = mock(Messenger.class); - ORMContext orm = transactionalOrm(feature); - PlayerDirectory directory = mock(PlayerDirectory.class); - Session session = session(orm); - @SuppressWarnings("unchecked") - Query<PlayerMessageSettingsEntity> query = mock(Query.class); - - UUID uuid = UUID.randomUUID(); - PlayerReference player = new PlayerReference(11L, uuid.toString(), "Remy"); - PlayerMessageSettingsEntity settings = new PlayerMessageSettingsEntity(player); - - when(feature.getDefaultMessageMode()).thenReturn(MessageMode.ALL); - when(directory.findActiveIdentityCached(uuid)) - .thenReturn(Optional.of(new PlayerIdentity(11L, uuid, "Remy"))); - when(session.getReference(PlayerReference.class, 11L)).thenReturn(player); - when(session.createQuery( - "FROM PlayerMessageSettingsEntity s WHERE s.playerId = :pid", - PlayerMessageSettingsEntity.class - )).thenReturn(query); - when(query.setParameter("pid", 11L)).thenReturn(query); - when(query.uniqueResultOptional()).thenReturn(Optional.of(settings)); - - PlayerMessageSettingsEntity loaded = new MessagingSettingsService(feature, directory) - .loadSettings(uuid, "Remy"); - - assertEquals(MessageMode.ALL, loaded.getStoredMessageMode().orElseThrow()); - verify(session).merge(settings); - } - @Test void loadSettingsCreatesNewRowWithConfiguredDefault() { Messenger feature = mock(Messenger.class); @@ -109,10 +79,10 @@ void loadSettingsCreatesNewRowWithConfiguredDefault() { when(query.setParameter("pid", 12L)).thenReturn(query); when(query.uniqueResultOptional()).thenReturn(Optional.empty()); - PlayerMessageSettingsEntity loaded = new MessagingSettingsService(feature, directory) - .loadSettings(uuid, "NewPlayer"); + PlayerMessageSettingsEntity loaded = service(feature, directory) + .loadSettings(uuid); - assertEquals(MessageMode.ALL, loaded.getStoredMessageMode().orElseThrow()); + assertEquals(MessageMode.ALL, loaded.getMessageMode()); verify(session).persist(loaded); } @@ -126,14 +96,14 @@ void toggleSpyAndModeMutationsUpdateSettingsInTransaction() { settings.setMessageMode(MessageMode.FRIENDS); when(session.find(PlayerMessageSettingsEntity.class, 20L)).thenReturn(settings); - MessagingSettingsService service = new MessagingSettingsService(feature, mock(PlayerDirectory.class)); + MessagingSettingsService service = service(feature, mock(PlayerDirectory.class)); service.setToggle(me, false); service.setSpy(me, true); service.setMode(me, MessageMode.ALL); assertFalse(settings.isMsgToggle()); assertTrue(settings.isMsgSpy()); - assertEquals(MessageMode.ALL, settings.getStoredMessageMode().orElseThrow()); + assertEquals(MessageMode.ALL, settings.getMessageMode()); verify(session, times(3)).merge(settings); } @@ -146,12 +116,12 @@ void newRowsCreatedByOtherMutationsReceiveConfiguredDefault() { when(feature.getDefaultMessageMode()).thenReturn(MessageMode.ALL); when(session.find(PlayerMessageSettingsEntity.class, 21L)).thenReturn(null); - new MessagingSettingsService(feature, mock(PlayerDirectory.class)).setToggle(me, false); + service(feature, mock(PlayerDirectory.class)).setToggle(me, false); ArgumentCaptor<PlayerMessageSettingsEntity> captor = ArgumentCaptor.forClass(PlayerMessageSettingsEntity.class); verify(session).persist(captor.capture()); - assertEquals(MessageMode.ALL, captor.getValue().getStoredMessageMode().orElseThrow()); + assertEquals(MessageMode.ALL, captor.getValue().getMessageMode()); verify(session).merge(captor.getValue()); } @@ -166,7 +136,7 @@ void blockAndUnblockMutationsManageBlockSet() { settings.setMessageMode(MessageMode.FRIENDS); when(session.find(PlayerMessageSettingsEntity.class, 1L)).thenReturn(settings); - MessagingSettingsService service = new MessagingSettingsService(feature, mock(PlayerDirectory.class)); + MessagingSettingsService service = service(feature, mock(PlayerDirectory.class)); service.block(me, target); assertTrue(settings.isBlocking(target)); service.unblock(me, target); @@ -184,7 +154,7 @@ void findPlayerReferenceQueriesPersistenceForOfflineUsername() { when(directory.findByIdentifier("offlinealice")) .thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); - Optional<PlayerReference> found = new MessagingSettingsService(feature, directory) + Optional<PlayerReference> found = service(feature, directory) .findPlayerReference("offlinealice") .toCompletableFuture() .join(); @@ -213,11 +183,11 @@ void missingPlayerGetsTransientConfiguredDefaultWithoutPersistence() { when(playerQuery.setMaxResults(1)).thenReturn(playerQuery); when(playerQuery.uniqueResultOptional()).thenReturn(Optional.empty()); - PlayerMessageSettingsEntity loaded = new MessagingSettingsService(feature, directory) - .loadSettings(uuid, "Remy"); + PlayerMessageSettingsEntity loaded = service(feature, directory) + .loadSettings(uuid); assertTrue(loaded.isMsgToggle()); - assertEquals(MessageMode.ALL, loaded.getStoredMessageMode().orElseThrow()); + assertEquals(MessageMode.ALL, loaded.getMessageMode()); verify(session, never()).persist(any(PlayerReference.class)); verify(session, never()).persist(any(PlayerMessageSettingsEntity.class)); } @@ -228,6 +198,10 @@ private static ORMContext transactionalOrm(Messenger feature) { return orm; } + private static MessagingSettingsService service(Messenger feature, PlayerDirectory directory) { + return new MessagingSettingsService(feature, new PlayerReferenceResolver(directory)); + } + private static Session session(ORMContext orm) { Session session = mock(Session.class); when(orm.runInTransaction(any())).thenAnswer(invocation -> { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibilityTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibilityTest.java index 85deb0ad..8383024f 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibilityTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/internal/MessengerTargetVisibilityTest.java @@ -2,10 +2,10 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.proxyfeatures.ProxyFeatures; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import nl.hauntedmc.proxyfeatures.features.messager.Messenger; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,18 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class MessengerTargetVisibilityTest { private Messenger feature; private ProxyFeatures plugin; private ProxyServer proxy; - private DataRegistryApi dataRegistry; - private VanishAPI vanishApi; + private MutableCapabilityRegistry capabilities; + private PresenceApi vanishApi; private Player viewer; private Player target; private UUID targetId; @@ -36,17 +33,16 @@ void setUp() { feature = mock(Messenger.class); plugin = mock(ProxyFeatures.class); proxy = mock(ProxyServer.class); - dataRegistry = mock(DataRegistryApi.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); - vanishApi = mock(VanishAPI.class); + capabilities = new MutableCapabilityRegistry(); + vanishApi = mock(PresenceApi.class); viewer = mock(Player.class); target = mock(Player.class); targetId = UUID.randomUUID(); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); - when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); - when(dataRegistry.featureServices().find(VanishAPI.class)) - .thenReturn(Optional.of(vanishApi)); + when(plugin.capabilities()).thenReturn(capabilities); + capabilities.register(PresenceApi.class, vanishApi); when(target.getUniqueId()).thenReturn(targetId); when(proxy.getPlayer(targetId)).thenReturn(Optional.of(target)); when(proxy.getPlayer("HiddenStaff")).thenReturn(Optional.of(target)); @@ -54,7 +50,7 @@ void setUp() { @Test void normalPlayerCannotResolveVanishedTargetByNameOrUuid() { - when(vanishApi.isVanished(targetId)).thenReturn(true); + when(vanishApi.isHidden(targetId)).thenReturn(true); assertTrue(MessengerTargetVisibility.find(feature, viewer, "HiddenStaff").isEmpty()); assertTrue(MessengerTargetVisibility.find(feature, viewer, targetId).isEmpty()); @@ -66,13 +62,14 @@ void vanishBypassCanResolveVanishedTarget() { assertEquals(target, MessengerTargetVisibility.find(feature, viewer, "HiddenStaff").orElseThrow()); assertEquals(target, MessengerTargetVisibility.find(feature, viewer, targetId).orElseThrow()); - verify(vanishApi, never()).isVanished(targetId); + verify(vanishApi, never()).isHidden(targetId); } @Test void suggestionsExcludeVanishedPlayersForNormalViewer() { Player visible = mock(Player.class); - when(vanishApi.getAdjustedOnlinePlayers()).thenReturn(List.of(visible)); + when(proxy.getAllPlayers()).thenReturn(List.of(visible)); + when(visible.getUniqueId()).thenReturn(UUID.randomUUID()); assertEquals(List.of(visible), MessengerTargetVisibility.list(feature, viewer)); } @@ -83,6 +80,5 @@ void suggestionsIncludeAllPlayersForVanishBypass() { when(proxy.getAllPlayers()).thenReturn(List.of(target)); assertEquals(List.of(target), MessengerTargetVisibility.list(feature, viewer)); - verify(vanishApi, never()).getAdjustedOnlinePlayers(); } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/listener/PlayerListenerDisconnectTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/listener/PlayerListenerDisconnectTest.java index 033bd24f..99ee44f8 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/listener/PlayerListenerDisconnectTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/listener/PlayerListenerDisconnectTest.java @@ -8,9 +8,7 @@ import java.util.UUID; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class PlayerListenerDisconnectTest { diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/messaging/MessageModeTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/model/MessageModeTest.java similarity index 91% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/messaging/MessageModeTest.java rename to proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/model/MessageModeTest.java index 78c51111..d6df90ca 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/messaging/MessageModeTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/messager/model/MessageModeTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.messaging; +package nl.hauntedmc.proxyfeatures.features.messager.model; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandlerTest.java index 35418608..11658510 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdHandlerTest.java @@ -4,19 +4,20 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceDirectory; -import nl.hauntedmc.proxyfeatures.test.TestFeatureServiceDirectory; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; import nl.hauntedmc.proxyfeatures.framework.feature.FeatureScopeFactory; +import nl.hauntedmc.proxyfeatures.framework.loader.FeatureDescriptor; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleFactory; import nl.hauntedmc.proxyfeatures.features.motd.Motd; -import nl.hauntedmc.proxyfeatures.features.motd.meta.Meta; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContext; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContribution; +import nl.hauntedmc.proxyfeatures.framework.extension.DefaultMotdExtensions; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import nl.hauntedmc.proxyfeatures.framework.config.MainConfigHandler; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.slf4j.LoggerFactory; @@ -25,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -40,11 +42,6 @@ class MotdHandlerTest { @TempDir Path tempDir; - @AfterEach - void tearDown() { - MotdLine2OverrideRegistry.clear(); - } - @Test void modifyServerPingAppliesMultiplierAndVanishAdjustment() { Motd feature = feature(Map.of( @@ -55,12 +52,12 @@ void modifyServerPingAppliesMultiplierAndVanishAdjustment() { )); MotdHandler handler = new MotdHandler(feature); - VanishAPI vanishApi = mock(VanishAPI.class); - when(vanishApi.getVanishedCount()).thenReturn(2); + PresenceApi vanishApi = mock(PresenceApi.class); + when(vanishApi.snapshot()).thenReturn(presenceWithHidden(2)); registerVanishApi(feature, vanishApi); ServerPing original = ping(10, 100); - ServerPing updated = handler.modifyServerPing(original); + ServerPing updated = handler.modifyServerPing(original, context()); ServerPing.Players players = updated.getPlayers().orElseThrow(); assertEquals(16, players.getOnline()); @@ -77,12 +74,12 @@ void modifyServerPingClampsNegativeMultiplierToZero() { )); MotdHandler handler = new MotdHandler(feature); - VanishAPI vanishApi = mock(VanishAPI.class); - when(vanishApi.getVanishedCount()).thenReturn(0); + PresenceApi vanishApi = mock(PresenceApi.class); + when(vanishApi.snapshot()).thenReturn(presenceWithHidden(0)); registerVanishApi(feature, vanishApi); ServerPing original = ping(5, 20); - ServerPing updated = handler.modifyServerPing(original); + ServerPing updated = handler.modifyServerPing(original, context()); ServerPing.Players players = updated.getPlayers().orElseThrow(); assertEquals(0, players.getOnline()); @@ -99,11 +96,11 @@ void modifyServerPingResolvesStaticLine2Placeholders() { )); MotdHandler handler = new MotdHandler(feature); - VanishAPI vanishApi = mock(VanishAPI.class); - when(vanishApi.getVanishedCount()).thenReturn(2); + PresenceApi vanishApi = mock(PresenceApi.class); + when(vanishApi.snapshot()).thenReturn(presenceWithHidden(2)); registerVanishApi(feature, vanishApi); - ServerPing updated = handler.modifyServerPing(ping(10, 100)); + ServerPing updated = handler.modifyServerPing(ping(10, 100), context()); String description = PLAIN.serialize(updated.getDescriptionComponent()); assertTrue(description.contains("Header haunted")); @@ -120,8 +117,8 @@ void modifyServerPingCyclesConfiguredLine2MessagesSequentially() { )); MotdHandler handler = new MotdHandler(feature); - String first = PLAIN.serialize(handler.modifyServerPing(ping(10, 100)).getDescriptionComponent()); - String second = PLAIN.serialize(handler.modifyServerPing(ping(10, 100)).getDescriptionComponent()); + String first = PLAIN.serialize(handler.modifyServerPing(ping(10, 100), context()).getDescriptionComponent()); + String second = PLAIN.serialize(handler.modifyServerPing(ping(10, 100), context()).getDescriptionComponent()); assertTrue(first.contains("First status")); assertTrue(second.contains("Second status")); @@ -138,7 +135,7 @@ void modifyServerPingFallsBackToStaticLineWhenRandomWordsAreUnavailable() { )); MotdHandler handler = new MotdHandler(feature); - String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100)).getDescriptionComponent()); + String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100), context()).getDescriptionComponent()); assertTrue(description.contains("Static fallback")); } @@ -156,7 +153,7 @@ void modifyServerPingUsesConfiguredRandomWordSeparatorAndSuffix() { )); MotdHandler handler = new MotdHandler(feature); - String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100)).getDescriptionComponent()); + String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100), context()).getDescriptionComponent()); assertTrue(description.contains("Alpha + Beta + Tail") || description.contains("Beta + Alpha + Tail")); } @@ -173,7 +170,7 @@ void modifyServerPingCentersRandomWordLineByDefault() { )); MotdHandler handler = new MotdHandler(feature); - String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100)).getDescriptionComponent()); + String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100), context()).getDescriptionComponent()); String line2 = description.substring(description.indexOf('\n') + 1); int leadingSpaces = line2.length() - line2.stripLeading().length(); @@ -189,9 +186,13 @@ void modifyServerPingUsesRegisteredLine2OverrideBeforeConfiguredLine2() { "line2_static", "Normal line" )); MotdHandler handler = new MotdHandler(feature); - MotdLine2OverrideRegistry.register("test-maintenance", 100, () -> "Maintenance line"); + feature.getPlugin().getMotdExtensions().register( + "test-maintenance", + 100, + ignored -> Optional.of(MotdContribution.secondLine("Maintenance line")) + ); - String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100)).getDescriptionComponent()); + String description = PLAIN.serialize(handler.modifyServerPing(ping(10, 100), context()).getDescriptionComponent()); assertTrue(description.contains("Maintenance line")); assertTrue(!description.contains("Normal line")); @@ -199,12 +200,11 @@ void modifyServerPingUsesRegisteredLine2OverrideBeforeConfiguredLine2() { private Motd feature(Map<String, Object> settings) { ProxyFeatures plugin = mock(ProxyFeatures.class, RETURNS_DEEP_STUBS); - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - FeatureServiceDirectory featureServices = new TestFeatureServiceDirectory(); + MutableCapabilityRegistry featureServices = new MutableCapabilityRegistry(); when(plugin.getDataDirectory()).thenReturn(tempDir); when(plugin.getLogger()).thenReturn(ComponentLogger.logger("MotdHandlerTest")); - when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); - when(dataRegistry.featureServices()).thenReturn(featureServices); + when(plugin.capabilities()).thenReturn(featureServices); + when(plugin.getMotdExtensions()).thenReturn(new DefaultMotdExtensions()); when(plugin.getFeatureLoadManager().getFeatureRegistry().isFeatureLoaded("VersionCheck")) .thenReturn(false); ConfigService service = new ConfigService(tempDir, LoggerFactory.getLogger(MotdHandlerTest.class), getClass().getClassLoader()); @@ -219,19 +219,28 @@ private Motd feature(Map<String, Object> settings) { new FeatureLifecycleFactory(plugin) ); - Motd feature = new Motd(featureScopeFactory.createContext(new Meta())); + Motd feature = new Motd(featureScopeFactory.createContext(new FeatureDescriptor( + "Motd", "Motd", "1.2.0", Motd.class, Motd::new, Set.of(), Set.of() + ))); feature.getConfigHandler().globals().put("server_name", "haunted"); settings.forEach((key, value) -> feature.getConfigHandler().put(key, value)); return feature; } - private static void registerVanishApi(Motd feature, VanishAPI vanishApi) { - feature.getPlugin().getDataRegistry().orElseThrow().featureServices().register( - "ProxyFeatures", - "Vanish", - VanishAPI.class, - vanishApi - ); + private static void registerVanishApi(Motd feature, PresenceApi vanishApi) { + ((MutableCapabilityRegistry) feature.getPlugin().capabilities()) + .register(PresenceApi.class, vanishApi); + } + + private static PresenceSnapshot presenceWithHidden(int count) { + java.util.Set<java.util.UUID> players = java.util.stream.IntStream.range(0, count) + .mapToObj(ignored -> java.util.UUID.randomUUID()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return new PresenceSnapshot(players, players, java.time.Instant.now()); + } + + private static MotdContext context() { + return new MotdContext(java.net.InetAddress.getLoopbackAddress(), 763); } private static ServerPing ping(int online, int max) { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdLine2OverrideRegistryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdLine2OverrideRegistryTest.java deleted file mode 100644 index 60e5315e..00000000 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/motd/internal/MotdLine2OverrideRegistryTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.motd.internal; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class MotdLine2OverrideRegistryTest { - - @AfterEach - void tearDown() { - MotdLine2OverrideRegistry.clear(); - } - - @Test - void resolveRegistrationPrefersHighestPriority() { - MotdLine2OverrideRegistry.register("low", 10, () -> "Low"); - MotdLine2OverrideRegistry.register("high", 100, () -> "High"); - - MotdLine2OverrideRegistry.ResolvedOverride resolved = MotdLine2OverrideRegistry.resolveRegistration().orElseThrow(); - - assertEquals("high", resolved.key()); - assertEquals(100, resolved.priority()); - assertEquals("High", resolved.value()); - } - - @Test - void resolveRegistrationUsesDeterministicKeyOrderWhenPriorityMatches() { - MotdLine2OverrideRegistry.register("zeta", 50, () -> "Zeta"); - MotdLine2OverrideRegistry.register("alpha", 50, () -> "Alpha"); - - MotdLine2OverrideRegistry.ResolvedOverride resolved = MotdLine2OverrideRegistry.resolveRegistration().orElseThrow(); - - assertEquals("alpha", resolved.key()); - assertEquals("Alpha", resolved.value()); - } - - @Test - void resolveSkipsBlankOrFailingSuppliers() { - MotdLine2OverrideRegistry.register("broken", 100, () -> { - throw new IllegalStateException("boom"); - }); - MotdLine2OverrideRegistry.register("blank", 90, () -> " "); - MotdLine2OverrideRegistry.register("usable", 10, () -> "Ready"); - - assertEquals("Ready", MotdLine2OverrideRegistry.resolve().orElseThrow()); - } - - @Test - void unregisterRemovesEntry() { - MotdLine2OverrideRegistry.register("maintenance", 100, () -> "Maintenance"); - MotdLine2OverrideRegistry.unregister("maintenance"); - - assertTrue(MotdLine2OverrideRegistry.resolve().isEmpty()); - } -} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisherTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisherTest.java index 75118dea..63e821d4 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisherTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountPublisherTest.java @@ -1,5 +1,9 @@ package nl.hauntedmc.proxyfeatures.features.playercount.internal; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCounts; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; + import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; import nl.hauntedmc.proxyfeatures.contracts.messaging.PlayerCountSnapshotMessage; import nl.hauntedmc.proxyfeatures.features.playercount.PlayerCount; @@ -69,7 +73,7 @@ void suppressesOverlappingPublishesAndResumesAfterCompletion() { void releasesInFlightGuardAfterSnapshotPreparationFailure() { Fixture fixture = fixture(); PlayerCountSnapshot validSnapshot = snapshot(); - doReturn(null).doReturn(validSnapshot).when(fixture.api()).capture(); + doReturn(null).doReturn(validSnapshot).when(fixture.api()).snapshot(); when(fixture.redisBus().publish(eq("counts"), any(PlayerCountSnapshotMessage.class))) .thenReturn(CompletableFuture.completedFuture(null)); @@ -100,7 +104,7 @@ void closePreventsNewPublishes() { fixture.publisher().close(); fixture.publisher().publishNow(); - verify(fixture.api(), never()).capture(); + verify(fixture.api(), never()).snapshot(); verify(fixture.redisBus(), never()) .publish(eq("counts"), any(PlayerCountSnapshotMessage.class)); } @@ -109,10 +113,10 @@ private static Fixture fixture() { PlayerCount feature = mock(PlayerCount.class); FeatureLogger logger = mock(FeatureLogger.class); MessagingDataAccess redisBus = mock(MessagingDataAccess.class); - PlayerCountAPI api = mock(PlayerCountAPI.class); + PlayerCountService api = mock(PlayerCountService.class); PlayerCountSnapshot defaultSnapshot = snapshot(); when(feature.getLogger()).thenReturn(logger); - when(api.capture()).thenReturn(defaultSnapshot); + when(api.snapshot()).thenReturn(defaultSnapshot); return new Fixture( redisBus, api, @@ -122,14 +126,15 @@ private static Fixture fixture() { private static PlayerCountSnapshot snapshot() { return new PlayerCountSnapshot( - new PlayerCountSnapshot.Counts(7, 2), - Map.of("survival", new PlayerCountSnapshot.Counts(5, 1)) + new PlayerCounts(7, 2), + Map.of(ServerId.of("survival"), new PlayerCounts(5, 1)), + java.time.Instant.now() ); } private record Fixture( MessagingDataAccess redisBus, - PlayerCountAPI api, + PlayerCountService api, PlayerCountPublisher publisher ) { } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountAPITest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountServiceTest.java similarity index 66% rename from proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountAPITest.java rename to proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountServiceTest.java index 3ee4d578..859ae777 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountAPITest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountServiceTest.java @@ -5,7 +5,11 @@ import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.ServerInfo; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCountSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.network.PlayerCounts; +import nl.hauntedmc.proxyfeatures.api.model.ServerId; import org.junit.jupiter.api.Test; import java.util.List; @@ -17,12 +21,12 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -class PlayerCountAPITest { +class PlayerCountServiceTest { @Test void capturesNetworkAndPerServerCountsIncludingVanishedPlayers() { ProxyServer proxy = mock(ProxyServer.class); - VanishAPI vanishApi = mock(VanishAPI.class); + PresenceApi vanishApi = mock(PresenceApi.class); RegisteredServer lobby = server("Lobby-1"); RegisteredServer survival = server("survival"); Player visibleLobby = player("Lobby-1"); @@ -37,14 +41,17 @@ void capturesNetworkAndPerServerCountsIncludingVanishedPlayers() { visibleSurvival, connecting )); - when(vanishApi.getVanishedPlayers()).thenReturn(List.of(vanishedLobby)); + UUID hiddenId = vanishedLobby.getUniqueId(); + java.util.Set<UUID> online = java.util.Set.of( + visibleLobby.getUniqueId(), hiddenId, visibleSurvival.getUniqueId(), connecting.getUniqueId()); + when(vanishApi.snapshot()).thenReturn(new PresenceSnapshot(online, java.util.Set.of(hiddenId), java.time.Instant.now())); - PlayerCountAPI api = new PlayerCountAPI(proxy, () -> Optional.of(vanishApi)); - PlayerCountSnapshot snapshot = api.capture(); + PlayerCountService api = new PlayerCountService(proxy, () -> Optional.of(vanishApi)); + PlayerCountSnapshot snapshot = api.snapshot(); - assertEquals(new PlayerCountSnapshot.Counts(4, 1), snapshot.network()); - assertEquals(new PlayerCountSnapshot.Counts(2, 1), snapshot.server("lobby-1")); - assertEquals(new PlayerCountSnapshot.Counts(1, 0), snapshot.server("SURVIVAL")); + assertEquals(new PlayerCounts(4, 1), snapshot.network()); + assertEquals(new PlayerCounts(2, 1), snapshot.server(ServerId.of("lobby-1"))); + assertEquals(new PlayerCounts(1, 0), snapshot.server(ServerId.of("SURVIVAL"))); assertEquals(3, snapshot.network().visible()); } @@ -55,10 +62,10 @@ void keepsZeroEntriesForRegisteredServersWhenVanishFeatureIsUnavailable() { when(proxy.getAllServers()).thenReturn(List.of(creative)); when(proxy.getAllPlayers()).thenReturn(List.of()); - PlayerCountSnapshot snapshot = new PlayerCountAPI(proxy, Optional::empty).capture(); + PlayerCountSnapshot snapshot = new PlayerCountService(proxy, Optional::empty).snapshot(); - assertEquals(PlayerCountSnapshot.Counts.empty(), snapshot.network()); - assertEquals(PlayerCountSnapshot.Counts.empty(), snapshot.server("creative")); + assertEquals(PlayerCounts.empty(), snapshot.network()); + assertEquals(PlayerCounts.empty(), snapshot.server(ServerId.of("creative"))); assertEquals(1, snapshot.servers().size()); } @@ -70,9 +77,9 @@ void rejectsAmbiguousNormalizedBackendNames() { when(proxy.getAllServers()).thenReturn(List.of(firstLobby, duplicateLobby)); when(proxy.getAllPlayers()).thenReturn(List.of()); - PlayerCountAPI api = new PlayerCountAPI(proxy, Optional::empty); + PlayerCountService api = new PlayerCountService(proxy, Optional::empty); - assertThrows(IllegalStateException.class, api::capture); + assertThrows(IllegalStateException.class, api::snapshot); } @Test @@ -82,9 +89,9 @@ void rejectsBlankBackendNames() { when(proxy.getAllServers()).thenReturn(List.of(blankServer)); when(proxy.getAllPlayers()).thenReturn(List.of()); - PlayerCountAPI api = new PlayerCountAPI(proxy, Optional::empty); + PlayerCountService api = new PlayerCountService(proxy, Optional::empty); - assertThrows(IllegalStateException.class, api::capture); + assertThrows(IllegalStateException.class, api::snapshot); } private static RegisteredServer server(String name) { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountSnapshotTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountSnapshotTest.java deleted file mode 100644 index 4242d7d4..00000000 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playercount/internal/PlayerCountSnapshotTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.playercount.internal; - -import org.junit.jupiter.api.Test; - -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -class PlayerCountSnapshotTest { - - @Test - void rejectsMissingNetworkAndServerCounts() { - assertThrows( - NullPointerException.class, - () -> new PlayerCountSnapshot(null, Map.of()) - ); - - Map<String, PlayerCountSnapshot.Counts> servers = new LinkedHashMap<>(); - servers.put("survival", null); - assertThrows( - NullPointerException.class, - () -> new PlayerCountSnapshot(PlayerCountSnapshot.Counts.empty(), servers) - ); - } - - @Test - void rejectsDuplicateNormalizedServerNames() { - Map<String, PlayerCountSnapshot.Counts> servers = new LinkedHashMap<>(); - servers.put("Lobby", PlayerCountSnapshot.Counts.empty()); - servers.put(" lobby ", PlayerCountSnapshot.Counts.empty()); - - assertThrows( - IllegalArgumentException.class, - () -> new PlayerCountSnapshot(PlayerCountSnapshot.Counts.empty(), servers) - ); - } -} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoServiceTest.java index 5473d97c..2c7e2cfe 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerinfo/service/PlayerInfoServiceTest.java @@ -5,19 +5,16 @@ import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.ServerInfo; import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataprovider.api.orm.ORMContext; -import nl.hauntedmc.dataregistry.api.player.PlayerConnectionSnapshot; -import nl.hauntedmc.dataregistry.api.player.PlayerData; -import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; -import nl.hauntedmc.dataregistry.api.player.PlayerNameHistoryEntry; -import nl.hauntedmc.dataregistry.api.player.PlayerProfile; +import nl.hauntedmc.dataregistry.api.player.*; import nl.hauntedmc.proxyfeatures.ProxyFeatures; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionFilter; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionType; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionsApi; import nl.hauntedmc.proxyfeatures.features.playerinfo.PlayerInfo; -import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; -import org.hibernate.Session; -import org.hibernate.query.Query; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import org.junit.jupiter.api.Test; import java.net.InetSocketAddress; @@ -40,12 +37,10 @@ void constructorFallsBackForInvalidDatePatternAndFmtHandlesNull() { ProxyFeatures plugin = mock(ProxyFeatures.class); FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); FeatureLogger logger = mock(FeatureLogger.class); - ORMContext orm = mock(ORMContext.class); DataRegistryApi dataRegistry = mock(DataRegistryApi.class); PlayerData players = mock(PlayerData.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLogger()).thenReturn(logger); - when(feature.getOrmContext()).thenReturn(orm); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(mock(ProxyServer.class)); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); @@ -66,12 +61,10 @@ void constructorHandlesBlankTimezoneAndBlankPattern() { ProxyFeatures plugin = mock(ProxyFeatures.class); FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); FeatureLogger logger = mock(FeatureLogger.class); - ORMContext orm = mock(ORMContext.class); DataRegistryApi dataRegistry = mock(DataRegistryApi.class); PlayerData players = mock(PlayerData.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLogger()).thenReturn(logger); - when(feature.getOrmContext()).thenReturn(orm); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(mock(ProxyServer.class)); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); @@ -89,12 +82,10 @@ void constructorHandlesInvalidTimezone() { ProxyFeatures plugin = mock(ProxyFeatures.class); FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); FeatureLogger logger = mock(FeatureLogger.class); - ORMContext orm = mock(ORMContext.class); DataRegistryApi dataRegistry = mock(DataRegistryApi.class); PlayerData players = mock(PlayerData.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLogger()).thenReturn(logger); - when(feature.getOrmContext()).thenReturn(orm); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(mock(ProxyServer.class)); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); @@ -175,18 +166,9 @@ void findPlayerProfileUsesFacadeIdentifierLookupAndSharedIpHelper() { @Test void queryHelpersForUuidConnectionInfoAndActiveSanctionsDelegateCorrectly() { PlayerInfo feature = playerInfoWithDefaults(); - ORMContext orm = feature.getOrmContext(); - Session session = mock(Session.class); - @SuppressWarnings("unchecked") - Query<SanctionEntity> sanctionQuery = mock(Query.class); DataRegistryApi dataRegistry = feature.getPlugin().getDataRegistry().orElseThrow(); PlayerData players = dataRegistry.players(); - when(orm.runInTransaction(any())).thenAnswer(invocation -> { - ORMContext.TransactionCallback<?> callback = invocation.getArgument(0); - return callback.execute(session); - }); - UUID uuid = UUID.randomUUID(); PlayerIdentity player = new PlayerIdentity(99L, uuid, "Remy"); PlayerConnectionSnapshot connection = new PlayerConnectionSnapshot( @@ -209,22 +191,21 @@ void queryHelpersForUuidConnectionInfoAndActiveSanctionsDelegateCorrectly() { List.of() )))); - when(session.createQuery( - "SELECT s FROM SanctionEntity s " + - "WHERE s.targetPlayerId = :playerId AND s.active = true " + - "AND (s.expiresAt IS NULL OR s.expiresAt > :now) " + - "ORDER BY s.createdAt DESC", - SanctionEntity.class)).thenReturn(sanctionQuery); - when(sanctionQuery.setParameter("playerId", 99L)).thenReturn(sanctionQuery); - when(sanctionQuery.setParameter(eq("now"), any(Instant.class))).thenReturn(sanctionQuery); - when(sanctionQuery.getResultList()).thenReturn(List.of(mock(SanctionEntity.class))); + SanctionsApi sanctions = mock(SanctionsApi.class); + when(sanctions.find(uuid, SanctionFilter.ACTIVE)).thenReturn(CompletableFuture.completedFuture(List.of( + new SanctionSnapshot( + 1L, uuid, SanctionType.BAN, "reason", "actor", Instant.EPOCH, + Optional.empty(), true) + ))); + ((MutableCapabilityRegistry) feature.getPlugin().capabilities()) + .register(SanctionsApi.class, sanctions); PlayerInfoService service = new PlayerInfoService(feature); Optional<PlayerProfile> profile = join(service.findPlayerProfile(uuid.toString())); assertTrue(profile.isPresent()); assertTrue(profile.get().connection().isPresent()); - assertEquals(1, service.getActiveSanctions(player).size()); + assertEquals(1, join(service.getActiveSanctions(player)).size()); } @Test @@ -297,19 +278,18 @@ private static PlayerInfo playerInfoWithDefaults() { PlayerInfo feature = mock(PlayerInfo.class); FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); FeatureLogger logger = mock(FeatureLogger.class); - ORMContext orm = mock(ORMContext.class); ProxyFeatures plugin = mock(ProxyFeatures.class); ProxyServer proxy = mock(ProxyServer.class); DataRegistryApi dataRegistry = mock(DataRegistryApi.class); PlayerData players = mock(PlayerData.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLogger()).thenReturn(logger); - when(feature.getOrmContext()).thenReturn(orm); when(feature.getPlugin()).thenReturn(plugin); when(cfg.get("timezone", String.class, "")).thenReturn("UTC"); when(cfg.get("datetimeFormat", String.class, "dd-MM-yyyy HH:mm:ss")).thenReturn("dd-MM-yyyy HH:mm:ss"); when(plugin.getProxy()).thenReturn(proxy); when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); + when(plugin.capabilities()).thenReturn(new MutableCapabilityRegistry()); when(dataRegistry.players()).thenReturn(players); when(proxy.getPlayer(anyString())).thenReturn(Optional.empty()); when(proxy.getPlayer(any(UUID.class))).thenReturn(Optional.empty()); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicyTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicyTest.java index 30dd5dc5..ad1de509 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicyTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/command/LanguageCommandPolicyTest.java @@ -1,14 +1,12 @@ package nl.hauntedmc.proxyfeatures.features.playerlanguage.command; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; class LanguageCommandPolicyTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java index f731cbfb..5a048d82 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java @@ -4,8 +4,8 @@ import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.dataregistry.api.player.PlayerData; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; import nl.hauntedmc.proxyfeatures.features.playerlanguage.PlayerLanguage; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.Language; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandlerTest.java index 66e989f6..9464f276 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlist/internal/PlayerListHandlerTest.java @@ -7,12 +7,10 @@ import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.api.proxy.server.ServerPing; import net.kyori.adventure.text.Component; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceDirectory; -import nl.hauntedmc.proxyfeatures.test.TestFeatureServiceDirectory; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.features.playerlist.PlayerList; -import nl.hauntedmc.proxyfeatures.features.vanish.internal.VanishAPI; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -36,21 +34,19 @@ class PlayerListHandlerTest { private LocalizationHandler localization; private LocalizationHandler.MessageBuilder builder; private PlayerListHandler handler; - private FeatureServiceDirectory featureServices; + private MutableCapabilityRegistry featureServices; @BeforeEach void setUp() { feature = mock(PlayerList.class); plugin = mock(ProxyFeatures.class); proxy = mock(ProxyServer.class); - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - featureServices = new TestFeatureServiceDirectory(); + featureServices = new MutableCapabilityRegistry(); localization = mock(LocalizationHandler.class); builder = mock(LocalizationHandler.MessageBuilder.class); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); - when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); - when(dataRegistry.featureServices()).thenReturn(featureServices); + when(plugin.capabilities()).thenReturn(featureServices); when(feature.getLocalizationHandler()).thenReturn(localization); when(localization.getMessage(anyString())).thenReturn(builder); when(builder.with(anyString(), anyString())).thenReturn(builder); @@ -77,9 +73,9 @@ void formatPlayerListFiltersVanishedPlayersAndSeparatesStaff() { Player visibleStaff = player("Zed", visibleStaffId, true); Player vanished = player("Ghost", vanishedId, false); - VanishAPI vanishApi = mock(VanishAPI.class); - when(vanishApi.isVanished(vanishedId)).thenReturn(true); - featureServices.register("ProxyFeatures", "Vanish", VanishAPI.class, vanishApi); + PresenceApi vanishApi = mock(PresenceApi.class); + when(vanishApi.isHidden(vanishedId)).thenReturn(true); + featureServices.register(PresenceApi.class, vanishApi); handler.formatPlayerList("survival", List.of(visibleUser, visibleStaff, vanished), audience); @@ -107,9 +103,9 @@ void formatGlobalListUsesVisibleCountsAndServerOrder() { Player carl = player("Carl", carlId, false); Player ghost = player("Ghost", ghostId, false); - VanishAPI vanishApi = mock(VanishAPI.class); - when(vanishApi.isVanished(ghostId)).thenReturn(true); - featureServices.register("ProxyFeatures", "Vanish", VanishAPI.class, vanishApi); + PresenceApi vanishApi = mock(PresenceApi.class); + when(vanishApi.isHidden(ghostId)).thenReturn(true); + featureServices.register(PresenceApi.class, vanishApi); RegisteredServer survival = server("survival", List.of(alice, ghost), CompletableFuture.completedFuture(mock(ServerPing.class))); CompletableFuture<ServerPing> failedPing = new CompletableFuture<>(); @@ -126,7 +122,7 @@ void formatGlobalListUsesVisibleCountsAndServerOrder() { verify(localization).getMessage("playerlist.server_bullet_offline"); verify(survival, times(1)).getPlayersConnected(); verify(lobby, times(1)).getPlayersConnected(); - verify(vanishApi, times(8)).isVanished(any(UUID.class)); + verify(vanishApi, times(8)).isHidden(any(UUID.class)); InOrder order = inOrder(builder); order.verify(builder).with("server", "lobby"); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManagerTest.java index b14b87ff..2daa11d5 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/QueueManagerTest.java @@ -7,11 +7,11 @@ import com.velocitypowered.api.scheduler.ScheduledTask; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDecision; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityDenialReason; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityLease; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityRequest; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDecision; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityDenialReason; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityLease; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityRequest; import nl.hauntedmc.proxyfeatures.features.queue.model.ServerQueue; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; @@ -92,9 +92,9 @@ void enqueueAcceptsOnlyConfiguredFullTargets() { Player player = player(UUID.randomUUID()); CapacityRequest request = request(player, "survival"); - assertFalse(manager.enqueue(player, "survival", CapacityDenialReason.SERVER_STATE, request)); - assertFalse(manager.enqueue(player, "unknown", CapacityDenialReason.FULL, request)); - assertTrue(manager.enqueue(player, "survival", CapacityDenialReason.FULL, request)); + assertFalse(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.SERVER_STATE, request)); + assertFalse(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("unknown"), CapacityDenialReason.FULL, request)); + assertTrue(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request)); assertTrue(manager.getQueue("survival").orElseThrow().contains(player.getUniqueId())); } @@ -103,8 +103,8 @@ void duplicateEntryIsIdempotent() { Player player = player(UUID.randomUUID()); CapacityRequest request = request(player, "survival"); - assertTrue(manager.enqueue(player, "survival", CapacityDenialReason.FULL, request)); - assertTrue(manager.enqueue(player, "survival", CapacityDenialReason.FULL, request)); + assertTrue(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request)); + assertTrue(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request)); ServerQueue queue = manager.getQueue("survival").orElseThrow(); assertEquals(1, queue.size()); @@ -115,10 +115,10 @@ void duplicateEntryIsIdempotent() { void enqueueMovesMembershipBetweenTargets() { Player player = player(UUID.randomUUID()); - assertTrue(manager.enqueue(player, "lobby", CapacityDenialReason.FULL, request(player, "lobby"))); + assertTrue(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("lobby"), CapacityDenialReason.FULL, request(player, "lobby"))); assertTrue(manager.getQueue("lobby").orElseThrow().contains(player.getUniqueId())); - assertTrue(manager.enqueue(player, "survival", CapacityDenialReason.FULL, request(player, "survival"))); + assertTrue(manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request(player, "survival"))); assertFalse(manager.getQueue("lobby").orElseThrow().contains(player.getUniqueId())); assertTrue(manager.getQueue("survival").orElseThrow().contains(player.getUniqueId())); assertEquals("survival", manager.findQueueOf(player.getUniqueId()).orElseThrow()); @@ -127,11 +127,11 @@ void enqueueMovesMembershipBetweenTargets() { @Test void leaveRemovesMembershipAndTransientState() { Player player = player(UUID.randomUUID()); - manager.enqueue(player, "survival", CapacityDenialReason.FULL, request(player, "survival")); + manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request(player, "survival")); - assertEquals("survival", manager.leave(player.getUniqueId()).orElseThrow()); + assertEquals("survival", manager.leaveNow(player.getUniqueId()).orElseThrow()); assertTrue(manager.findQueueOf(player.getUniqueId()).isEmpty()); - assertTrue(manager.leave(player.getUniqueId()).isEmpty()); + assertTrue(manager.leaveNow(player.getUniqueId()).isEmpty()); } @Test @@ -150,7 +150,7 @@ void leaveFencesConnectionRequestThatAlreadyEnteredVelocityPipeline() { when(player.createConnectionRequest(target)).thenReturn(connection); when(connection.connect()).thenReturn(pending); - manager.enqueue(player, "survival", CapacityDenialReason.FULL, request(player, "survival")); + manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request(player, "survival")); ArgumentCaptor<Runnable> wake = ArgumentCaptor.forClass(Runnable.class); verify(tasks).scheduleTask(wake.capture()); wake.getValue().run(); @@ -159,16 +159,16 @@ void leaveFencesConnectionRequestThatAlreadyEnteredVelocityPipeline() { verify(tasks).scheduleDelayedTask(delayed.capture(), any(Duration.class)); delayed.getValue().run(); - assertEquals("survival", manager.leave(playerId).orElseThrow()); - assertFalse(manager.consumeCancelledAdvance(playerId, "lobby")); - assertTrue(manager.consumeCancelledAdvance(playerId, "survival")); - assertFalse(manager.consumeCancelledAdvance(playerId, "survival")); + assertEquals("survival", manager.leaveNow(playerId).orElseThrow()); + assertFalse(manager.consumeCancelledAdvance(playerId, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("lobby"))); + assertTrue(manager.consumeCancelledAdvance(playerId, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"))); + assertFalse(manager.consumeCancelledAdvance(playerId, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"))); } @Test void successfulPostConnectClearsTargetQueueEntry() { Player player = player(UUID.randomUUID()); - manager.enqueue(player, "survival", CapacityDenialReason.FULL, request(player, "survival")); + manager.enqueueDenied(player, nl.hauntedmc.proxyfeatures.api.model.ServerId.of("survival"), CapacityDenialReason.FULL, request(player, "survival")); manager.onPostConnect(player, "SURVIVAL"); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommandTest.java index c4e7da41..b5103446 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/queue/command/QueueCommandTest.java @@ -128,11 +128,11 @@ void leaveSubcommandUsesManagerCancellationAndConfirms() { Player player = mock(Player.class); UUID id = UUID.randomUUID(); when(player.getUniqueId()).thenReturn(id); - when(manager.leave(id)).thenReturn(Optional.of("survival")); + when(manager.leaveNow(id)).thenReturn(Optional.of("survival")); command.execute(invocation(player, "leave")); - verify(manager).leave(id); + verify(manager).leaveNow(id); verify(localization).getMessage("queue.cmd.leave.done"); verify(builder).with("server", "survival"); } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommandTest.java index eeb53a75..20553a34 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/command/ResourcePackCommandTest.java @@ -17,16 +17,9 @@ import java.util.Optional; import java.util.Set; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; class ResourcePackCommandTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/listener/ResourcePackStatusPolicyTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/listener/ResourcePackStatusPolicyTest.java index 793c0279..48dbabc6 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/listener/ResourcePackStatusPolicyTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/resourcepack/listener/ResourcePackStatusPolicyTest.java @@ -3,10 +3,7 @@ import com.velocitypowered.api.event.player.PlayerResourcePackStatusEvent; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; class ResourcePackStatusPolicyTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectCancellationTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectCancellationTest.java index 71e84c3b..380fb0b5 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectCancellationTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectCancellationTest.java @@ -9,7 +9,7 @@ import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectDelayedNoticeRaceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectDelayedNoticeRaceTest.java index 035a9f17..06639c74 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectDelayedNoticeRaceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectDelayedNoticeRaceTest.java @@ -10,7 +10,7 @@ import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManagerTest.java index 2542fe70..efd4fc26 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/BackendReconnectManagerTest.java @@ -8,8 +8,8 @@ import com.velocitypowered.api.scheduler.ScheduledTask; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; @@ -29,11 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class BackendReconnectManagerTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/PlayerAutoreconnectCancellationTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/PlayerAutoreconnectCancellationTest.java index e2860ff7..070904ba 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/PlayerAutoreconnectCancellationTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/PlayerAutoreconnectCancellationTest.java @@ -10,7 +10,7 @@ import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinatorTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinatorTest.java index bc5c1ba5..763c11d9 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinatorTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/internal/RestartCapacityCoordinatorTest.java @@ -4,10 +4,10 @@ import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.scheduler.ScheduledTask; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityAPI; -import nl.hauntedmc.proxyfeatures.api.capacity.CapacityState; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityAPI; +import nl.hauntedmc.proxyfeatures.framework.admission.CapacityState; import nl.hauntedmc.proxyfeatures.features.restart.Restart; -import nl.hauntedmc.proxyfeatures.features.restart.messaging.RestartLifecycleMessage; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBusTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBusTest.java index 9b39ab44..1487e3b0 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBusTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/restart/messaging/RestartLifecycleBusTest.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableSubscription; +import nl.hauntedmc.proxyfeatures.contracts.messaging.RestartLifecycleMessage; import nl.hauntedmc.proxyfeatures.features.restart.Restart; import nl.hauntedmc.proxyfeatures.features.restart.internal.BackendReconnectManager; import org.junit.jupiter.api.Test; @@ -12,9 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class RestartLifecycleBusTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommandTest.java index 40b94f50..200bae08 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/AsyncSanctionsCommandTest.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.sanctions.command; import com.velocitypowered.api.command.SimpleCommand; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; diff --git a/proxyfeatures-contracts/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntityTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntityTest.java similarity index 100% rename from proxyfeatures-contracts/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntityTest.java rename to proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/entity/SanctionEntityTest.java diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListenerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListenerTest.java index c3965867..f51fee82 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListenerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/listener/ConnectListenerTest.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.proxy.Player; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.audit.SanctionsSecurityAuditLogService; import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionsService; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordServiceTest.java index f7549ea7..0ea998e9 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/DiscordServiceTest.java @@ -1,8 +1,7 @@ package nl.hauntedmc.proxyfeatures.features.sanctions.service; import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; -import nl.hauntedmc.proxyfeatures.api.util.http.DiscordUtils; -import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.proxyfeatures.toolkit.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; @@ -10,11 +9,9 @@ import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; class DiscordServiceTest { @@ -33,25 +30,21 @@ void sendWarnSkipsWhenWebhookNotConfigured() { } @Test - void sendUnbanSchedulesTaskAndPostsPayload() { + void sendUnbanSchedulesTaskAndPostsPayload() throws Exception { Sanctions feature = configuredFeature("https://discord.example/hook"); FeatureLogger logger = feature.getLogger(); PlayerReference target = new PlayerReference(1L, null, "Remy"); - try (MockedStatic<DiscordUtils> mocked = mockStatic(DiscordUtils.class)) { - mocked.when(() -> DiscordUtils.sendPayload(anyString(), anyString())).thenReturn(true); - - DiscordService service = new DiscordService(feature); - service.sendUnban(target, "Admin"); - - mocked.verify(() -> DiscordUtils.sendPayload( - eq("https://discord.example/hook"), - argThat(payload -> payload.contains("Sanctie Melding") - && payload.contains("Unban") - && payload.contains("Remy") - && payload.contains("Admin")))); - } + DiscordService.WebhookTransport transport = mock(DiscordService.WebhookTransport.class); + DiscordService service = new DiscordService(feature, transport); + service.sendUnban(target, "Admin"); + verify(transport).post( + eq("https://discord.example/hook"), + argThat(payload -> payload.contains("Sanctie Melding") + && payload.contains("Unban") + && payload.contains("Remy") + && payload.contains("Admin"))); verify(logger, never()).warn(contains("Failed to deliver webhook payload")); } @@ -63,12 +56,11 @@ void sendKickLogsWarningWhenDeliveryFails() { PlayerReference target = new PlayerReference(1L, null, "Remy"); - try (MockedStatic<DiscordUtils> mocked = mockStatic(DiscordUtils.class)) { - mocked.when(() -> DiscordUtils.sendPayload(anyString(), anyString())).thenReturn(false); - - DiscordService service = new DiscordService(feature); - service.sendKick(target, "spam", "Admin"); - } + DiscordService.WebhookTransport transport = (url, payload) -> { + throw new java.io.IOException("offline"); + }; + DiscordService service = new DiscordService(feature, transport); + service.sendKick(target, "spam", "Admin"); verify(logger).warn(contains("Failed to deliver webhook payload")); } @@ -90,18 +82,10 @@ void sendBanMuteAndUnmuteUsePlaceholderPayloadData() { SanctionEntity sanction = new SanctionEntity(); java.util.List<String> payloads = new java.util.ArrayList<>(); - try (MockedStatic<DiscordUtils> mocked = mockStatic(DiscordUtils.class)) { - mocked.when(() -> DiscordUtils.sendPayload(anyString(), anyString())) - .thenAnswer(invocation -> { - payloads.add(invocation.getArgument(1, String.class)); - return true; - }); - - DiscordService service = new DiscordService(feature); - service.sendBan(sanction); - service.sendMute(sanction); - service.sendUnmute(target, "Admin"); - } + DiscordService service = new DiscordService(feature, (url, payload) -> payloads.add(payload)); + service.sendBan(sanction); + service.sendMute(sanction); + service.sendUnmute(target, "Admin"); verify(feature.getLifecycleManager().getTaskManager(), times(3)).scheduleTask(any(Runnable.class)); assertTrue(payloads.stream().anyMatch(p -> p.contains("\"value\":\"Ban\""))); @@ -114,17 +98,9 @@ void sendWarnAndKickHandleNullTargetAndBlankReasonWithDashFallback() { Sanctions feature = configuredFeature("https://discord.example/hook"); java.util.List<String> payloads = new java.util.ArrayList<>(); - try (MockedStatic<DiscordUtils> mocked = mockStatic(DiscordUtils.class)) { - mocked.when(() -> DiscordUtils.sendPayload(anyString(), anyString())) - .thenAnswer(invocation -> { - payloads.add(invocation.getArgument(1, String.class)); - return true; - }); - - DiscordService service = new DiscordService(feature); - service.sendWarn(null, " ", "Admin"); - service.sendKick(null, null, "Admin"); - } + DiscordService service = new DiscordService(feature, (url, payload) -> payloads.add(payload)); + service.sendWarn(null, " ", "Admin"); + service.sendKick(null, null, "Admin"); verify(feature.getLifecycleManager().getTaskManager(), times(2)).scheduleTask(any(Runnable.class)); assertTrue(payloads.stream().anyMatch(p -> p.contains("\"value\":\"Warn\""))); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsCapabilityTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsCapabilityTest.java new file mode 100644 index 00000000..66edaecd --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsCapabilityTest.java @@ -0,0 +1,67 @@ +package nl.hauntedmc.proxyfeatures.features.sanctions.service; + +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionFilter; +import nl.hauntedmc.proxyfeatures.api.capability.moderation.SanctionSnapshot; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionEntity; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SanctionsCapabilityTest { + + @Test + void findRejectsNullArgumentsBeforeStartingPersistenceWork() { + SanctionsService service = mock(SanctionsService.class); + SanctionsCapability capability = new SanctionsCapability(service); + + assertThrows(NullPointerException.class, () -> capability.find(null, SanctionFilter.ALL)); + assertThrows(NullPointerException.class, () -> capability.find(UUID.randomUUID(), null)); + } + + @Test + void findUsesTypedFilterAndInjectedClockForSnapshotState() { + UUID playerId = UUID.randomUUID(); + Instant now = Instant.parse("2026-08-06T08:00:00Z"); + SanctionEntity expired = sanction(now.minusSeconds(1)); + SanctionEntity current = sanction(now.plusSeconds(1)); + SanctionsService service = mock(SanctionsService.class); + when(service.findSanctionsByUuid(playerId, true)) + .thenReturn(CompletableFuture.completedFuture(List.of(expired, current))); + SanctionsCapability capability = new SanctionsCapability( + service, + Clock.fixed(now, ZoneOffset.UTC) + ); + + List<SanctionSnapshot> snapshots = capability.find(playerId, SanctionFilter.ACTIVE) + .toCompletableFuture() + .join(); + + verify(service).findSanctionsByUuid(playerId, true); + assertFalse(snapshots.get(0).active()); + assertTrue(snapshots.get(1).active()); + } + + private static SanctionEntity sanction(Instant expiresAt) { + SanctionEntity entity = new SanctionEntity(); + entity.setType(SanctionType.BAN); + entity.setReason("reason"); + entity.setActorName("Admin"); + entity.setCreatedAt(Instant.parse("2026-08-01T00:00:00Z")); + entity.setExpiresAt(expiresAt); + entity.setActive(true); + return entity; + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceParsingTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceParsingTest.java index 6ec113c7..09bb533f 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceParsingTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceParsingTest.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.junit.jupiter.api.Test; import java.time.Instant; @@ -13,7 +14,7 @@ class SanctionsServiceParsingTest { @Test void parseLengthSupportsPermanentAndCompoundDurations() { - SanctionsService service = new SanctionsService(mock(Sanctions.class), mock(PlayerDirectory.class)); + SanctionsService service = service(); assertNull(service.parseLengthToExpiry("p")); assertNull(service.parseLengthToExpiry("perm")); @@ -29,7 +30,7 @@ void parseLengthSupportsPermanentAndCompoundDurations() { @Test void parseLengthRejectsMalformedTokensAndJunkInput() { - SanctionsService service = new SanctionsService(mock(Sanctions.class), mock(PlayerDirectory.class)); + SanctionsService service = service(); assertThrows(IllegalArgumentException.class, () -> service.parseLengthToExpiry(null)); assertThrows(IllegalArgumentException.class, () -> service.parseLengthToExpiry("")); @@ -42,7 +43,7 @@ void parseLengthRejectsMalformedTokensAndJunkInput() { @Test void sanitizeReasonAndHumanDurationHandleBoundaries() { - SanctionsService service = new SanctionsService(mock(Sanctions.class), mock(PlayerDirectory.class)); + SanctionsService service = service(); assertEquals("-", service.sanitizeReason(null)); assertEquals("-", service.sanitizeReason(" ")); @@ -57,4 +58,10 @@ void sanitizeReasonAndHumanDurationHandleBoundaries() { assertEquals("45s", service.humanDuration(from, from.plusSeconds(45))); assertEquals("1d 1h 1m", service.humanDuration(from, from.plusSeconds(90_061))); } + + private static SanctionsService service() { + return new SanctionsService( + mock(Sanctions.class), new PlayerReferenceResolver(mock(PlayerDirectory.class)) + ); + } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceSharedIpTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceSharedIpTest.java index 80062685..9bdbf979 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceSharedIpTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionsServiceSharedIpTest.java @@ -6,6 +6,7 @@ import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.Session; import org.hibernate.query.Query; import org.junit.jupiter.api.Test; @@ -64,7 +65,9 @@ void findSharedIpSanctionsGroupsActiveBanAndMuteMatchesByPlayerId() { new Object[]{SanctionType.BAN, 1L} )); - SanctionsService service = new SanctionsService(feature, playerDirectory, players); + SanctionsService service = new SanctionsService( + feature, new PlayerReferenceResolver(playerDirectory), players + ); SanctionsService.SharedIpSanctions result = join(service.findSharedIpSanctions("203.0.113.7", 99L)); assertEquals(List.of("Alpha"), result.bannedUsernames()); @@ -74,7 +77,9 @@ void findSharedIpSanctionsGroupsActiveBanAndMuteMatchesByPlayerId() { @Test void findSharedIpSanctionsReturnsEmptyForBlankIp() { Sanctions feature = mock(Sanctions.class); - SanctionsService service = new SanctionsService(feature, mock(PlayerDirectory.class)); + SanctionsService service = new SanctionsService( + feature, new PlayerReferenceResolver(mock(PlayerDirectory.class)) + ); SanctionsService.SharedIpSanctions result = join(service.findSharedIpSanctions(" ", 99L)); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookupTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookupTest.java index db9be974..fb0f4e1f 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookupTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/ServiceLookupTest.java @@ -2,6 +2,7 @@ import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReference; import nl.hauntedmc.dataregistry.api.player.PlayerDirectory; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import org.junit.jupiter.api.Test; @@ -22,7 +23,7 @@ void byNameDelegatesToPlayerDirectory() { when(playerDirectory.snapshotActiveIdentities()) .thenReturn(Map.of(uuid.toString(), new PlayerIdentity(3L, uuid, "Remy"))); - ServiceLookup lookup = new ServiceLookup(playerDirectory); + ServiceLookup lookup = new ServiceLookup(new PlayerReferenceResolver(playerDirectory)); Optional<PlayerReference> result = lookup.byName("Remy"); assertTrue(result.isPresent()); @@ -39,7 +40,7 @@ void byUuidDelegatesToPlayerDirectoryAndCanReturnEmpty() { when(playerDirectory.findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); - ServiceLookup lookup = new ServiceLookup(playerDirectory); + ServiceLookup lookup = new ServiceLookup(new PlayerReferenceResolver(playerDirectory)); Optional<PlayerReference> result = lookup.byUuid(uuid.toString()); assertTrue(result.isEmpty()); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommandTest.java index e1211a9b..42cbc40c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/textcommands/command/TextCommandTest.java @@ -12,7 +12,8 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; class TextCommandTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactorTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactorTest.java index 63d697c5..79c9aa5f 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactorTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/TwoFactorTest.java @@ -9,11 +9,10 @@ import com.velocitypowered.api.scheduler.ScheduledTask; import net.kyori.adventure.text.Component; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; import nl.hauntedmc.proxyfeatures.features.twofactor.config.TwoFactorConfig; -import nl.hauntedmc.proxyfeatures.features.twofactor.meta.Meta; import nl.hauntedmc.proxyfeatures.features.twofactor.service.TwoFactorService; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; @@ -23,10 +22,7 @@ import java.lang.reflect.Field; import java.net.InetSocketAddress; import java.time.Duration; -import java.util.Optional; -import java.util.OptionalLong; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; @@ -68,9 +64,12 @@ void sendDelayedLockPromptSchedulesPromptOnLockServerOnly() throws Exception { return null; }); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, @@ -129,9 +128,12 @@ void sendLockPromptDoesNothingWhenPlayerIsNotLocked() throws Exception { TwoFactorService.LockReason.AUTH_REQUIRED )); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, @@ -174,9 +176,12 @@ void movePlayerToLockServerDisconnectsWhenForcedTransferFails() throws Exception when(request.connect()).thenReturn(CompletableFuture.completedFuture(result)); when(result.isSuccessful()).thenReturn(false); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, @@ -233,9 +238,12 @@ void enforcePlayerLockStateMovesLockedPlayerOffNonLockServer() throws Exception when(currentServer.getServerInfo()).thenReturn(new ServerInfo("survival", new InetSocketAddress("127.0.0.1", 25565))); AtomicBoolean transferRequested = new AtomicBoolean(); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, @@ -305,9 +313,12 @@ void movePlayerToLockServerSkipsDuplicateTransferWhilePreviousAttemptIsPending() when(player.createConnectionRequest(lockServer)).thenReturn(request); when(request.connect()).thenReturn(pending); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, @@ -363,9 +374,12 @@ void refreshTrustedExpiryMonitorSchedulesExpiryTaskForTrustedLogin() throws Exce when(service.trustedLoginExpiry(player)).thenReturn(OptionalLong.of(System.currentTimeMillis() + 5000L)); when(taskManager.scheduleDelayedTask(any(Runnable.class), any(Duration.class))).thenReturn(scheduledTask); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, @@ -392,9 +406,12 @@ void clearTrustedExpiryMonitorCancelsScheduledTask() throws Exception { when(lifecycleManager.getTaskManager()).thenReturn(taskManager); - TwoFactor feature = new TwoFactor(new FeatureContext<>( + TwoFactor feature = new TwoFactor(new FeatureContext( plugin, - new Meta(), + "TwoFactor", + "1.0.0", + List.of(), + List.of("dataprovider", "dataregistry"), configHandler, lifecycleManager, logger, diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStoreTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStoreTest.java index 54bda934..47e5a186 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStoreTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/persistence/OrmTwoFactorAccountStoreTest.java @@ -111,7 +111,7 @@ private static PlayerReferenceResolver resolverReturning( PlayerReference player ) { PlayerReferenceResolver resolver = mock(PlayerReferenceResolver.class); - when(resolver.resolveManaged(session, uuid)).thenReturn(player); + when(resolver.resolveReference(uuid)).thenReturn(player); return resolver; } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/PresenceServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/PresenceServiceTest.java new file mode 100644 index 00000000..7d9cdd29 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/PresenceServiceTest.java @@ -0,0 +1,43 @@ +package nl.hauntedmc.proxyfeatures.features.vanish.internal; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PresenceServiceTest { + @Test + void exposesOnlyOnlineHiddenPlayers() { + ProxyServer proxy = mock(ProxyServer.class); + VanishRegistry registry = mock(VanishRegistry.class); + Player hidden = mock(Player.class); + Player visible = mock(Player.class); + UUID hiddenId = UUID.randomUUID(); + UUID visibleId = UUID.randomUUID(); + UUID staleId = UUID.randomUUID(); + Instant now = Instant.parse("2026-08-06T00:00:00Z"); + + when(hidden.getUniqueId()).thenReturn(hiddenId); + when(visible.getUniqueId()).thenReturn(visibleId); + when(proxy.getAllPlayers()).thenReturn(List.of(hidden, visible)); + when(registry.snapshot()).thenReturn(Map.of(hiddenId, "Hidden", staleId, "Offline")); + when(registry.isVanished(hiddenId)).thenReturn(true); + + PresenceService service = new PresenceService(proxy, registry, Clock.fixed(now, ZoneOffset.UTC)); + assertTrue(service.isHidden(hiddenId)); + assertEquals(java.util.Set.of(hiddenId, visibleId), service.snapshot().onlinePlayers()); + assertEquals(java.util.Set.of(hiddenId), service.snapshot().hiddenPlayers()); + assertEquals(now, service.snapshot().observedAt()); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishAPITest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishAPITest.java deleted file mode 100644 index c1c8aba2..00000000 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishAPITest.java +++ /dev/null @@ -1,39 +0,0 @@ -package nl.hauntedmc.proxyfeatures.features.vanish.internal; - -import com.velocitypowered.api.proxy.Player; -import nl.hauntedmc.proxyfeatures.features.vanish.Vanish; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class VanishAPITest { - - @Test - void apiDelegatesToRegistry() { - Vanish feature = mock(Vanish.class); - VanishRegistry registry = mock(VanishRegistry.class); - when(feature.getVanishRegistry()).thenReturn(registry); - - Player a = mock(Player.class); - Player b = mock(Player.class); - UUID id = UUID.randomUUID(); - - when(registry.getAdjustedOnlineCount()).thenReturn(12); - when(registry.getAdjustedOnlinePlayers()).thenReturn(List.of(a)); - when(registry.getVanishedOnlinePlayers()).thenReturn(List.of(b)); - when(registry.getVanishedOnlineCount()).thenReturn(1); - when(registry.isVanished(id)).thenReturn(true); - - VanishAPI api = new VanishAPI(feature); - assertEquals(12, api.getAdjustedPlayerCount()); - assertEquals(List.of(a), api.getAdjustedOnlinePlayers()); - assertEquals(List.of(b), api.getVanishedPlayers()); - assertEquals(1, api.getVanishedCount()); - assertTrue(api.isVanished(id)); - } -} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistryEventTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistryEventTest.java index 62a9a6a7..d723cbdd 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistryEventTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/vanish/internal/VanishRegistryEventTest.java @@ -5,7 +5,7 @@ import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.features.vanish.Vanish; -import nl.hauntedmc.proxyfeatures.features.vanish.event.VanishStateChangeEvent; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceChangedEvent; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -36,14 +36,14 @@ void acceptedOnlineStateChangesPublishExactlyOnce() { ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class); verify(fixture.eventManager(), times(2)).fireAndForget(eventCaptor.capture()); - VanishStateChangeEvent hidden = (VanishStateChangeEvent) eventCaptor.getAllValues().get(0); - assertEquals(fixture.playerUuid(), hidden.playerUuid()); + PresenceChangedEvent hidden = (PresenceChangedEvent) eventCaptor.getAllValues().get(0); + assertEquals(fixture.playerUuid(), hidden.playerId()); assertEquals("Remy", hidden.playerName()); - assertTrue(hidden.vanished()); + assertTrue(hidden.hidden()); - VanishStateChangeEvent visible = (VanishStateChangeEvent) eventCaptor.getAllValues().get(1); - assertEquals(fixture.playerUuid(), visible.playerUuid()); - assertFalse(visible.vanished()); + PresenceChangedEvent visible = (PresenceChangedEvent) eventCaptor.getAllValues().get(1); + assertEquals(fixture.playerUuid(), visible.playerId()); + assertFalse(visible.hidden()); } @Test diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandlerTest.java index bf8e8ce0..46ed0054 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/versioncheck/internal/VersionHandlerTest.java @@ -24,17 +24,31 @@ void constructorReadsConfigAndAppliesFriendlyFallback() { VersionAuditLogService auditLogService = mock(VersionAuditLogService.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getAuditLogService()).thenReturn(auditLogService); - when(cfg.get("minimum_protocol_version", Integer.class, 0)).thenReturn(763); + when(cfg.get("minimum_protocol_version", Integer.class, VersionCheck.DEFAULT_MINIMUM_PROTOCOL_VERSION)).thenReturn(763); when(cfg.get("friendly_protocol_name", String.class, "")).thenReturn(" "); VersionHandler handler = new VersionHandler(feature); - assertEquals(763, handler.getMinimumProtcolVersion()); - assertEquals("unsupported", handler.getFriendlyProtocolName()); - assertTrue(handler.isUnsupportedVersion(762)); - assertFalse(handler.isUnsupportedVersion(763)); - assertTrue(handler.isUnsupportedVersion(762)); - assertFalse(handler.isUnsupportedVersion(763)); + assertEquals(763, handler.minimumProtocolVersion()); + assertEquals("unsupported", handler.minimumVersionName()); + assertFalse(handler.isSupported(762)); + assertTrue(handler.isSupported(763)); + } + + @Test + void constructorRejectsNegativeMinimumProtocol() { + VersionCheck feature = mock(VersionCheck.class); + FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); + when(feature.getConfigHandler()).thenReturn(cfg); + when(cfg.get( + "minimum_protocol_version", + Integer.class, + VersionCheck.DEFAULT_MINIMUM_PROTOCOL_VERSION + )).thenReturn(-1); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new VersionHandler(feature)); + + assertEquals("minimum_protocol_version must be zero or greater", exception.getMessage()); } @Test @@ -47,12 +61,13 @@ void checkVersionDeniesUnsupportedClientsWithLocalizedMessage() { when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLocalizationHandler()).thenReturn(localization); when(feature.getAuditLogService()).thenReturn(auditLogService); - when(cfg.get("minimum_protocol_version", Integer.class, 0)).thenReturn(800); + when(cfg.get("minimum_protocol_version", Integer.class, VersionCheck.DEFAULT_MINIMUM_PROTOCOL_VERSION)).thenReturn(800); when(cfg.get("friendly_protocol_name", String.class, "")).thenReturn("1.21"); when(localization.getMessage("versioncheck.unsupported_version")).thenReturn(builder); when(builder.forAudience(any())).thenReturn(builder); when(builder.with("friendly_protocol_name", "1.21")).thenReturn(builder); - when(builder.build()).thenReturn(Component.text("unsupported")); + Component denial = Component.text("unsupported"); + when(builder.build()).thenReturn(denial); Player player = mock(Player.class); ProtocolVersion protocol = mock(ProtocolVersion.class); @@ -66,7 +81,7 @@ void checkVersionDeniesUnsupportedClientsWithLocalizedMessage() { ResultedEvent.ComponentResult result = event.getResult(); assertFalse(result.isAllowed()); - assertTrue(result.getReasonComponent().isPresent()); + assertEquals(denial, result.getReasonComponent().orElseThrow()); verify(auditLogService).logObservation(player, "denied", 800, "1.21"); } @@ -77,7 +92,7 @@ void checkVersionDoesNothingForSupportedClients() { VersionAuditLogService auditLogService = mock(VersionAuditLogService.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getAuditLogService()).thenReturn(auditLogService); - when(cfg.get("minimum_protocol_version", Integer.class, 0)).thenReturn(700); + when(cfg.get("minimum_protocol_version", Integer.class, VersionCheck.DEFAULT_MINIMUM_PROTOCOL_VERSION)).thenReturn(700); when(cfg.get("friendly_protocol_name", String.class, "")).thenReturn("1.8"); Player player = mock(Player.class); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsServiceTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsServiceTest.java index f041c46a..b47ae44c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsServiceTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VoteStatsServiceTest.java @@ -11,6 +11,7 @@ import nl.hauntedmc.proxyfeatures.features.votifier.entity.PlayerVoteStatsEntity; import nl.hauntedmc.proxyfeatures.features.votifier.model.Vote; import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; import org.hibernate.LockMode; import org.hibernate.Session; import org.hibernate.query.Query; @@ -51,7 +52,7 @@ void offlineIdentityLookupUsesDataRegistryPersistence() { mock(ORMContext.class), mock(ORMContext.class), false, - directory + new PlayerReferenceResolver(directory) ); assertEquals( @@ -248,6 +249,7 @@ private static Votifier feature(Path dataDir, FeatureLogger logger) { when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); when(dataRegistry.players()).thenReturn(players); when(players.identities()).thenReturn(playerDirectory); + when(plugin.getPlayerReferenceResolver()).thenReturn(new PlayerReferenceResolver(playerDirectory)); Votifier feature = mock(Votifier.class); when(feature.getPlugin()).thenReturn(plugin); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierConfigTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierConfigTest.java index 85f04c4d..064d3c69 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierConfigTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/internal/VotifierConfigTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.proxyfeatures.features.votifier.internal; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigNode; import nl.hauntedmc.proxyfeatures.features.votifier.Votifier; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/BackendVoteDeliveryManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/BackendVoteDeliveryManagerTest.java index c318e641..e6f4a12c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/BackendVoteDeliveryManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/votifier/messaging/BackendVoteDeliveryManagerTest.java @@ -5,11 +5,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; +import java.time.*; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -17,9 +13,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; class BackendVoteDeliveryManagerTest { diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommandTest.java index 01d562b6..0b20929a 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/ProxyFeaturesCommandTest.java @@ -10,7 +10,7 @@ import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.framework.feature.FeatureScopeFactory; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureCommandManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureDataManager; @@ -121,7 +121,7 @@ void metadataAndDispatcherExecutionWorkForAllSubcommands() throws Exception { assertEquals("proxyfeatures", command.name()); assertTrue(command.description().contains("framework command")); - VelocityBaseFeature<?> queue = feature("Queue", "1.0", List.of("luckperms"), List.of()); + VelocityBaseFeature queue = feature("Queue", "1.0", List.of("luckperms"), List.of()); registry.registerLoadedFeature("Queue", queue); registry.registerAvailableFeature(descriptor("Queue", "1.0", Set.of(), Set.of("luckperms"))); registry.registerAvailableFeature(descriptor("Friends", "1.0", Set.of(), Set.of())); @@ -162,7 +162,7 @@ void metadataAndDispatcherExecutionWorkForAllSubcommands() throws Exception { @Test void handleInfoCoversBlankLoadedCaseInsensitiveAvailableAndMissing() { - VelocityBaseFeature<?> queue = feature("Queue", "2.0", List.of("A"), List.of("B")); + VelocityBaseFeature queue = feature("Queue", "2.0", List.of("A"), List.of("B")); registry.registerLoadedFeature("Queue", queue); registry.registerAvailableFeature(descriptor("Friends", "1.0", Set.of(), Set.of())); @@ -264,8 +264,8 @@ void enableDisableSoftReloadAndReloadSwitchBranchesAreCovered() { @Test void suggestionAndRenderingHelpersReturnExpectedValues() { - VelocityBaseFeature<?> queue = feature("Queue", "1.0", List.of(), List.of()); - VelocityBaseFeature<?> vanish = feature("Vanish", "1.0", List.of(), List.of()); + VelocityBaseFeature queue = feature("Queue", "1.0", List.of(), List.of()); + VelocityBaseFeature vanish = feature("Vanish", "1.0", List.of(), List.of()); registry.registerLoadedFeature("Queue", queue); registry.registerLoadedFeature("Vanish", vanish); registry.registerAvailableFeature(descriptor("Queue", "1.0", Set.of(), Set.of())); @@ -308,8 +308,8 @@ void suggestionAndRenderingHelpersReturnExpectedValues() { @Test void statusAndListHelpersEmitMessages() { - VelocityBaseFeature<?> queue = feature("Queue", "1.0", List.of(), List.of()); - VelocityBaseFeature<?> friends = feature("Friends", "2.0", List.of(), List.of()); + VelocityBaseFeature queue = feature("Queue", "1.0", List.of(), List.of()); + VelocityBaseFeature friends = feature("Friends", "2.0", List.of(), List.of()); registry.registerLoadedFeature("Queue", queue); registry.registerLoadedFeature("Friends", friends); @@ -372,8 +372,8 @@ void commandTreeEnforcesRootAndSubcommandPermissions() throws Exception { verify(sender, times(1)).sendMessage(any(Component.class)); } - private VelocityBaseFeature<?> feature(String name, String version, List<String> pluginDeps, List<String> featureDeps) { - VelocityBaseFeature<?> feature = mock(VelocityBaseFeature.class); + private VelocityBaseFeature feature(String name, String version, List<String> pluginDeps, List<String> featureDeps) { + VelocityBaseFeature feature = mock(VelocityBaseFeature.class); when(feature.getFeatureName()).thenReturn(name); when(feature.getFeatureVersion()).thenReturn(version); when(feature.getPluginDependencies()).thenReturn(pluginDeps); @@ -385,7 +385,7 @@ private VelocityBaseFeature<?> feature(String name, String version, List<String> FeatureListenerManager listeners = mock(FeatureListenerManager.class); FeatureDataManager data = mock(FeatureDataManager.class); - when(cmd.getRegisteredCommands()).thenReturn(Map.of(name.toLowerCase(), mock(nl.hauntedmc.proxyfeatures.api.command.FeatureCommand.class))); + when(cmd.getRegisteredCommands()).thenReturn(Map.of(name.toLowerCase(), mock(nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand.class))); when(task.getActiveTaskCount()).thenReturn(1); when(listeners.getRegisteredListenerCount()).thenReturn(2); when(data.getActiveConnectionCount()).thenReturn(3); @@ -402,9 +402,10 @@ private VelocityBaseFeature<?> feature(String name, String version, List<String> private FeatureDescriptor descriptor(String name, String version, Set<String> featureDeps, Set<String> pluginDeps) { return new FeatureDescriptor( name, - VelocityBaseFeature.class.getName(), name, version, + VelocityBaseFeature.class, + ignored -> mock(VelocityBaseFeature.class), featureDeps, pluginDeps ); diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/command/brigadier/BrigadierCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/brigadier/BrigadierCommandTest.java similarity index 93% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/command/brigadier/BrigadierCommandTest.java rename to proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/brigadier/BrigadierCommandTest.java index 7d1e41c6..165ad255 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/command/brigadier/BrigadierCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/command/brigadier/BrigadierCommandTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.command.brigadier; +package nl.hauntedmc.proxyfeatures.framework.command.brigadier; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.velocitypowered.api.command.CommandSource; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandlerTest.java index 46cccc73..bb954a72 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/FeatureConfigHandlerTest.java @@ -2,14 +2,14 @@ import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -38,21 +38,33 @@ void featureScopedReadsWritesAndGlobalAccessorsWork() { } @Test - void injectDefaultsPreservesUnknownKeysRepairsKnownTypeMismatchesAndReloadPreservesPersistedValues() { + void injectDefaultsPreservesUnknownAndMismatchedOperatorValues() { MainConfigHandler main = createMainHandler(); FeatureConfigHandler handler = main.openFeatureConfig("Queue"); handler.put("unknown", "keep-me"); handler.put("threshold", "wrong-type"); - - handler.injectDefaults(new ConfigMap() + ConfigMap defaults = new ConfigMap() .put("enabled", true) .put("threshold", 5) - .put("settings.mode", "normal")); + .put("settings.mode", "normal"); - handler.reloadConfig(); + FeatureConfigurationException failure = assertThrows( + FeatureConfigurationException.class, + () -> handler.injectDefaults(defaults) + ); + assertEquals("Queue", failure.featureName()); + assertEquals(1, failure.mismatches().size()); + handler.reloadConfig(); assertEquals("keep-me", handler.get("unknown", String.class)); + assertEquals("wrong-type", handler.get("threshold", String.class)); + assertFalse(handler.node("enabled").isPresent()); + assertFalse(handler.node("settings").isPresent()); + + handler.put("threshold", 5); + handler.injectDefaults(defaults); + handler.reloadConfig(); assertEquals(5, handler.get("threshold", Integer.class)); assertEquals("normal", handler.get("settings.mode", String.class)); } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandlerTest.java index 696553f1..ba4db7d2 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/config/MainConfigHandlerTest.java @@ -2,8 +2,8 @@ import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/extension/DefaultMotdExtensionsTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/extension/DefaultMotdExtensionsTest.java new file mode 100644 index 00000000..0e2cc66f --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/extension/DefaultMotdExtensionsTest.java @@ -0,0 +1,51 @@ +package nl.hauntedmc.proxyfeatures.framework.extension; + +import nl.hauntedmc.proxyfeatures.api.extension.MotdContext; +import nl.hauntedmc.proxyfeatures.api.extension.MotdContribution; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultMotdExtensionsTest { + private final MotdContext context = new MotdContext(InetAddress.getLoopbackAddress(), 763); + + @Test + void resolveRegistrationPrefersHighestPriority() { + DefaultMotdExtensions extensions = new DefaultMotdExtensions(); + extensions.register("low", 10, ignored -> Optional.of(MotdContribution.secondLine("Low"))); + extensions.register("high", 100, ignored -> Optional.of(MotdContribution.secondLine("High"))); + assertEquals("High", extensions.resolve(context).orElseThrow().secondLine().orElseThrow()); + } + + @Test + void resolveRegistrationUsesDeterministicKeyOrderWhenPriorityMatches() { + DefaultMotdExtensions extensions = new DefaultMotdExtensions(); + extensions.register("zeta", 50, ignored -> Optional.of(MotdContribution.secondLine("Zeta"))); + extensions.register("alpha", 50, ignored -> Optional.of(MotdContribution.secondLine("Alpha"))); + assertEquals("Alpha", extensions.resolve(context).orElseThrow().secondLine().orElseThrow()); + } + + @Test + void resolveSkipsBlankOrFailingSuppliers() { + DefaultMotdExtensions extensions = new DefaultMotdExtensions(); + extensions.register("broken", 100, ignored -> { + throw new IllegalStateException("boom"); + }); + extensions.register("blank", 90, ignored -> Optional.of(MotdContribution.secondLine(" "))); + extensions.register("usable", 10, ignored -> Optional.of(MotdContribution.secondLine("Ready"))); + assertEquals("Ready", extensions.resolve(context).orElseThrow().secondLine().orElseThrow()); + } + + @Test + void unregisterRemovesEntry() { + DefaultMotdExtensions extensions = new DefaultMotdExtensions(); + var registration = extensions.register( + "maintenance", 100, ignored -> Optional.of(MotdContribution.secondLine("Maintenance"))); + registration.close(); + assertTrue(extensions.resolve(context).isEmpty()); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactoryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactoryTest.java index 2caf2a89..979c3c10 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactoryTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/feature/FeatureScopeFactoryTest.java @@ -1,8 +1,7 @@ package nl.hauntedmc.proxyfeatures.framework.feature; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; -import nl.hauntedmc.proxyfeatures.features.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.loader.FeatureDescriptor; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureApiManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureCacheManager; @@ -15,7 +14,7 @@ import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; import org.junit.jupiter.api.Test; -import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertNotSame; @@ -56,9 +55,12 @@ void createContextCachesFeatureScopedResourcesButBuildsFreshLifecycleManagers() } ); - TestMeta meta = new TestMeta("Queue"); - FeatureContext<TestMeta> first = factory.createContext(meta); - FeatureContext<TestMeta> second = factory.createContext(meta); + FeatureDescriptor descriptor = new FeatureDescriptor( + "Queue", "Queue", "1.0", VelocityBaseFeature.class, + context -> mock(VelocityBaseFeature.class), Set.of(), Set.of() + ); + FeatureContext first = factory.createContext(descriptor); + FeatureContext second = factory.createContext(descriptor); assertSame(configHandler, first.configHandler()); assertSame(configHandler, second.configHandler()); @@ -73,26 +75,4 @@ void createContextCachesFeatureScopedResourcesButBuildsFreshLifecycleManagers() org.junit.jupiter.api.Assertions.assertEquals(1, loggerCounter.get()); verify(dataManager, times(2)).bindToFeature("Queue"); } - - private record TestMeta(String featureName) implements BaseMeta { - @Override - public String getFeatureName() { - return featureName; - } - - @Override - public String getFeatureVersion() { - return "1.0"; - } - - @Override - public List<String> getDependencies() { - return List.of(); - } - - @Override - public List<String> getPluginDependencies() { - return List.of(); - } - } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerActivationFailureTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerActivationFailureTest.java new file mode 100644 index 00000000..574a312c --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerActivationFailureTest.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.proxyfeatures.framework.lifecycle; + +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.framework.service.CapabilityRegistration; +import nl.hauntedmc.proxyfeatures.framework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.proxyfeatures.framework.service.InternalServiceRegistry; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class FeatureApiManagerActivationFailureTest { + + @Test + void failedActivationWithdrawsCapabilitiesPublishedEarlierInTheSameStage() { + DefaultCapabilityRegistry publicRegistry = new DefaultCapabilityRegistry(); + InternalServiceRegistry internalRegistry = new InternalServiceRegistry(); + CapabilityRegistration conflict = internalRegistry.register( + FeatureId.of("existing"), + Runnable.class, + mock(Runnable.class) + ); + + FeatureApiManager manager = new FeatureApiManager(); + manager.bindRegistry(publicRegistry, internalRegistry, "feature"); + manager.registerService(PresenceApi.class, mock(PresenceApi.class)); + manager.registerInternalService(Runnable.class, mock(Runnable.class)); + + assertThrows(IllegalStateException.class, manager::activateServices); + assertTrue(publicRegistry.reference(PresenceApi.class).get().isEmpty()); + assertFalse(manager.isActive()); + assertEquals(2, manager.getRegisteredServiceCount()); + + conflict.close(); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerTest.java index b3482208..05a1c37c 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureApiManagerTest.java @@ -1,128 +1,128 @@ package nl.hauntedmc.proxyfeatures.framework.lifecycle; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceDirectory; -import nl.hauntedmc.proxyfeatures.test.TestFeatureServiceDirectory; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.framework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.proxyfeatures.framework.service.InternalServiceRegistry; import org.junit.jupiter.api.Test; -import java.util.Optional; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; class FeatureApiManagerTest { - @Test - void registerWithoutDataRegistryBindingStillTracksOwnershipForCleanup() { + void managerMustBeBoundBeforePublishing() { FeatureApiManager manager = new FeatureApiManager(); - - manager.registerService(String.class, "value"); - assertEquals(1, manager.getRegisteredServiceCount()); - - manager.unregisterAllServices(); - assertEquals(0, manager.getRegisteredServiceCount()); + assertThrows(IllegalStateException.class, + () -> manager.registerService(PresenceApi.class, mock(PresenceApi.class))); } @Test - void unregisterServiceOnlyDetachesTrackedRegistration() { - FeatureApiManager manager = new FeatureApiManager(); - - manager.registerService(String.class, "value"); - manager.unregisterService(String.class); + void differentOwnersCannotPublishSameCapability() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + InternalServiceRegistry internal = new InternalServiceRegistry(); + FeatureApiManager first = manager(registry, internal, "first"); + FeatureApiManager second = manager(registry, internal, "second"); + PresenceApi service = mock(PresenceApi.class); + first.registerService(PresenceApi.class, service); + first.activateServices(); + second.registerService(PresenceApi.class, mock(PresenceApi.class)); + + assertThrows(IllegalStateException.class, second::activateServices); + registry.reference(PresenceApi.class).require().snapshot(); + verify(service).snapshot(); - assertEquals(0, manager.getRegisteredServiceCount()); + first.unregisterAllServices(); + assertTrue(registry.reference(PresenceApi.class).get().isEmpty()); } @Test - void differentOwnersCannotPublishSameApiType() { - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - FeatureServiceDirectory directory = new TestFeatureServiceDirectory(); - when(dataRegistry.featureServices()).thenReturn(directory); - FeatureApiManager first = new FeatureApiManager(); - FeatureApiManager second = new FeatureApiManager(); - first.bindDataRegistryCatalog("ProxyFeatures", "First", () -> Optional.of(dataRegistry)); - second.bindDataRegistryCatalog("ProxyFeatures", "Second", () -> Optional.of(dataRegistry)); - - first.registerService(String.class, "first"); - - assertThrows(IllegalStateException.class, () -> second.registerService(String.class, "second")); - assertEquals(0, second.getRegisteredServiceCount()); - assertEquals("first", directory.find(String.class).orElseThrow()); - - first.unregisterAllServices(); - assertTrue(directory.find(String.class).isEmpty()); + void replacementCleanupAndIdempotencyAreOwned() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + FeatureApiManager manager = manager(registry, new InternalServiceRegistry(), "example"); + PresenceApi first = mock(PresenceApi.class); + PresenceApi second = mock(PresenceApi.class); + + manager.registerService(PresenceApi.class, first); + manager.registerService(PresenceApi.class, first); + assertEquals(1, manager.getRegisteredServiceCount()); + assertTrue(registry.reference(PresenceApi.class).get().isEmpty()); + manager.activateServices(); + manager.registerService(PresenceApi.class, second); + registry.reference(PresenceApi.class).require().snapshot(); + verify(second).snapshot(); + + manager.unregisterService(PresenceApi.class); + assertTrue(registry.reference(PresenceApi.class).get().isEmpty()); } @Test - void registerAndCleanupManageDataRegistryCatalogServices() { - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - FeatureServiceDirectory directory = new TestFeatureServiceDirectory(); - when(dataRegistry.featureServices()).thenReturn(directory); - - FeatureApiManager manager = new FeatureApiManager(); - manager.bindDataRegistryCatalog("ProxyFeatures", "Example", () -> Optional.of(dataRegistry)); - - manager.registerService(String.class, "value"); - - assertEquals("value", directory.find(String.class).orElseThrow()); - assertEquals("Example", directory.describe(String.class).orElseThrow().ownerFeature()); + void internalPortsAreNotPublishedPublicly() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + InternalServiceRegistry internal = new InternalServiceRegistry(); + FeatureApiManager manager = manager(registry, internal, "example"); + Runnable port = mock(Runnable.class); + + manager.registerInternalService(Runnable.class, port); + assertTrue(internal.find(Runnable.class).isEmpty()); + manager.activateServices(); + assertSame(port, internal.require(Runnable.class)); + assertTrue(registry.availableTypes().isEmpty()); manager.unregisterAllServices(); - - assertTrue(directory.find(String.class).isEmpty()); + assertTrue(internal.find(Runnable.class).isEmpty()); } @Test - void nullDataRegistrySupplierResultSkipsCatalogPublication() { - FeatureApiManager manager = new FeatureApiManager(); - manager.bindDataRegistryCatalog("ProxyFeatures", "Example", () -> null); - - manager.registerService(String.class, "value"); - + void deactivationWithdrawsServicesBeforeDefinitionsAreCleared() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + FeatureApiManager manager = manager(registry, new InternalServiceRegistry(), "example"); + PresenceApi service = mock(PresenceApi.class); + + manager.registerService(PresenceApi.class, service); + manager.activateServices(); + registry.reference(PresenceApi.class).require().snapshot(); + verify(service).snapshot(); + + manager.deactivateServices(); + assertTrue(registry.reference(PresenceApi.class).get().isEmpty()); assertEquals(1, manager.getRegisteredServiceCount()); - } - - @Test - void replacingServiceClosesPreviousDataRegistryHandle() { - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - FeatureServiceDirectory directory = new TestFeatureServiceDirectory(); - when(dataRegistry.featureServices()).thenReturn(directory); - - FeatureApiManager manager = new FeatureApiManager(); - manager.bindDataRegistryCatalog("ProxyFeatures", "Example", () -> Optional.of(dataRegistry)); - - manager.registerService(String.class, "first"); - manager.registerService(String.class, "second"); - assertEquals("second", directory.find(String.class).orElseThrow()); + manager.activateServices(); + registry.reference(PresenceApi.class).require().snapshot(); + verify(service, times(2)).snapshot(); + } - manager.unregisterService(String.class); - assertTrue(directory.find(String.class).isEmpty()); + @Test + void failedActiveReplacementLeavesPreviousServicePublished() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + FeatureApiManager manager = manager(registry, new InternalServiceRegistry(), "example"); + PresenceApi original = mock(PresenceApi.class); + manager.registerService(PresenceApi.class, original); + manager.activateServices(); + + assertThrows(ClassCastException.class, () -> registerUnchecked(manager, PresenceApi.class, new Object())); + registry.reference(PresenceApi.class).require().snapshot(); + verify(original).snapshot(); } - @Test - void registeringSameInstanceIsIdempotent() { - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - FeatureServiceDirectory directory = new TestFeatureServiceDirectory(); - when(dataRegistry.featureServices()).thenReturn(directory); + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void registerUnchecked(FeatureApiManager manager, Class<?> type, Object instance) { + manager.registerService((Class) type, instance); + } + private static FeatureApiManager manager( + DefaultCapabilityRegistry registry, + InternalServiceRegistry internal, + String owner + ) { FeatureApiManager manager = new FeatureApiManager(); - manager.bindDataRegistryCatalog("ProxyFeatures", "Example", () -> Optional.of(dataRegistry)); - Object service = new Object(); - - manager.registerService(Object.class, service); - manager.registerService(Object.class, service); - - assertSame(service, directory.find(Object.class).orElseThrow()); - assertEquals(1, manager.getRegisteredServiceCount()); - - manager.unregisterService(Object.class); - - assertTrue(directory.find(Object.class).isEmpty()); + manager.bindRegistry(registry, internal, owner); + return manager; } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManagerTest.java index edf5fbef..759441e5 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCacheManagerTest.java @@ -2,7 +2,7 @@ import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheDirectory; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheDirectory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManagerTest.java index 5f83415a..d5236e4d 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureCommandManagerTest.java @@ -6,8 +6,8 @@ import com.velocitypowered.api.command.CommandSource; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; -import nl.hauntedmc.proxyfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.proxyfeatures.framework.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.framework.command.brigadier.BrigadierCommand; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -16,20 +16,30 @@ import java.util.List; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class FeatureCommandManagerTest { private ProxyFeatures plugin; private CommandManager commandManager; + private CommandOwnershipRegistry ownershipRegistry; private FeatureCommandManager manager; @BeforeEach void setUp() { plugin = mock(ProxyFeatures.class); commandManager = mock(CommandManager.class); + ownershipRegistry = new CommandOwnershipRegistry(); when(plugin.getCommandManager()).thenReturn(commandManager); when(plugin.getLogger()).thenReturn(ComponentLogger.logger("FeatureCommandManagerTest")); @@ -40,17 +50,15 @@ void setUp() { return new TestCommandMetaBuilder(command.getNode().getName()); }); - manager = new FeatureCommandManager(plugin); + manager = new FeatureCommandManager(plugin, ownershipRegistry, "Queue"); } @Test - void registersSimpleCommandAndSkipsDuplicate() { - FeatureCommand command = mock(FeatureCommand.class); - when(command.getName()).thenReturn("queue"); - when(command.getAliases()).thenReturn(new String[]{" q ", "", "queue", "q", null}); + void registersSimpleCommandAndRejectsDuplicate() { + FeatureCommand command = simple("queue", " q ", "", "queue", "q", null); manager.registerFeatureCommand(command); - manager.registerFeatureCommand(command); + assertThrows(CommandRegistrationException.class, () -> manager.registerFeatureCommand(command)); verify(commandManager, times(1)).register(any(CommandMeta.class), eq(command)); assertEquals(1, manager.getRegisteredCommandCount()); @@ -58,175 +66,110 @@ void registersSimpleCommandAndSkipsDuplicate() { } @Test - void registerSimpleCommandHandlesRegistrationFailure() { - FeatureCommand command = mock(FeatureCommand.class); - when(command.getName()).thenReturn("queue"); - when(command.getAliases()).thenReturn(new String[0]); + void registrationFailureIsFatalAndReleasesOwnership() { + FeatureCommand command = simple("queue"); when(commandManager.metaBuilder("queue")).thenThrow(new RuntimeException("boom")); - manager.registerFeatureCommand(command); + assertThrows(CommandRegistrationException.class, () -> manager.registerFeatureCommand(command)); assertEquals(0, manager.getRegisteredCommandCount()); + assertEquals(0, ownershipRegistry.size()); } @Test - void registerSimpleCommandAllowsExternalOverride() { - FeatureCommand command = mock(FeatureCommand.class); - when(command.getName()).thenReturn("glist"); - when(command.getAliases()).thenReturn(new String[0]); - - manager.registerFeatureCommand(command); - - assertEquals(1, manager.getRegisteredCommandCount()); - verify(commandManager).register(any(CommandMeta.class), eq(command)); - } - - @Test - void registerSimpleCommandSkipsFrameworkOwnedAliasCollision() { - FeatureCommand first = mock(FeatureCommand.class); - when(first.getName()).thenReturn("queue"); - when(first.getAliases()).thenReturn(new String[]{"q"}); + void globalAliasCollisionAcrossFeatureManagersIsFatal() { + FeatureCommand first = simple("queue", "q"); manager.registerFeatureCommand(first); - FeatureCommand second = mock(FeatureCommand.class); - when(second.getName()).thenReturn("other"); - when(second.getAliases()).thenReturn(new String[]{"q"}); - - manager.registerFeatureCommand(second); + FeatureCommand second = simple("other", "q"); + FeatureCommandManager other = new FeatureCommandManager(plugin, ownershipRegistry, "Other"); - assertEquals(1, manager.getRegisteredCommandCount()); + assertThrows(CommandRegistrationException.class, () -> other.registerFeatureCommand(second)); + assertEquals(0, other.getRegisteredCommandCount()); verify(commandManager, times(1)).register(any(CommandMeta.class), any(FeatureCommand.class)); } @Test - void unregisterSimpleCommandHandlesUnknownAndException() { - FeatureCommand command = mock(FeatureCommand.class); - when(command.getName()).thenReturn("queue"); - when(command.getAliases()).thenReturn(new String[]{"q"}); + void unregisterFailureRetainsTrackingAndOwnershipUntilVelocityDetachesTheCommand() { + FeatureCommand command = simple("queue", "q"); manager.registerFeatureCommand(command); + doThrow(new RuntimeException("boom")).doNothing() + .when(commandManager).unregister(any(CommandMeta.class)); - doThrow(new RuntimeException("boom")).when(commandManager).unregister(any(CommandMeta.class)); - manager.unregisterCommand("queue"); - manager.unregisterCommand("queue"); - - assertEquals(0, manager.getRegisteredCommandCount()); - verify(commandManager).unregister(any(CommandMeta.class)); - } - - @Test - void unregisterAllSimpleCommandsIteratesSnapshot() { - FeatureCommand one = mock(FeatureCommand.class); - when(one.getName()).thenReturn("one"); - when(one.getAliases()).thenReturn(new String[0]); - FeatureCommand two = mock(FeatureCommand.class); - when(two.getName()).thenReturn("two"); - when(two.getAliases()).thenReturn(new String[0]); - - manager.registerFeatureCommand(one); - manager.registerFeatureCommand(two); - assertEquals(2, manager.getRegisteredCommandCount()); + assertThrows(CommandRegistrationException.class, () -> manager.unregisterCommand("queue")); + assertEquals(1, manager.getRegisteredCommandCount()); + assertEquals(2, ownershipRegistry.size()); - manager.unregisterAllCommands(); + manager.unregisterCommand("queue"); assertEquals(0, manager.getRegisteredCommandCount()); + assertEquals(0, ownershipRegistry.size()); } @Test - void registersBrigadierCommandAndSkipsDuplicate() { - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("proxyfeatures"); - when(brigadier.aliases()).thenReturn(List.of("pf")); - when(brigadier.buildTree()).thenReturn(LiteralArgumentBuilder.<CommandSource>literal("proxyfeatures").build()); - - manager.registerBrigadierCommand(brigadier); - manager.registerBrigadierCommand(brigadier); + void unregisterAllSimpleCommandsAggregatesFailures() { + manager.registerFeatureCommand(simple("one")); + manager.registerFeatureCommand(simple("two")); + doThrow(new RuntimeException("boom")).when(commandManager).unregister(any(CommandMeta.class)); - assertEquals(1, manager.getRegisteredBrigadierCommandCount()); - verify(commandManager, times(1)).register(any(CommandMeta.class), any(com.velocitypowered.api.command.BrigadierCommand.class)); + CommandRegistrationException failure = assertThrows( + CommandRegistrationException.class, + manager::unregisterAllCommands + ); + assertEquals(1, failure.getSuppressed().length); + assertEquals(2, manager.getRegisteredCommandCount()); + assertEquals(2, ownershipRegistry.size()); } @Test - void registerBrigadierAllowsExternalOverride() { - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("glist"); - when(brigadier.aliases()).thenReturn(List.of()); - when(brigadier.buildTree()).thenReturn(LiteralArgumentBuilder.<CommandSource>literal("glist").build()); + void registersBrigadierCommandAndRejectsDuplicate() { + BrigadierCommand command = brigadier("proxyfeatures", List.of("pf")); - manager.registerBrigadierCommand(brigadier); + manager.registerBrigadierCommand(command); + assertThrows(CommandRegistrationException.class, () -> manager.registerBrigadierCommand(command)); assertEquals(1, manager.getRegisteredBrigadierCommandCount()); - verify(brigadier).buildTree(); - verify(commandManager).register(any(CommandMeta.class), any(com.velocitypowered.api.command.BrigadierCommand.class)); + verify(commandManager, times(1)).register( + any(CommandMeta.class), + any(com.velocitypowered.api.command.BrigadierCommand.class) + ); } @Test - void registerBrigadierSkipsFrameworkOwnedAliasCollision() { - FeatureCommand simple = mock(FeatureCommand.class); - when(simple.getName()).thenReturn("queue"); - when(simple.getAliases()).thenReturn(new String[]{"q"}); - manager.registerFeatureCommand(simple); - - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("other"); - when(brigadier.aliases()).thenReturn(List.of("q")); - - manager.registerBrigadierCommand(brigadier); + void brigadierCollisionIsRejectedBeforeTreeConstruction() { + manager.registerFeatureCommand(simple("queue", "q")); + BrigadierCommand command = brigadier("other", List.of("q")); + FeatureCommandManager other = new FeatureCommandManager(plugin, ownershipRegistry, "Other"); - assertEquals(0, manager.getRegisteredBrigadierCommandCount()); - verify(brigadier, never()).buildTree(); + assertThrows(CommandRegistrationException.class, () -> other.registerBrigadierCommand(command)); + verify(command, never()).buildTree(); } @Test - void registerBrigadierHandlesFailure() { - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("proxyfeatures"); - when(brigadier.aliases()).thenReturn(List.of()); - when(brigadier.buildTree()).thenThrow(new RuntimeException("boom")); + void brigadierBuildFailureIsFatalAndReleasesOwnership() { + BrigadierCommand command = mock(BrigadierCommand.class); + when(command.name()).thenReturn("proxyfeatures"); + when(command.aliases()).thenReturn(List.of()); + when(command.buildTree()).thenThrow(new RuntimeException("boom")); - manager.registerBrigadierCommand(brigadier); + assertThrows(CommandRegistrationException.class, () -> manager.registerBrigadierCommand(command)); assertEquals(0, manager.getRegisteredBrigadierCommandCount()); + assertEquals(0, ownershipRegistry.size()); } @Test - void unregisterBrigadierByMetaHandlesFailurePathAndUnknown() { - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("proxyfeatures"); - when(brigadier.aliases()).thenReturn(List.of("pf")); - when(brigadier.buildTree()).thenReturn(LiteralArgumentBuilder.<CommandSource>literal("proxyfeatures").build()); - manager.registerBrigadierCommand(brigadier); - - doThrow(new RuntimeException("boom")).when(commandManager).unregister(any(CommandMeta.class)); - manager.unregisterBrigadierCommand("proxyfeatures"); // failure still clears local registration - assertEquals(0, manager.getRegisteredBrigadierCommandCount()); - verify(commandManager).unregister(any(CommandMeta.class)); - - manager.unregisterBrigadierCommand("missing"); - } + void unregisterAllBrigadierCommandsCleansEveryRegistration() { + manager.registerBrigadierCommand(brigadier("one", List.of("o"))); + manager.registerBrigadierCommand(brigadier("two", List.of("t"))); - @Test - void unregisterAllBrigadierCommandsHandlesEmptyAndNonEmpty() { manager.unregisterAllBrigadierCommands(); - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("proxyfeatures"); - when(brigadier.aliases()).thenReturn(List.of()); - when(brigadier.buildTree()).thenReturn(LiteralArgumentBuilder.<CommandSource>literal("proxyfeatures").build()); - manager.registerBrigadierCommand(brigadier); - - manager.unregisterAllBrigadierCommands(); assertEquals(0, manager.getRegisteredBrigadierCommandCount()); + assertEquals(0, ownershipRegistry.size()); } @Test void reportingMethodsExposeCombinedCountsAndNames() { - FeatureCommand command = mock(FeatureCommand.class); - when(command.getName()).thenReturn("queue"); - when(command.getAliases()).thenReturn(new String[0]); - manager.registerFeatureCommand(command); - - BrigadierCommand brigadier = mock(BrigadierCommand.class); - when(brigadier.name()).thenReturn("proxyfeatures"); - when(brigadier.aliases()).thenReturn(List.of()); - when(brigadier.buildTree()).thenReturn(LiteralArgumentBuilder.<CommandSource>literal("proxyfeatures").build()); - manager.registerBrigadierCommand(brigadier); + manager.registerFeatureCommand(simple("queue")); + manager.registerBrigadierCommand(brigadier("proxyfeatures", List.of())); assertEquals(1, manager.getRegisteredCommands().size()); assertEquals(1, manager.getRegisteredBrigadierCommands().size()); @@ -234,8 +177,29 @@ void reportingMethodsExposeCombinedCountsAndNames() { assertEquals(Set.of("queue", "proxyfeatures"), manager.getAllRegisteredCommandNames()); } - private static final class TestCommandMetaBuilder implements CommandMeta.Builder { + @Test + void managerCannotBeReboundAfterRegistration() { + manager.bindToFeature("Queue"); + manager.registerFeatureCommand(simple("queue")); + assertThrows(IllegalStateException.class, () -> manager.bindToFeature("Other")); + } + private static FeatureCommand simple(String name, String... aliases) { + FeatureCommand command = mock(FeatureCommand.class); + when(command.getName()).thenReturn(name); + when(command.getAliases()).thenReturn(aliases); + return command; + } + + private static BrigadierCommand brigadier(String name, List<String> aliases) { + BrigadierCommand command = mock(BrigadierCommand.class); + when(command.name()).thenReturn(name); + when(command.aliases()).thenReturn(aliases); + when(command.buildTree()).thenReturn(LiteralArgumentBuilder.<CommandSource>literal(name).build()); + return command; + } + + private static final class TestCommandMetaBuilder implements CommandMeta.Builder { private final LinkedHashSet<String> aliases = new LinkedHashSet<>(); private Object plugin; @@ -269,7 +233,6 @@ public CommandMeta build() { } private record TestCommandMeta(Set<String> aliases, Object plugin) implements CommandMeta { - @Override public Collection<String> getAliases() { return aliases; @@ -285,5 +248,4 @@ public Object getPlugin() { return plugin; } } - } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManagerTest.java index 9b92f863..514bab7b 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureLifecycleManagerTest.java @@ -4,17 +4,18 @@ import org.mockito.InOrder; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.doThrow; class FeatureLifecycleManagerTest { @Test - void cleanupDelegatesToAllManagersInOrderAndExposesInstances() { + void cleanupQuiescesIngressBeforeReleasingResourcesAndIsIdempotent() { FeatureTaskManager taskManager = mock(FeatureTaskManager.class); FeatureCommandManager commandManager = mock(FeatureCommandManager.class); FeatureListenerManager listenerManager = mock(FeatureListenerManager.class); @@ -31,6 +32,7 @@ void cleanupDelegatesToAllManagersInOrderAndExposesInstances() { apiManager ); + assertEquals(FeatureResourceState.OPEN, manager.state()); assertSame(taskManager, manager.getTaskManager()); assertSame(commandManager, manager.getCommandManager()); assertSame(listenerManager, manager.getListenerManager()); @@ -38,9 +40,15 @@ void cleanupDelegatesToAllManagersInOrderAndExposesInstances() { assertSame(cacheManager, manager.getCacheManager()); assertSame(apiManager, manager.getApiManager()); + manager.cleanup(); manager.cleanup(); InOrder order = inOrder(listenerManager, taskManager, commandManager, apiManager, dataManager, cacheManager); + order.verify(listenerManager).quiesce(); + order.verify(taskManager).quiesce(); + order.verify(commandManager).quiesce(); + order.verify(apiManager).quiesce(); + order.verify(cacheManager).quiesce(); order.verify(listenerManager).unregisterAllListeners(); order.verify(taskManager).cancelAllTasks(); order.verify(commandManager).unregisterAllCommands(); @@ -48,10 +56,12 @@ void cleanupDelegatesToAllManagersInOrderAndExposesInstances() { order.verify(apiManager).unregisterAllServices(); order.verify(dataManager).closeAllDataResources(); order.verify(cacheManager).cleanupAll(); + assertEquals(FeatureResourceState.CLOSED, manager.state()); + verify(taskManager, times(1)).cancelAllTasks(); } @Test - void cleanupContinuesAcrossFailuresAndSuppressesLaterErrors() { + void cleanupContinuesAcrossQuiesceAndResourceFailures() { FeatureTaskManager taskManager = mock(FeatureTaskManager.class); FeatureCommandManager commandManager = mock(FeatureCommandManager.class); FeatureListenerManager listenerManager = mock(FeatureListenerManager.class); @@ -59,8 +69,10 @@ void cleanupContinuesAcrossFailuresAndSuppressesLaterErrors() { FeatureCacheManager cacheManager = mock(FeatureCacheManager.class); FeatureApiManager apiManager = mock(FeatureApiManager.class); + RuntimeException quiesceFailure = new RuntimeException("quiesce"); RuntimeException listenerFailure = new RuntimeException("listener"); RuntimeException cacheFailure = new RuntimeException("cache"); + doThrow(quiesceFailure).when(taskManager).quiesce(); doThrow(listenerFailure).when(listenerManager).unregisterAllListeners(); doThrow(cacheFailure).when(cacheManager).cleanupAll(); @@ -74,9 +86,11 @@ void cleanupContinuesAcrossFailuresAndSuppressesLaterErrors() { ); RuntimeException thrown = assertThrows(RuntimeException.class, manager::cleanup); - assertSame(listenerFailure, thrown); - assertEquals(1, thrown.getSuppressed().length); - assertSame(cacheFailure, thrown.getSuppressed()[0]); + assertSame(quiesceFailure, thrown); + assertEquals(2, thrown.getSuppressed().length); + assertSame(listenerFailure, thrown.getSuppressed()[0]); + assertSame(cacheFailure, thrown.getSuppressed()[1]); + assertEquals(FeatureResourceState.CLOSED, manager.state()); verify(taskManager).cancelAllTasks(); verify(commandManager).unregisterAllCommands(); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManagerTest.java index 22acec0a..cd7ff7a1 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/lifecycle/FeatureListenerManagerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; class FeatureListenerManagerTest { @@ -32,4 +33,20 @@ void registerAndUnregisterTracksListenerCount() { verify(eventManager).unregisterListener(plugin, first); verify(eventManager).unregisterListener(plugin, second); } + + @Test + void failedUnregistrationRetainsTheListenerForARecoveryAttempt() { + ProxyFeatures plugin = mock(ProxyFeatures.class); + EventManager eventManager = mock(EventManager.class); + when(plugin.getEventManager()).thenReturn(eventManager); + Object listener = new Object(); + doThrow(new RuntimeException("boom")).when(eventManager).unregisterListener(plugin, listener); + + FeatureListenerManager manager = new FeatureListenerManager(plugin); + manager.registerListener(listener); + + assertThrows(RuntimeException.class, manager::unregisterAllListeners); + assertEquals(1, manager.getRegisteredListenerCount()); + assertEquals(FeatureResourceState.QUIESCING, manager.state()); + } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/BuiltInFeaturesTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/BuiltInFeaturesTest.java new file mode 100644 index 00000000..a8333e5d --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/BuiltInFeaturesTest.java @@ -0,0 +1,84 @@ +package nl.hauntedmc.proxyfeatures.framework.loader; + +import nl.hauntedmc.proxyfeatures.api.capability.admission.AdmissionApi; +import nl.hauntedmc.proxyfeatures.api.capability.player.NetworkLocationApi; +import nl.hauntedmc.proxyfeatures.api.extension.MotdExtensions; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureClassification; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuiltInFeaturesTest { + + @Test + void manifestExplicitlyDefinesEveryShippedFeatureExactlyOnce() { + assertEquals(33, BuiltInFeatures.definitions().size()); + Set<Class<?>> featureTypes = new HashSet<>(); + Set<String> featureNames = new HashSet<>(); + Map<Class<?>, BuiltInFeatures.Definition> providers = new HashMap<>(); + + for (BuiltInFeatures.Definition definition : BuiltInFeatures.definitions()) { + assertTrue(featureTypes.add(definition.implementationType()), + () -> "Duplicate feature implementation: " + definition.implementationType().getName()); + assertTrue(featureNames.add(definition.featureName().toLowerCase(java.util.Locale.ROOT)), + () -> "Duplicate feature name: " + definition.featureName()); + assertNotNull(definition.constructor()); + definition.providedCapabilities().forEach(capability -> + assertTrue(providers.putIfAbsent(capability, definition) == null, + () -> "Duplicate capability provider: " + capability.getName())); + } + + assertSame(provider(providers, AdmissionApi.class), feature("Capacity")); + assertSame(provider(providers, NetworkLocationApi.class), feature("AntiVPN")); + } + + @Test + void allReferencedCapabilitiesHaveExactlyOneBuiltInOrBootstrapProvider() { + Map<Class<?>, BuiltInFeatures.Definition> providers = new HashMap<>(); + BuiltInFeatures.definitions().forEach(definition -> + definition.providedCapabilities().forEach(capability -> providers.put(capability, definition))); + Set<Class<?>> bootstrapCapabilities = Set.of(MotdExtensions.class); + + BuiltInFeatures.definitions().forEach(definition -> { + Set<Class<?>> referenced = new HashSet<>(definition.requiredCapabilities()); + referenced.addAll(definition.optionalCapabilities()); + referenced.forEach(capability -> + assertTrue(providers.containsKey(capability) || bootstrapCapabilities.contains(capability), + () -> definition.featureName() + " references unprovided " + capability.getName())); + }); + } + + @Test + void providerClassificationsAlwaysPublishAtLeastOneCapability() { + BuiltInFeatures.definitions().forEach(definition -> { + if (definition.classification() == FeatureClassification.CAPABILITY_PROVIDER) { + assertFalse(definition.providedCapabilities().isEmpty()); + } else { + assertTrue(definition.providedCapabilities().isEmpty()); + } + }); + } + + private static BuiltInFeatures.Definition feature(String name) { + return BuiltInFeatures.definitions().stream() + .filter(definition -> definition.featureName().equals(name)) + .findFirst() + .orElseThrow(); + } + + private static BuiltInFeatures.Definition provider( + Map<Class<?>, BuiltInFeatures.Definition> providers, + Class<?> capability + ) { + return java.util.Objects.requireNonNull(providers.get(capability)); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptorTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptorTest.java index 61c41b7e..7ffa0774 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptorTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureDescriptorTest.java @@ -1,79 +1,58 @@ package nl.hauntedmc.proxyfeatures.framework.loader; -import nl.hauntedmc.proxyfeatures.api.feature.meta.BaseMeta; +import nl.hauntedmc.proxyfeatures.framework.feature.FeatureContext; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import org.junit.jupiter.api.Test; -import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; class FeatureDescriptorTest { @Test - void createMetaBuildsFreshInstancesFromMetaClass() { - FeatureDescriptor descriptor = new FeatureDescriptor( - "Queue", - "example.QueueFeature", - MutableMeta.class, - "Queue", - "1.0", - Set.of("Friends"), - Set.of("luckperms") - ); - - BaseMeta first = descriptor.createMeta(); - BaseMeta second = descriptor.createMeta(); - - assertNotSame(first, second); - assertTrue(first instanceof MutableMeta); - assertTrue(second instanceof MutableMeta); + void createUsesTypedConstructorAndValidatesResultType() { + FeatureContext context = org.mockito.Mockito.mock(FeatureContext.class); + TestFeature expected = org.mockito.Mockito.mock(TestFeature.class); + FeatureDescriptor descriptor = descriptor(ignored -> expected); + + assertSame(expected, descriptor.create(context)); + assertEquals(Set.of("Friends"), descriptor.featureDependencies()); + assertEquals(Set.of("luckperms"), descriptor.pluginDependencies()); } @Test - void createMetaFallsBackToImmutableSnapshotWhenOnlyDescriptorDataExists() { - FeatureDescriptor descriptor = new FeatureDescriptor( - "Queue", - "example.QueueFeature", - "Queue", - "1.0", - Set.of("Friends"), - Set.of("luckperms") - ); - - BaseMeta first = descriptor.createMeta(); - BaseMeta second = descriptor.createMeta(); + void createRejectsNullOrWrongImplementation() { + FeatureContext context = org.mockito.Mockito.mock(FeatureContext.class); + assertThrows(IllegalStateException.class, () -> descriptor(ignored -> null).create(context)); - assertNotSame(first, second); - assertEquals("Queue", first.getFeatureName()); - assertEquals("1.0", first.getFeatureVersion()); - assertEquals(List.of("Friends"), first.getDependencies()); - assertEquals(List.of("luckperms"), first.getPluginDependencies()); - assertEquals(first.getDependencies(), second.getDependencies()); - assertEquals(first.getPluginDependencies(), second.getPluginDependencies()); + FeatureDescriptor wrong = new FeatureDescriptor( + "Queue", "Queue", "1.0", TestFeature.class, + ignored -> org.mockito.Mockito.mock(OtherFeature.class), Set.of(), Set.of() + ); + assertThrows(IllegalStateException.class, () -> wrong.create(context)); } - public static final class MutableMeta implements BaseMeta { - @Override - public String getFeatureName() { - return "Queue"; - } - - @Override - public String getFeatureVersion() { - return "1.0"; - } + private static FeatureDescriptor descriptor( + java.util.function.Function<FeatureContext, ? extends VelocityBaseFeature> constructor + ) { + return new FeatureDescriptor( + "Queue", "Queue", "1.0", TestFeature.class, constructor, + Set.of("Friends", "Queue"), Set.of("luckperms") + ); + } - @Override - public List<String> getDependencies() { - return List.of(); + private abstract static class TestFeature extends VelocityBaseFeature { + private TestFeature(FeatureContext context) { + super(context); } + } - @Override - public List<String> getPluginDependencies() { - return List.of(); + private abstract static class OtherFeature extends VelocityBaseFeature { + private OtherFeature(FeatureContext context) { + super(context); } } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManagerTest.java index 0238d52e..5a65adb4 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureLoadManagerTest.java @@ -2,31 +2,44 @@ import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.command.CommandManager; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.feature.stateful.SnapshotState; -import nl.hauntedmc.proxyfeatures.api.feature.stateful.StatefulFeature; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.FeatureFactory; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.extension.MotdExtensions; +import nl.hauntedmc.proxyfeatures.framework.feature.stateful.SnapshotState; +import nl.hauntedmc.proxyfeatures.framework.feature.stateful.StatefulFeature; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigMap; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.framework.config.FeatureConfigHandler; import nl.hauntedmc.proxyfeatures.framework.config.MainConfigHandler; import nl.hauntedmc.proxyfeatures.framework.feature.FeatureScopeFactory; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureApiManager; import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleFactory; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.CommandOwnershipRegistry; +import nl.hauntedmc.proxyfeatures.framework.service.DefaultFeatureCatalog; +import nl.hauntedmc.proxyfeatures.framework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.proxyfeatures.framework.service.InternalServiceRegistry; +import nl.hauntedmc.proxyfeatures.framework.loader.disable.FeatureDisableResponse; import nl.hauntedmc.proxyfeatures.framework.loader.disable.FeatureDisableResult; import nl.hauntedmc.proxyfeatures.framework.loader.enable.FeatureEnableResult; +import nl.hauntedmc.proxyfeatures.framework.loader.reload.FeatureReloadResponse; import nl.hauntedmc.proxyfeatures.framework.loader.reload.FeatureReloadResult; import nl.hauntedmc.proxyfeatures.framework.loader.softreload.FeatureSoftReloadResult; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InOrder; -import org.mockito.MockedStatic; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.HashMap; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -40,9 +53,12 @@ class FeatureLoadManagerTest { private ComponentLogger logger; private PluginManager pluginManager; private FeatureScopeFactory featureScopeFactory; + private DefaultFeatureCatalog featureCatalog; + private final Map<String, AtomicReference<VelocityBaseFeature>> constructors = new HashMap<>(); @BeforeEach void setUp() { + constructors.clear(); plugin = mock(ProxyFeatures.class); mainConfig = mock(MainConfigHandler.class); localization = mock(LocalizationHandler.class); @@ -53,6 +69,15 @@ void setUp() { when(plugin.getLocalizationHandler()).thenReturn(localization); when(plugin.getLogger()).thenReturn(logger); when(plugin.getPluginManager()).thenReturn(pluginManager); + when(plugin.getCommandManager()).thenReturn(mock(CommandManager.class)); + when(plugin.getCommandOwnershipRegistry()).thenReturn(mock(CommandOwnershipRegistry.class)); + featureCatalog = mock(DefaultFeatureCatalog.class); + when(plugin.getFeatureCatalog()).thenReturn(featureCatalog); + DefaultCapabilityRegistry capabilityRegistry = new DefaultCapabilityRegistry(); + capabilityRegistry.register(FeatureId.of("core"), MotdExtensions.class, mock(MotdExtensions.class)); + when(plugin.capabilities()).thenReturn(capabilityRegistry); + when(plugin.getCapabilityRegistry()).thenReturn(capabilityRegistry); + when(plugin.getInternalServiceRegistry()).thenReturn(new InternalServiceRegistry()); when(mainConfig.isFeatureEnabled(anyString())).thenReturn(true); when(mainConfig.openFeatureConfig(anyString())).thenReturn(mock(FeatureConfigHandler.class)); when(localization.openFeatureLocalization(anyString())).thenReturn(localization); @@ -99,34 +124,28 @@ void enableFeatureCoversAllResultTypes() { assertEquals(FeatureEnableResult.NOT_FOUND, manager.enableFeature("Missing").result()); - VelocityBaseFeature<?> loaded = feature("Queue", List.of(), List.of()); + VelocityBaseFeature loaded = feature("Queue", List.of(), List.of()); manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); manager.getFeatureRegistry().registerLoadedFeature("Queue", loaded); assertEquals(FeatureEnableResult.ALREADY_LOADED, manager.enableFeature("Queue").result()); manager.getFeatureRegistry().deregisterLoadedFeature("Queue"); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(null); - assertEquals(FeatureEnableResult.FAILED, manager.enableFeature("Queue").result()); - } + constructorResult("Queue", null); + assertEquals(FeatureEnableResult.FAILED, manager.enableFeature("Queue").result()); clearRegistry(manager); manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of("luckperms"))); - VelocityBaseFeature<?> missingPlugin = feature("Queue", List.of(), List.of("luckperms")); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(missingPlugin); - when(pluginManager.getPlugin("luckperms")).thenReturn(Optional.empty()); - assertEquals(FeatureEnableResult.MISSING_PLUGIN_DEPENDENCY, manager.enableFeature("Queue").result()); - } + VelocityBaseFeature missingPlugin = feature("Queue", List.of(), List.of("luckperms")); + constructorResult("Queue", missingPlugin); + when(pluginManager.getPlugin("luckperms")).thenReturn(Optional.empty()); + assertEquals(FeatureEnableResult.MISSING_PLUGIN_DEPENDENCY, manager.enableFeature("Queue").result()); clearRegistry(manager); manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of("Friends"), Set.of())); - VelocityBaseFeature<?> missingFeature = feature("Queue", List.of("Friends"), List.of()); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(missingFeature); - assertEquals(FeatureEnableResult.MISSING_FEATURE_DEPENDENCY, manager.enableFeature("Queue").result()); - } + VelocityBaseFeature missingFeature = feature("Queue", List.of("Friends"), List.of()); + constructorResult("Queue", missingFeature); + assertEquals(FeatureEnableResult.MISSING_FEATURE_DEPENDENCY, manager.enableFeature("Queue").result()); clearRegistry(manager); manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); @@ -156,6 +175,24 @@ void enableFeatureRestoresPreviousConfigStateWhenLoadFails() { order.verify(mainConfig).setFeatureEnabled("Queue", false); } + @Test + void runtimeEnableAndDisableSynchronizeConfiguredCatalogState() { + FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); + clearRegistry(manager); + clearInvocations(featureCatalog); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); + + FeatureLoadManager successfulEnable = spy(manager); + doReturn(true).when(successfulEnable).loadFeature("Queue"); + assertEquals(FeatureEnableResult.SUCCESS, successfulEnable.enableFeature("Queue").result()); + verify(featureCatalog).setConfiguredEnabled(FeatureId.of("Queue"), true); + + VelocityBaseFeature queue = feature("Queue", List.of(), List.of()); + manager.getFeatureRegistry().registerLoadedFeature("Queue", queue); + assertEquals(FeatureDisableResult.SUCCESS, manager.disableFeature("Queue").result()); + verify(featureCatalog).setConfiguredEnabled(FeatureId.of("Queue"), false); + } + @Test void disableSoftReloadAndReloadHandleSuccessAndFailures() { FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); @@ -165,26 +202,36 @@ void disableSoftReloadAndReloadHandleSuccessAndFailures() { assertEquals(FeatureSoftReloadResult.NOT_LOADED, manager.softReloadFeature("Queue").result()); assertEquals(FeatureReloadResult.NOT_LOADED, manager.reloadFeature("Queue").result()); - VelocityBaseFeature<?> queue = feature("Queue", List.of(), List.of()); - VelocityBaseFeature<?> dependent = feature("Dependent", List.of("Queue"), List.of()); + VelocityBaseFeature queue = feature("Queue", List.of(), List.of()); + VelocityBaseFeature dependent = feature("Dependent", List.of("Queue"), List.of()); doThrow(new RuntimeException("cleanup failed")).when(dependent).cleanup(); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("Dependent", Set.of("Queue"), Set.of())); manager.getFeatureRegistry().registerLoadedFeature("Queue", queue); manager.getFeatureRegistry().registerLoadedFeature("Dependent", dependent); - assertEquals(FeatureDisableResult.SUCCESS, manager.disableFeature("Queue").result()); + FeatureDisableResponse dependentFailure = manager.disableFeature("Queue"); + assertEquals(FeatureDisableResult.FAILED, dependentFailure.result()); + assertEquals(Set.of("Dependent"), dependentFailure.alsoDisabledDependents()); + assertFalse(manager.getFeatureRegistry().isFeatureLoaded("Queue")); + assertFalse(manager.getFeatureRegistry().isFeatureLoaded("Dependent")); + verify(mainConfig).setFeatureEnabled("Dependent", false); + verify(mainConfig).setFeatureEnabled("Queue", false); clearRegistry(manager); - VelocityBaseFeature<?> failDisable = feature("Fail", List.of(), List.of()); + VelocityBaseFeature failDisable = feature("Fail", List.of(), List.of()); doThrow(new RuntimeException("boom")).when(failDisable).cleanup(); manager.getFeatureRegistry().registerLoadedFeature("Fail", failDisable); assertEquals(FeatureDisableResult.FAILED, manager.disableFeature("Fail").result()); + assertFalse(manager.getFeatureRegistry().isFeatureLoaded("Fail")); + verify(mainConfig).setFeatureEnabled("Fail", false); clearRegistry(manager); - VelocityBaseFeature<?> soft = feature("Soft", List.of(), List.of()); + VelocityBaseFeature soft = feature("Soft", List.of(), List.of()); manager.getFeatureRegistry().registerLoadedFeature("Soft", soft); assertEquals(FeatureSoftReloadResult.SUCCESS, manager.softReloadFeature("Soft").result()); - VelocityBaseFeature<?> softFail = feature("SoftFail", List.of(), List.of()); + VelocityBaseFeature softFail = feature("SoftFail", List.of(), List.of()); FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); when(softFail.getConfigHandler()).thenReturn(cfg); doThrow(new RuntimeException("boom")).when(cfg).reloadConfig(); @@ -193,29 +240,79 @@ void disableSoftReloadAndReloadHandleSuccessAndFailures() { } @Test - void reloadFeatureCoversFailedAndSuccessPathsIncludingDependents() { - FeatureLoadManager base = new FeatureLoadManager(plugin, featureScopeFactory); - FeatureLoadManager manager = spy(base); + void reloadFeatureRestartsDependencyGraphInTopologicalOrder() { + FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); clearRegistry(manager); - VelocityBaseFeature<?> queue = feature("Queue", List.of(), List.of()); - VelocityBaseFeature<?> dependent = feature("Dependent", List.of("Queue"), List.of()); - manager.getFeatureRegistry().registerLoadedFeature("Queue", queue); - manager.getFeatureRegistry().registerLoadedFeature("Dependent", dependent); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("A", Set.of(), Set.of())); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("B", Set.of("A"), Set.of())); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("C", Set.of("A"), Set.of())); + manager.getFeatureRegistry().registerAvailableFeature(descriptor("D", Set.of("B", "C"), Set.of())); + + VelocityBaseFeature oldA = feature("A", List.of(), List.of()); + VelocityBaseFeature oldB = feature("B", List.of("A"), List.of()); + VelocityBaseFeature oldC = feature("C", List.of("A"), List.of()); + VelocityBaseFeature oldD = feature("D", List.of("B", "C"), List.of()); + manager.getFeatureRegistry().registerLoadedFeature("A", oldA); + manager.getFeatureRegistry().registerLoadedFeature("B", oldB); + manager.getFeatureRegistry().registerLoadedFeature("C", oldC); + manager.getFeatureRegistry().registerLoadedFeature("D", oldD); + + VelocityBaseFeature newA = feature("A", List.of(), List.of()); + VelocityBaseFeature newB = feature("B", List.of("A"), List.of()); + VelocityBaseFeature newC = feature("C", List.of("A"), List.of()); + VelocityBaseFeature newD = feature("D", List.of("B", "C"), List.of()); + constructorResult("A", newA); + constructorResult("B", newB); + constructorResult("C", newC); + constructorResult("D", newD); + + FeatureReloadResponse response = manager.reloadFeature("A"); + + assertEquals(FeatureReloadResult.SUCCESS, response.result()); + assertEquals(Set.of("B", "C", "D"), response.reloadedDependents()); + InOrder stopOrder = inOrder(oldD, oldC, oldB, oldA); + stopOrder.verify(oldD).cleanup(); + stopOrder.verify(oldC).cleanup(); + stopOrder.verify(oldB).cleanup(); + stopOrder.verify(oldA).cleanup(); + InOrder startOrder = inOrder(newA, newB, newC, newD); + startOrder.verify(newA).initialize(); + startOrder.verify(newB).initialize(); + startOrder.verify(newC).initialize(); + startOrder.verify(newD).initialize(); + } - doReturn(false).when(manager).loadFeature("Queue"); - assertEquals(FeatureReloadResult.FAILED, manager.reloadFeature("Queue").result()); + @Test + void reloadFeatureRollsBackEntireGraphWhenReplacementFails() { + FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); + clearRegistry(manager); - manager.getFeatureRegistry().registerLoadedFeature("Queue", queue); - doThrow(new RuntimeException("boom")).when(mainConfig).reloadConfig(); - assertEquals(FeatureReloadResult.FAILED, manager.reloadFeature("Queue").result()); - reset(mainConfig); + VelocityBaseFeature replacementA = feature("A", List.of(), List.of()); + VelocityBaseFeature rollbackA = feature("A", List.of(), List.of()); + VelocityBaseFeature failingB = feature("B", List.of("A"), List.of()); + doThrow(new RuntimeException("replacement failed")).when(failingB).initialize(); + VelocityBaseFeature rollbackB = feature("B", List.of("A"), List.of()); + manager.getFeatureRegistry().registerAvailableFeature( + descriptorSequence("A", Set.of(), replacementA, rollbackA) + ); + manager.getFeatureRegistry().registerAvailableFeature( + descriptorSequence("B", Set.of("A"), failingB, rollbackB) + ); - manager.getFeatureRegistry().registerLoadedFeature("Queue", queue); - manager.getFeatureRegistry().registerLoadedFeature("Dependent", dependent); - reset(manager); - doReturn(true).when(manager).loadFeature(anyString()); - assertEquals(FeatureReloadResult.SUCCESS, manager.reloadFeature("Queue").result()); + VelocityBaseFeature oldA = feature("A", List.of(), List.of()); + VelocityBaseFeature oldB = feature("B", List.of("A"), List.of()); + manager.getFeatureRegistry().registerLoadedFeature("A", oldA); + manager.getFeatureRegistry().registerLoadedFeature("B", oldB); + + FeatureReloadResponse response = manager.reloadFeature("A"); + + assertEquals(FeatureReloadResult.FAILED, response.result()); + assertSame(rollbackA, manager.getFeatureRegistry().getLoadedFeature("A")); + assertSame(rollbackB, manager.getFeatureRegistry().getLoadedFeature("B")); + verify(replacementA).cleanup(); + verify(rollbackA).initialize(); + verify(rollbackB).initialize(); } @Test @@ -225,17 +322,15 @@ void reloadFeatureCapturesAndRestoresFrameworkManagedReloadState() { manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); - VelocityBaseFeature<?> oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + VelocityBaseFeature oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); TestReloadState snapshot = new TestReloadState("payload"); when(reloadStateful(oldFeature).captureReloadState()).thenReturn(Optional.of(snapshot)); - VelocityBaseFeature<?> reloadedFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + VelocityBaseFeature reloadedFeature = reloadStatefulFeature("Queue", List.of(), List.of()); manager.getFeatureRegistry().registerLoadedFeature("Queue", oldFeature); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(reloadedFeature); - assertEquals(FeatureReloadResult.SUCCESS, manager.reloadFeature("Queue").result()); - } + constructorResult("Queue", reloadedFeature); + assertEquals(FeatureReloadResult.SUCCESS, manager.reloadFeature("Queue").result()); verify(reloadStateful(reloadedFeature)).restoreReloadState(snapshot); assertSame(reloadedFeature, manager.getFeatureRegistry().getLoadedFeature("Queue")); @@ -248,16 +343,14 @@ void reloadFeatureCapturesStateBeforeCleanup() { manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); - VelocityBaseFeature<?> oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + VelocityBaseFeature oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); when(reloadStateful(oldFeature).captureReloadState()).thenReturn(Optional.of(new TestReloadState("payload"))); - VelocityBaseFeature<?> reloadedFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + VelocityBaseFeature reloadedFeature = reloadStatefulFeature("Queue", List.of(), List.of()); manager.getFeatureRegistry().registerLoadedFeature("Queue", oldFeature); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(reloadedFeature); - assertEquals(FeatureReloadResult.SUCCESS, manager.reloadFeature("Queue").result()); - } + constructorResult("Queue", reloadedFeature); + assertEquals(FeatureReloadResult.SUCCESS, manager.reloadFeature("Queue").result()); InOrder order = inOrder(oldFeature); order.verify(reloadStateful(oldFeature)).captureReloadState(); @@ -269,22 +362,25 @@ void reloadFeatureFailsAndCleansUpWhenReloadStateRestoreFails() { FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); clearRegistry(manager); - manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); - - VelocityBaseFeature<?> oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + VelocityBaseFeature oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); TestReloadState snapshot = new TestReloadState("payload"); when(reloadStateful(oldFeature).captureReloadState()).thenReturn(Optional.of(snapshot)); - VelocityBaseFeature<?> reloadedFeature = reloadStatefulFeature("Queue", List.of(), List.of()); - doThrow(new RuntimeException("restore failed")).when(reloadStateful(reloadedFeature)).restoreReloadState(snapshot); + VelocityBaseFeature replacementFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + doThrow(new RuntimeException("replacement restore failed")) + .when(reloadStateful(replacementFeature)).restoreReloadState(snapshot); + VelocityBaseFeature rollbackFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + doThrow(new RuntimeException("rollback restore failed")) + .when(reloadStateful(rollbackFeature)).restoreReloadState(snapshot); + manager.getFeatureRegistry().registerAvailableFeature( + descriptorSequence("Queue", Set.of(), replacementFeature, rollbackFeature) + ); manager.getFeatureRegistry().registerLoadedFeature("Queue", oldFeature); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(reloadedFeature); - assertEquals(FeatureReloadResult.FAILED, manager.reloadFeature("Queue").result()); - } + assertEquals(FeatureReloadResult.FAILED, manager.reloadFeature("Queue").result()); - verify(reloadedFeature).cleanup(); + verify(replacementFeature).cleanup(); + verify(rollbackFeature).cleanup(); assertFalse(manager.getFeatureRegistry().isFeatureLoaded("Queue")); } @@ -293,7 +389,7 @@ void reloadFeatureFailsWhenStateCaptureFails() { FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); clearRegistry(manager); - VelocityBaseFeature<?> oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); + VelocityBaseFeature oldFeature = reloadStatefulFeature("Queue", List.of(), List.of()); doThrow(new RuntimeException("capture failed")).when(reloadStateful(oldFeature)).captureReloadState(); manager.getFeatureRegistry().registerLoadedFeature("Queue", oldFeature); @@ -314,55 +410,42 @@ void loadFeatureCoversRegistrationDependencyAndInitializationBranches() { assertFalse(manager.loadFeature("Queue")); manager.getFeatureRegistry().deregisterLoadedFeature("Queue"); - VelocityBaseFeature<?> feature = feature("Queue", List.of(), List.of()); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(null); - assertFalse(manager.loadFeature("Queue")); - } + constructorResult("Queue", null); + assertFalse(manager.loadFeature("Queue")); clearInvocations(mainConfig, localization); when(mainConfig.isFeatureEnabled("Queue")).thenReturn(false); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - assertFalse(manager.loadFeature("Queue")); - factory.verifyNoInteractions(); - } + assertFalse(manager.loadFeature("Queue")); clearRegistry(manager); manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of("required"))); when(mainConfig.isFeatureEnabled("Queue")).thenReturn(true); when(pluginManager.getPlugin("required")).thenReturn(Optional.empty()); - VelocityBaseFeature<?> depsFeature = feature("Queue", List.of(), List.of("required")); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(depsFeature); - assertFalse(manager.loadFeature("Queue")); - } + VelocityBaseFeature depsFeature = feature("Queue", List.of(), List.of("required")); + constructorResult("Queue", depsFeature); + assertFalse(manager.loadFeature("Queue")); clearRegistry(manager); manager.getFeatureRegistry().registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); when(pluginManager.getPlugin("required")).thenReturn(Optional.of(mock(PluginContainer.class))); - VelocityBaseFeature<?> success = feature("Queue", List.of(), List.of()); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(success); - assertTrue(manager.loadFeature("Queue")); - assertTrue(manager.getFeatureRegistry().isFeatureLoaded("Queue")); - } + VelocityBaseFeature success = feature("Queue", List.of(), List.of()); + constructorResult("Queue", success); + assertTrue(manager.loadFeature("Queue")); + assertTrue(manager.getFeatureRegistry().isFeatureLoaded("Queue")); manager.getFeatureRegistry().deregisterLoadedFeature("Queue"); - VelocityBaseFeature<?> initFail = feature("Queue", List.of(), List.of()); + VelocityBaseFeature initFail = feature("Queue", List.of(), List.of()); doThrow(new RuntimeException("boom")).when(initFail).initialize(); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(initFail); - assertFalse(manager.loadFeature("Queue")); - verify(initFail).cleanup(); - } + constructorResult("Queue", initFail); + assertFalse(manager.loadFeature("Queue")); + verify(initFail).cleanup(); - VelocityBaseFeature<?> initFailCleanupFail = feature("Queue", List.of(), List.of()); + VelocityBaseFeature initFailCleanupFail = feature("Queue", List.of(), List.of()); doThrow(new RuntimeException("boom")).when(initFailCleanupFail).initialize(); doThrow(new RuntimeException("cleanup boom")).when(initFailCleanupFail).cleanup(); - try (MockedStatic<FeatureFactory> factory = mockStatic(FeatureFactory.class)) { - factory.when(() -> FeatureFactory.createFeature(anyString(), any())).thenReturn(initFailCleanupFail); - assertFalse(manager.loadFeature("Queue")); - } + constructorResult("Queue", initFailCleanupFail); + assertFalse(manager.loadFeature("Queue")); + } @Test @@ -370,8 +453,8 @@ void unloadAllFeaturesHandlesCleanupErrorsAndNullEntries() { FeatureLoadManager manager = new FeatureLoadManager(plugin, featureScopeFactory); clearRegistry(manager); - VelocityBaseFeature<?> ok = feature("Queue", List.of(), List.of()); - VelocityBaseFeature<?> failing = feature("Fail", List.of(), List.of()); + VelocityBaseFeature ok = feature("Queue", List.of(), List.of()); + VelocityBaseFeature failing = feature("Fail", List.of(), List.of()); doThrow(new RuntimeException("boom")).when(failing).cleanup(); manager.getFeatureRegistry().registerLoadedFeature("Queue", ok); @@ -382,8 +465,8 @@ void unloadAllFeaturesHandlesCleanupErrorsAndNullEntries() { assertTrue(manager.getFeatureRegistry().getLoadedFeatureNames().isEmpty()); } - private VelocityBaseFeature<?> feature(String name, List<String> dependencies, List<String> pluginDependencies) { - VelocityBaseFeature<?> feature = mock(VelocityBaseFeature.class); + private VelocityBaseFeature feature(String name, List<String> dependencies, List<String> pluginDependencies) { + VelocityBaseFeature feature = mock(VelocityBaseFeature.class); when(feature.getFeatureName()).thenReturn(name); when(feature.getDependencies()).thenReturn(dependencies); when(feature.getPluginDependencies()).thenReturn(pluginDependencies); @@ -393,11 +476,14 @@ private VelocityBaseFeature<?> feature(String name, List<String> dependencies, L FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLocalizationHandler()).thenReturn(localization); + FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); + when(lifecycle.getApiManager()).thenReturn(mock(FeatureApiManager.class)); + when(feature.getLifecycleManager()).thenReturn(lifecycle); return feature; } - private VelocityBaseFeature<?> reloadStatefulFeature(String name, List<String> dependencies, List<String> pluginDependencies) { - VelocityBaseFeature<?> feature = mock(VelocityBaseFeature.class, withSettings().extraInterfaces(StatefulFeature.class)); + private VelocityBaseFeature reloadStatefulFeature(String name, List<String> dependencies, List<String> pluginDependencies) { + VelocityBaseFeature feature = mock(VelocityBaseFeature.class, withSettings().extraInterfaces(StatefulFeature.class)); when(feature.getFeatureName()).thenReturn(name); when(feature.getDependencies()).thenReturn(dependencies); when(feature.getPluginDependencies()).thenReturn(pluginDependencies); @@ -407,11 +493,14 @@ private VelocityBaseFeature<?> reloadStatefulFeature(String name, List<String> d FeatureConfigHandler cfg = mock(FeatureConfigHandler.class); when(feature.getConfigHandler()).thenReturn(cfg); when(feature.getLocalizationHandler()).thenReturn(localization); + FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); + when(lifecycle.getApiManager()).thenReturn(mock(FeatureApiManager.class)); + when(feature.getLifecycleManager()).thenReturn(lifecycle); return feature; } @SuppressWarnings("unchecked") - private StatefulFeature<TestReloadState> reloadStateful(VelocityBaseFeature<?> feature) { + private StatefulFeature<TestReloadState> reloadStateful(VelocityBaseFeature feature) { return (StatefulFeature<TestReloadState>) feature; } @@ -428,16 +517,42 @@ private void clearRegistry(FeatureLoadManager manager) { } private FeatureDescriptor descriptor(String name, Set<String> featureDependencies, Set<String> pluginDependencies) { + AtomicReference<VelocityBaseFeature> constructor = constructors.computeIfAbsent( + name, + ignored -> new AtomicReference<>(feature(name, List.copyOf(featureDependencies), List.copyOf(pluginDependencies))) + ); return new FeatureDescriptor( name, - VelocityBaseFeature.class.getName(), name, "1.0", + VelocityBaseFeature.class, + ignored -> constructor.get(), featureDependencies, pluginDependencies ); } + private FeatureDescriptor descriptorSequence( + String name, + Set<String> featureDependencies, + VelocityBaseFeature... sequence + ) { + AtomicInteger index = new AtomicInteger(); + return new FeatureDescriptor( + name, + name, + "1.0", + VelocityBaseFeature.class, + ignored -> sequence[Math.min(index.getAndIncrement(), sequence.length - 1)], + featureDependencies, + Set.of() + ); + } + + private void constructorResult(String name, VelocityBaseFeature feature) { + constructors.computeIfAbsent(name, ignored -> new AtomicReference<>()).set(feature); + } + private record TestReloadState(String value) implements SnapshotState { } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistryTest.java index 04cb759f..b5031390 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistryTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/FeatureRegistryTest.java @@ -1,7 +1,7 @@ package nl.hauntedmc.proxyfeatures.framework.loader; import org.junit.jupiter.api.Test; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import java.util.List; import java.util.Set; @@ -15,15 +15,8 @@ class FeatureRegistryTest { void tracksAvailableAndLoadedFeatures() { FeatureRegistry registry = new FeatureRegistry(); - VelocityBaseFeature<?> loaded = mock(VelocityBaseFeature.class); - FeatureDescriptor descriptor = new FeatureDescriptor( - "Queue", - VelocityBaseFeature.class.getName(), - "Queue", - "1.0", - Set.of(), - Set.of("luckperms") - ); + VelocityBaseFeature loaded = mock(VelocityBaseFeature.class); + FeatureDescriptor descriptor = descriptor("Queue", Set.of(), Set.of("luckperms")); registry.registerAvailableFeature(descriptor); registry.registerLoadedFeature("Queue", loaded); @@ -38,20 +31,13 @@ void tracksAvailableAndLoadedFeatures() { void deregisterRemovesLoadedFeatureAndLoadedFeaturesReturnsCopy() { FeatureRegistry registry = new FeatureRegistry(); - VelocityBaseFeature<?> loaded = mock(VelocityBaseFeature.class); - FeatureDescriptor descriptor = new FeatureDescriptor( - "Queue", - VelocityBaseFeature.class.getName(), - "Queue", - "1.0", - Set.of(), - Set.of() - ); + VelocityBaseFeature loaded = mock(VelocityBaseFeature.class); + FeatureDescriptor descriptor = descriptor("Queue", Set.of(), Set.of()); registry.registerAvailableFeature(descriptor); registry.registerLoadedFeature("Queue", loaded); - List<VelocityBaseFeature<?>> copy = registry.getLoadedFeatures(); + List<VelocityBaseFeature> copy = registry.getLoadedFeatures(); copy.clear(); assertEquals(1, registry.getLoadedFeatures().size()); @@ -65,25 +51,25 @@ void deregisterRemovesLoadedFeatureAndLoadedFeaturesReturnsCopy() { void availableFeaturesReturnsSnapshotCopy() { FeatureRegistry registry = new FeatureRegistry(); - registry.registerAvailableFeature(new FeatureDescriptor( - "Queue", - VelocityBaseFeature.class.getName(), - "Queue", - "1.0", - Set.of(), - Set.of() - )); + registry.registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); var snapshot = registry.getAvailableFeatures(); - registry.registerAvailableFeature(new FeatureDescriptor( - "Friends", - VelocityBaseFeature.class.getName(), - "Friends", - "1.0", - Set.of("Queue"), - Set.of() - )); + registry.registerAvailableFeature(descriptor("Friends", Set.of("Queue"), Set.of())); assertEquals(Set.of("Queue"), snapshot.keySet()); } + + private FeatureDescriptor descriptor( + String name, Set<String> featureDependencies, Set<String> pluginDependencies + ) { + return new FeatureDescriptor( + name, + name, + "1.0", + VelocityBaseFeature.class, + ignored -> mock(VelocityBaseFeature.class), + featureDependencies, + pluginDependencies + ); + } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/dependency/FeatureDependencyManagerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/dependency/FeatureDependencyManagerTest.java index 631a608c..ce843ac4 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/dependency/FeatureDependencyManagerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/loader/dependency/FeatureDependencyManagerTest.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.plugin.PluginManager; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; +import nl.hauntedmc.proxyfeatures.framework.feature.VelocityBaseFeature; import nl.hauntedmc.proxyfeatures.framework.loader.FeatureDescriptor; import nl.hauntedmc.proxyfeatures.framework.loader.FeatureLoadManager; import nl.hauntedmc.proxyfeatures.framework.loader.FeatureRegistry; @@ -91,10 +91,10 @@ void pluginDependencyChecksAreDelegated() { @Test void getDependentFeaturesReturnsLoadedFeaturesThatDependOnTarget() { - VelocityBaseFeature<?> queue = mock(VelocityBaseFeature.class); + VelocityBaseFeature queue = mock(VelocityBaseFeature.class); when(queue.getDependencies()).thenReturn(List.of()); - VelocityBaseFeature<?> friends = mock(VelocityBaseFeature.class); + VelocityBaseFeature friends = mock(VelocityBaseFeature.class); when(friends.getDependencies()).thenReturn(List.of("Queue")); registry.registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); @@ -107,10 +107,10 @@ void getDependentFeaturesReturnsLoadedFeaturesThatDependOnTarget() { @Test void getDependentFeaturesSkipsNullLoadedEntries() { - VelocityBaseFeature<?> queue = mock(VelocityBaseFeature.class); + VelocityBaseFeature queue = mock(VelocityBaseFeature.class); when(queue.getDependencies()).thenReturn(List.of()); - VelocityBaseFeature<?> friends = mock(VelocityBaseFeature.class); + VelocityBaseFeature friends = mock(VelocityBaseFeature.class); when(friends.getDependencies()).thenReturn(List.of("Queue")); registry.registerAvailableFeature(descriptor("Queue", Set.of(), Set.of())); @@ -126,9 +126,10 @@ void getDependentFeaturesSkipsNullLoadedEntries() { private FeatureDescriptor descriptor(String name, Set<String> featureDependencies, Set<String> pluginDependencies) { return new FeatureDescriptor( name, - VelocityBaseFeature.class.getName(), name, "1.0", + VelocityBaseFeature.class, + ignored -> mock(VelocityBaseFeature.class), featureDependencies, pluginDependencies ); diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandlerTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandlerTest.java index 78219b74..8e34c7fa 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandlerTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/localization/LocalizationHandlerTest.java @@ -5,14 +5,11 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import nl.hauntedmc.dataregistry.api.DataRegistryApi; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceDirectory; -import nl.hauntedmc.proxyfeatures.test.TestFeatureServiceDirectory; +import nl.hauntedmc.proxyfeatures.test.MutableCapabilityRegistry; import nl.hauntedmc.proxyfeatures.ProxyFeatures; -import nl.hauntedmc.proxyfeatures.api.io.config.ConfigService; -import nl.hauntedmc.proxyfeatures.api.io.localization.Language; -import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.proxyfeatures.features.playerlanguage.api.LanguageAPI; +import nl.hauntedmc.proxyfeatures.toolkit.io.config.ConfigService; +import nl.hauntedmc.proxyfeatures.toolkit.io.localization.MessageMap; +import nl.hauntedmc.proxyfeatures.api.capability.player.PlayerLanguageApi; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.slf4j.LoggerFactory; @@ -21,6 +18,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; +import java.util.Locale; import java.util.UUID; import java.util.function.Consumer; @@ -113,16 +111,13 @@ void messageBuilderUsesPlayerTranslationAndFeatureLanguageOverrides() throws IOE status: "&bEnglish queue" """); - LanguageAPI languageApi = mock(LanguageAPI.class); + PlayerLanguageApi languageApi = mock(PlayerLanguageApi.class); UUID uuid = UUID.randomUUID(); - when(languageApi.get(uuid)).thenReturn(Language.EN); - - LocalizationHandler framework = new LocalizationHandler(mockPlugin(services -> services.register( - "ProxyFeatures", - "PlayerLanguage", - LanguageAPI.class, - languageApi - )), service()); + when(languageApi.resolvedLanguage(uuid)).thenReturn(Optional.of(Locale.ENGLISH)); + + LocalizationHandler framework = new LocalizationHandler(mockPlugin( + services -> services.register(PlayerLanguageApi.class, languageApi) + ), service()); LocalizationHandler feature = framework.openFeatureLocalization("Queue"); Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(uuid); @@ -164,15 +159,13 @@ private ProxyFeatures mockPlugin() { }); } - private ProxyFeatures mockPlugin(Consumer<FeatureServiceDirectory> servicesSetup) { + private ProxyFeatures mockPlugin(Consumer<MutableCapabilityRegistry> servicesSetup) { ProxyFeatures plugin = mock(ProxyFeatures.class); - DataRegistryApi dataRegistry = mock(DataRegistryApi.class); - FeatureServiceDirectory services = new TestFeatureServiceDirectory(); + MutableCapabilityRegistry services = new MutableCapabilityRegistry(); servicesSetup.accept(services); when(plugin.getDataDirectory()).thenReturn(tempDir); when(plugin.getLogger()).thenReturn(ComponentLogger.logger("LocalizationHandlerTest")); - when(plugin.getDataRegistry()).thenReturn(Optional.of(dataRegistry)); - when(dataRegistry.featureServices()).thenReturn(services); + when(plugin.capabilities()).thenReturn(services); return plugin; } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java index ac5104f6..fafa4b16 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java @@ -27,7 +27,7 @@ class PlayerReferenceResolverTest { @Test - void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() { + void resolveReferenceUsesActiveIdentityWithoutQueryingPersistence() { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); @@ -35,7 +35,7 @@ void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() { when(directory.findActiveIdentityCached(uuid)).thenReturn(Optional.of(identity)); - PlayerReference result = new PlayerReferenceResolver(directory).resolveManaged(session, uuid); + PlayerReference result = new PlayerReferenceResolver(directory).resolveReference(uuid); assertEquals(PlayerReference.from(identity), result); verify(directory, never()).findByUuid(uuid); @@ -43,7 +43,7 @@ void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() { } @Test - void resolveManagedUsesPersistedIdentityFromBackgroundWorkerWhenPlayerIsOffline() throws Exception { + void resolveReferenceUsesPersistedIdentityFromBackgroundWorkerWhenPlayerIsOffline() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); @@ -53,7 +53,7 @@ void resolveManagedUsesPersistedIdentityFromBackgroundWorkerWhenPlayerIsOffline( when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); - PlayerReference result = runOnThread("ProxyFeatures-Votifier-worker", () -> resolver.resolveManaged(session, uuid)); + PlayerReference result = runOnThread("ProxyFeatures-Votifier-worker", () -> resolver.resolveReference(uuid)); assertEquals(PlayerReference.from(identity), result); verify(directory).findByUuid(uuid); @@ -99,7 +99,7 @@ void asyncLookupQueriesPersistenceWithoutBlockingEventThread() throws Exception } @Test - void resolveManagedReturnsNullWhenPersistedIdentityDoesNotExist() throws Exception { + void resolveReferenceReturnsNullWhenPersistedIdentityDoesNotExist() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); @@ -108,7 +108,7 @@ void resolveManagedReturnsNullWhenPersistedIdentityDoesNotExist() throws Excepti when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); - PlayerReference result = runOnThread("ProxyFeatures-worker", () -> resolver.resolveManaged(session, uuid)); + PlayerReference result = runOnThread("ProxyFeatures-worker", () -> resolver.resolveReference(uuid)); assertNull(result); verify(directory).findByUuid(uuid); @@ -162,7 +162,7 @@ void findByIdsAsyncCombinesActiveAndPersistedPlayersInRequestedOrder() { } @Test - void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() throws Exception { + void resolveReferenceByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID activeUuid = UUID.randomUUID(); @@ -181,14 +181,14 @@ void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() throw PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); - assertEquals(PlayerReference.from(activeIdentity), resolver.resolveManagedById(session, 22L)); + assertEquals(PlayerReference.from(activeIdentity), resolver.resolveReferenceById(22L)); assertEquals( PlayerReference.from(offlineIdentity), - runOnThread("ProxyFeatures-worker", () -> resolver.resolveManagedById(session, 23L)) + runOnThread("ProxyFeatures-worker", () -> resolver.resolveReferenceById(23L)) ); assertEquals( PlayerReference.byId(24L), - runOnThread("ProxyFeatures-worker", () -> resolver.resolveManagedById(session, 24L)) + runOnThread("ProxyFeatures-worker", () -> resolver.resolveReferenceById(24L)) ); verifyNoInteractions(session); } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryClassLoaderTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryClassLoaderTest.java new file mode 100644 index 00000000..f0be9876 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryClassLoaderTest.java @@ -0,0 +1,85 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DefaultCapabilityRegistryClassLoaderTest { + + @Test + void rejectsArbitraryContractsBeforeCachingAReference() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + + assertThrows(IllegalArgumentException.class, () -> registry.reference(Runnable.class)); + assertEquals(0, registry.cachedReferenceCount()); + } + + @Test + void rejectsDuplicateApiClassesBeforeTheyCanRetainAnExternalClassLoader() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + byte[] classBytes = readClassBytes(PresenceApi.class); + ClassLoader duplicateApiLoader = new DuplicateApiClassLoader( + PresenceApi.class.getClassLoader(), + PresenceApi.class.getName(), + classBytes + ); + Class<?> duplicatePresenceApi = Class.forName( + PresenceApi.class.getName(), + false, + duplicateApiLoader + ); + assertNotSame(PresenceApi.class, duplicatePresenceApi); + + assertThrows(IllegalArgumentException.class, () -> referenceUnchecked(registry, duplicatePresenceApi)); + assertEquals(0, registry.cachedReferenceCount()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void referenceUnchecked(DefaultCapabilityRegistry registry, Class<?> type) { + registry.reference((Class) type); + } + + private static byte[] readClassBytes(Class<?> type) throws IOException { + String resourceName = type.getSimpleName() + ".class"; + try (InputStream input = type.getResourceAsStream(resourceName)) { + if (input == null) { + throw new IOException("Unable to read class resource: " + resourceName); + } + return input.readAllBytes(); + } + } + + private static final class DuplicateApiClassLoader extends ClassLoader { + private final String duplicateClassName; + private final byte[] duplicateClassBytes; + + private DuplicateApiClassLoader(ClassLoader parent, String duplicateClassName, byte[] duplicateClassBytes) { + super(parent); + this.duplicateClassName = duplicateClassName; + this.duplicateClassBytes = duplicateClassBytes.clone(); + } + + @Override + protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class<?> loaded = findLoadedClass(name); + if (loaded == null && duplicateClassName.equals(name)) { + loaded = defineClass(name, duplicateClassBytes, 0, duplicateClassBytes.length); + } + if (loaded == null) { + loaded = super.loadClass(name, false); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryProxyContractTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryProxyContractTest.java new file mode 100644 index 00000000..819965f4 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryProxyContractTest.java @@ -0,0 +1,79 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultCapabilityRegistryProxyContractTest { + + @Test + void stableProxyUsesIdentityObjectMethods() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + PresenceApi provider = presence(); + CapabilityRegistration registration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, provider + ); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + assertTrue(proxy.equals(proxy)); + assertFalse(proxy.equals(provider)); + assertNotEquals(provider.hashCode(), proxy.hashCode()); + assertEquals("CapabilityRefProxy[" + PresenceApi.class.getName() + "]", proxy.toString()); + + registration.close(); + } + + @Test + void providerFailureIsUnwrappedAndReleasesInvocationLease() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + IllegalStateException failure = new IllegalStateException("provider failure"); + PresenceApi provider = new PresenceApi() { + @Override + public boolean isHidden(UUID playerId) { + throw failure; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + }; + CapabilityRegistration registration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, provider + ); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + assertSame(failure, assertThrows( + IllegalStateException.class, + () -> proxy.isHidden(UUID.randomUUID()) + )); + registration.close(); + assertTrue(registry.reference(PresenceApi.class).get().isEmpty()); + } + + private static PresenceApi presence() { + return new PresenceApi() { + @Override + public boolean isHidden(UUID playerId) { + return false; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + }; + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryReplacementTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryReplacementTest.java new file mode 100644 index 00000000..09fa5fbe --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryReplacementTest.java @@ -0,0 +1,199 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityListener; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultCapabilityRegistryReplacementTest { + + @Test + void ownerReplacementPublishesBeforeWaitingForOldInvocationsToDrain() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + FeatureId owner = FeatureId.of("vanish"); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + PresenceApi oldProvider = presenceBlockingFirstInvocation(false, entered, release); + registry.register(owner, PresenceApi.class, oldProvider); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var oldInvocation = executor.submit(() -> proxy.isHidden(UUID.randomUUID())); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var replacement = executor.submit(() -> registry.replace( + owner, + PresenceApi.class, + presence(true, null, null) + )); + + assertTrue(awaitHidden(proxy)); + assertThrows(TimeoutException.class, () -> replacement.get(100, TimeUnit.MILLISECONDS)); + + release.countDown(); + assertFalse(oldInvocation.get(5, TimeUnit.SECONDS)); + CapabilityRegistration replacementRegistration = replacement.get(5, TimeUnit.SECONDS); + assertTrue(proxy.isHidden(UUID.randomUUID())); + replacementRegistration.close(); + } + } + + @Test + void replacementByAnotherOwnerLeavesExistingProviderActive() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + registry.register(FeatureId.of("vanish"), PresenceApi.class, presence(false, null, null)); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + assertThrows(IllegalStateException.class, () -> registry.replace( + FeatureId.of("other"), + PresenceApi.class, + presence(true, null, null) + )); + assertFalse(proxy.isHidden(UUID.randomUUID())); + } + + @Test + void replacementReturnsItsRegistrationWhenThePreviousSynchronousCallTimesOut() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(Duration.ofMillis(25)); + FeatureId owner = FeatureId.of("vanish"); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + registry.register(owner, PresenceApi.class, presence(false, entered, release)); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var oldInvocation = executor.submit(() -> proxy.isHidden(UUID.randomUUID())); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + + CapabilityRegistration replacement = registry.replace( + owner, PresenceApi.class, presence(true, null, null) + ); + assertTrue(proxy.isHidden(UUID.randomUUID())); + + release.countDown(); + assertFalse(oldInvocation.get(5, TimeUnit.SECONDS)); + replacement.close(); + } + } + + @Test + void closingSupersededRegistrationDoesNotPublishAnUnavailableEvent() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + List<String> events = new ArrayList<>(); + AutoCloseable subscription = registry.subscribe(new CapabilityListener() { + @Override + public void available(Class<?> type, long generation) { + events.add("available:" + generation); + } + + @Override + public void unavailable(Class<?> type, long generation) { + events.add("unavailable:" + generation); + } + + @Override + public void replaced(Class<?> type, long previousGeneration, long nextGeneration) { + events.add("replaced:" + previousGeneration + ":" + nextGeneration); + } + }); + CapabilityRegistration oldRegistration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, presence(false, null, null) + ); + CapabilityRegistration replacement = registry.replace( + FeatureId.of("vanish"), PresenceApi.class, presence(true, null, null) + ); + + oldRegistration.close(); + + assertEquals(List.of("available:1", "replaced:1:2"), events); + replacement.close(); + assertEquals(List.of("available:1", "replaced:1:2", "unavailable:2"), events); + subscription.close(); + } + + private static boolean awaitHidden(PresenceApi proxy) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (proxy.isHidden(UUID.randomUUID())) { + return true; + } + Thread.sleep(10L); + } + return false; + } + + private static PresenceApi presence(boolean hidden, CountDownLatch entered, CountDownLatch release) { + return new PresenceApi() { + @Override + public boolean isHidden(UUID playerId) { + if (entered != null) { + entered.countDown(); + } + if (release != null) { + await(release); + } + return hidden; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + }; + } + + private static PresenceApi presenceBlockingFirstInvocation( + boolean hidden, + CountDownLatch entered, + CountDownLatch release + ) { + AtomicBoolean firstInvocation = new AtomicBoolean(true); + return new PresenceApi() { + @Override + public boolean isHidden(UUID playerId) { + if (firstInvocation.compareAndSet(true, false)) { + entered.countDown(); + await(release); + } + return hidden; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + }; + } + + private static void await(CountDownLatch latch) { + boolean interrupted = false; + while (true) { + try { + latch.await(); + break; + } catch (InterruptedException ignored) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryTest.java new file mode 100644 index 00000000..d0b05c42 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultCapabilityRegistryTest.java @@ -0,0 +1,286 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.ApiFailureCode; +import nl.hauntedmc.proxyfeatures.api.ApiOperationException; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceApi; +import nl.hauntedmc.proxyfeatures.api.capability.presence.PresenceSnapshot; +import nl.hauntedmc.proxyfeatures.api.capability.social.FriendshipApi; +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRef; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityUnavailableException; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultCapabilityRegistryTest { + + @Test + void stableReferenceTracksRegistrationReplacementAndRemoval() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + CapabilityRef<PresenceApi> reference = registry.reference(PresenceApi.class); + PresenceApi first = presence(false); + + CapabilityRegistration firstRegistration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, first + ); + PresenceApi proxy = reference.require(); + long firstGeneration = reference.generation().orElseThrow(); + assertSame(reference, registry.reference(PresenceApi.class)); + assertFalse(proxy.isHidden(UUID.randomUUID())); + assertEquals(Set.of(PresenceApi.class), registry.availableTypes()); + assertEquals(FeatureId.of("vanish"), registry.owner(PresenceApi.class).orElseThrow()); + + firstRegistration.close(); + firstRegistration.close(); + assertTrue(reference.get().isEmpty()); + assertTrue(reference.generation().isEmpty()); + assertTrue(registry.owner(PresenceApi.class).isEmpty()); + assertThrows(CapabilityUnavailableException.class, () -> proxy.isHidden(UUID.randomUUID())); + + PresenceApi replacement = presence(true); + CapabilityRegistration replacementRegistration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, replacement + ); + assertSame(proxy, reference.require()); + assertTrue(proxy.isHidden(UUID.randomUUID())); + assertNotEquals(firstGeneration, reference.generation().orElseThrow()); + replacementRegistration.close(); + assertFalse(reference.isAvailable()); + } + + @Test + void synchronousInvocationLeaseDelaysProviderWithdrawal() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + PresenceApi blocking = new PresenceApi() { + @Override + public boolean isHidden(UUID playerId) { + entered.countDown(); + await(release); + return false; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + }; + CapabilityRegistration registration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, blocking + ); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var invocation = executor.submit(() -> proxy.isHidden(UUID.randomUUID())); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var withdrawal = executor.submit(registration::close); + + assertThrows(TimeoutException.class, () -> withdrawal.get(100, TimeUnit.MILLISECONDS)); + assertFalse(registry.reference(PresenceApi.class).isAvailable()); + + release.countDown(); + assertFalse(invocation.get(5, TimeUnit.SECONDS)); + withdrawal.get(5, TimeUnit.SECONDS); + } + } + + @Test + void asynchronousInvocationLeaseEndsWhenReturnedStageCompletes() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + CompletableFuture<Boolean> result = new CompletableFuture<>(); + FriendshipApi friendship = (first, second) -> result; + CapabilityRegistration registration = registry.register( + FeatureId.of("friends"), FriendshipApi.class, friendship + ); + FriendshipApi proxy = registry.reference(FriendshipApi.class).require(); + CompletableFuture<Boolean> returned = proxy.areFriends(UUID.randomUUID(), UUID.randomUUID()) + .toCompletableFuture(); + + result.complete(true); + assertTrue(returned.get(5, TimeUnit.SECONDS)); + registration.close(); + } + + @Test + void asynchronousInvocationIsAbandonedAfterCallerCancellationDuringWithdrawal() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + CompletableFuture<Boolean> source = new CompletableFuture<>(); + CapabilityRegistration registration = registry.register( + FeatureId.of("friends"), + FriendshipApi.class, + (first, second) -> source + ); + CompletableFuture<Boolean> returned = registry.reference(FriendshipApi.class).require() + .areFriends(UUID.randomUUID(), UUID.randomUUID()) + .toCompletableFuture(); + + assertTrue(returned.cancel(false)); + registration.close(); + + assertFalse(source.isDone(), "The registry must not cancel provider-owned work"); + } + + @Test + void withdrawalFailsOutstandingCallerStageAndReleasesItsLease() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + CompletableFuture<Boolean> source = new CompletableFuture<>(); + CapabilityRegistration registration = registry.register( + FeatureId.of("friends"), + FriendshipApi.class, + (first, second) -> source + ); + CompletableFuture<Boolean> returned = registry.reference(FriendshipApi.class).require() + .areFriends(UUID.randomUUID(), UUID.randomUUID()) + .toCompletableFuture(); + + registration.close(); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> returned.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof ApiOperationException); + assertEquals(ApiFailureCode.PROVIDER_RELOADED, + ((ApiOperationException) failure.getCause()).code()); + assertFalse(source.isDone(), "The registry must not cancel provider-owned work"); + } + + @Test + void synchronousInvocationDrainTimesOutInsteadOfBlockingForever() throws Exception { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(Duration.ofMillis(25)); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CapabilityRegistration registration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, presence(false, entered, release) + ); + PresenceApi proxy = registry.reference(PresenceApi.class).require(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var invocation = executor.submit(() -> proxy.isHidden(UUID.randomUUID())); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + + ApiOperationException timeout = assertThrows(ApiOperationException.class, registration::close); + assertEquals(ApiFailureCode.TIMEOUT, timeout.code()); + assertFalse(registry.reference(PresenceApi.class).isAvailable()); + + release.countDown(); + assertFalse(invocation.get(5, TimeUnit.SECONDS)); + } + } + + @Test + void registryRejectsConflictsAndNonApiContracts() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + PresenceApi existing = presence(false); + registry.register(FeatureId.of("vanish"), PresenceApi.class, existing); + + assertThrows(IllegalStateException.class, + () -> registry.register(FeatureId.of("other"), PresenceApi.class, presence(true))); + assertThrows(IllegalStateException.class, + () -> registry.register(FeatureId.of("vanish"), PresenceApi.class, existing)); + assertThrows(IllegalArgumentException.class, + () -> registry.register(FeatureId.of("bad"), String.class, "not-an-interface")); + assertThrows(IllegalArgumentException.class, + () -> registry.register(FeatureId.of("bad"), Runnable.class, () -> { })); + assertThrows(NullPointerException.class, () -> registry.reference(null)); + assertThrows(NullPointerException.class, () -> registry.owner(null)); + } + + @Test + void configuresGenerationAwareProvidersBeforePublication() { + DefaultCapabilityRegistry registry = new DefaultCapabilityRegistry(); + GenerationAwarePresence first = new GenerationAwarePresence(false); + CapabilityRegistration registration = registry.register( + FeatureId.of("vanish"), PresenceApi.class, first + ); + + assertEquals(registry.reference(PresenceApi.class).generation().orElseThrow(), first.generation); + + GenerationAwarePresence replacement = new GenerationAwarePresence(true); + CapabilityRegistration replacementRegistration = registry.replace( + FeatureId.of("vanish"), PresenceApi.class, replacement + ); + assertEquals(registry.reference(PresenceApi.class).generation().orElseThrow(), replacement.generation); + + registration.close(); + replacementRegistration.close(); + } + + private static PresenceApi presence(boolean hidden) { + return presence(hidden, null, null); + } + + private static final class GenerationAwarePresence implements PresenceApi, CapabilityProviderGenerationAware { + private final boolean hidden; + private long generation; + + private GenerationAwarePresence(boolean hidden) { + this.hidden = hidden; + } + + @Override + public void providerGeneration(long generation) { + this.generation = generation; + } + + @Override + public boolean isHidden(UUID playerId) { + return hidden; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + } + + private static PresenceApi presence(boolean hidden, CountDownLatch entered, CountDownLatch release) { + return new PresenceApi() { + @Override + public boolean isHidden(UUID playerId) { + if (entered != null) { + entered.countDown(); + } + if (release != null) { + await(release); + } + return hidden; + } + + @Override + public PresenceSnapshot snapshot() { + return new PresenceSnapshot(Set.of(), Set.of(), Instant.EPOCH); + } + }; + } + + private static void await(CountDownLatch latch) { + boolean interrupted = false; + while (true) { + try { + latch.await(); + break; + } catch (InterruptedException ignored) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultFeatureCatalogTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultFeatureCatalogTest.java new file mode 100644 index 00000000..0af3eca2 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/DefaultFeatureCatalogTest.java @@ -0,0 +1,122 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.feature.*; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultFeatureCatalogTest { + + private static final Instant NOW = Instant.parse("2026-08-06T00:00:00Z"); + + @Test + void catalogProjectsSortedPointInTimeLifecycleSnapshots() { + DefaultFeatureCatalog catalog = new DefaultFeatureCatalog(Clock.fixed(NOW, ZoneOffset.UTC)); + FeatureDescriptor vanish = descriptor("vanish"); + FeatureDescriptor queue = descriptor("queue"); + catalog.register(vanish); + catalog.register(queue); + + assertEquals(FeatureState.DISABLED, + catalog.find(FeatureId.of("queue")).orElseThrow().state()); + catalog.transition(FeatureId.of("queue"), FeatureState.STARTING); + catalog.transition(FeatureId.of("queue"), FeatureState.ACTIVE); + + var snapshots = catalog.snapshot(); + assertEquals(2, snapshots.size()); + assertEquals(FeatureId.of("queue"), snapshots.get(0).descriptor().id()); + assertEquals(FeatureState.ACTIVE, snapshots.get(0).state()); + assertEquals(NOW, snapshots.get(0).observedAt()); + assertEquals(NOW, snapshots.get(1).observedAt()); + assertTrue(catalog.find(FeatureId.of("missing")).isEmpty()); + } + + @Test + void failuresExposeAUsefulMessageAndUnknownTransitionsFailFast() { + DefaultFeatureCatalog catalog = new DefaultFeatureCatalog(Clock.fixed(NOW, ZoneOffset.UTC)); + FeatureId queue = FeatureId.of("queue"); + catalog.register(descriptor("queue")); + + catalog.fail(queue, new IllegalStateException("startup failed")); + assertEquals(Optional.of("startup failed"), catalog.find(queue).orElseThrow().failure()); + + catalog.register(descriptor("queue")); + catalog.fail(queue, new IllegalArgumentException()); + assertEquals(Optional.of("IllegalArgumentException"), + catalog.find(queue).orElseThrow().failure()); + assertThrows(IllegalArgumentException.class, + () -> catalog.transition(FeatureId.of("missing"), FeatureState.ACTIVE)); + assertThrows(IllegalArgumentException.class, + () -> catalog.setConfiguredEnabled(FeatureId.of("missing"), true)); + assertThrows(IllegalArgumentException.class, + () -> catalog.setUnavailableDependencies(FeatureId.of("missing"), Set.of())); + assertThrows(NullPointerException.class, () -> catalog.register(null)); + assertThrows(NullPointerException.class, () -> catalog.fail(queue, null)); + } + + @Test + void longFailureMessagesAreTruncatedToThePublicFailureLimit() { + DefaultFeatureCatalog catalog = new DefaultFeatureCatalog(Clock.fixed(NOW, ZoneOffset.UTC)); + FeatureId queue = FeatureId.of("queue"); + catalog.register(descriptor("queue")); + + catalog.fail(queue, new IllegalStateException("x".repeat(200))); + + FeatureSnapshot snapshot = catalog.find(queue).orElseThrow(); + assertEquals(FeatureState.FAILED, snapshot.state()); + assertEquals(160, snapshot.failure().orElseThrow().length()); + assertEquals(snapshot.failure(), snapshot.failureDetail().orElseThrow().message()); + } + + @Test + void configurationAvailabilityAndFailurePhaseRemainAuthoritative() { + DefaultFeatureCatalog catalog = new DefaultFeatureCatalog(Clock.fixed(NOW, ZoneOffset.UTC)); + FeatureId queue = FeatureId.of("queue"); + FeatureId friends = FeatureId.of("friends"); + catalog.register(descriptor("queue")); + + catalog.setConfiguredEnabled(queue, true); + catalog.setUnavailableDependencies(queue, Set.of(friends)); + catalog.fail(queue, "startup", new IllegalStateException("missing provider")); + + FeatureSnapshot snapshot = catalog.find(queue).orElseThrow(); + assertTrue(snapshot.configuredEnabled()); + assertEquals(Set.of(friends), snapshot.unavailableDependencies()); + assertEquals("startup", snapshot.failureDetail().orElseThrow().phase()); + assertTrue(snapshot.generation() >= 3); + } + + @Test + void unchangedCatalogProjectionDoesNotAdvanceGenerationOrNotifyListeners() throws Exception { + DefaultFeatureCatalog catalog = new DefaultFeatureCatalog(Clock.fixed(NOW, ZoneOffset.UTC)); + FeatureId queue = FeatureId.of("queue"); + catalog.register(descriptor("queue")); + long initialGeneration = catalog.find(queue).orElseThrow().generation(); + java.util.concurrent.atomic.AtomicInteger notifications = new java.util.concurrent.atomic.AtomicInteger(); + AutoCloseable subscription = catalog.subscribe(snapshot -> notifications.incrementAndGet()); + + catalog.setConfiguredEnabled(queue, false); + catalog.setUnavailableDependencies(queue, Set.of()); + + assertEquals(initialGeneration, catalog.find(queue).orElseThrow().generation()); + assertEquals(0, notifications.get()); + subscription.close(); + } + + private static FeatureDescriptor descriptor(String id) { + return new FeatureDescriptor( + FeatureId.of(id), + id, + "1.0.0", + FeatureClassification.INTERNAL, + Set.of(), + Set.of() + ); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/InternalServiceRegistryReplacementTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/InternalServiceRegistryReplacementTest.java new file mode 100644 index 00000000..9c4a0170 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/framework/service/InternalServiceRegistryReplacementTest.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.proxyfeatures.framework.service; + +import nl.hauntedmc.proxyfeatures.api.feature.FeatureId; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class InternalServiceRegistryReplacementTest { + + @Test + void ownerCanReplaceInternalServiceWithoutAnAvailabilityGap() { + InternalServiceRegistry registry = new InternalServiceRegistry(); + FeatureId owner = FeatureId.of("queue"); + Runnable original = () -> { }; + Runnable replacement = () -> { }; + CapabilityRegistration originalRegistration = registry.register(owner, Runnable.class, original); + + CapabilityRegistration replacementRegistration = registry.replace(owner, Runnable.class, replacement); + assertSame(replacement, registry.require(Runnable.class)); + + originalRegistration.close(); + assertSame(replacement, registry.require(Runnable.class)); + replacementRegistration.close(); + } + + @Test + void anotherOwnerCannotReplaceInternalService() { + InternalServiceRegistry registry = new InternalServiceRegistry(); + Runnable original = () -> { }; + registry.register(FeatureId.of("queue"), Runnable.class, original); + + assertThrows(IllegalStateException.class, () -> registry.replace( + FeatureId.of("other"), + Runnable.class, + (Runnable) () -> { } + )); + assertSame(original, registry.require(Runnable.class)); + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/test/MutableCapabilityRegistry.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/test/MutableCapabilityRegistry.java new file mode 100644 index 00000000..f0b49533 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/test/MutableCapabilityRegistry.java @@ -0,0 +1,48 @@ +package nl.hauntedmc.proxyfeatures.test; + +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRef; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityRegistry; +import nl.hauntedmc.proxyfeatures.api.service.CapabilityListener; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** Small mutable registry for capability-consuming unit tests. */ +public final class MutableCapabilityRegistry implements CapabilityRegistry { + private final Map<Class<?>, Object> values = new ConcurrentHashMap<>(); + + public <T> void register(Class<T> type, T value) { + values.put(type, type.cast(value)); + } + + public void clear() { + values.clear(); + } + + @Override + public <T> CapabilityRef<T> reference(Class<T> type) { + return new CapabilityRef<>() { + @Override + public Class<T> type() { + return type; + } + + @Override + public Optional<T> get() { + return Optional.ofNullable(values.get(type)).map(type::cast); + } + }; + } + + @Override + public Set<Class<?>> availableTypes() { + return Set.copyOf(values.keySet()); + } + + @Override + public AutoCloseable subscribe(CapabilityListener listener) { + return () -> { }; + } +} diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/test/TestFeatureServiceDirectory.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/test/TestFeatureServiceDirectory.java deleted file mode 100644 index 96f2e4ed..00000000 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/test/TestFeatureServiceDirectory.java +++ /dev/null @@ -1,59 +0,0 @@ -package nl.hauntedmc.proxyfeatures.test; - -import nl.hauntedmc.dataregistry.api.service.FeatureServiceDirectory; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceHandle; -import nl.hauntedmc.dataregistry.api.service.FeatureServiceInfo; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -/** API-only in-memory feature catalog used by consumer tests. */ -public final class TestFeatureServiceDirectory implements FeatureServiceDirectory { - private final Map<Class<?>, Entry<?>> entries = new ConcurrentHashMap<>(); - - @Override - public <T> FeatureServiceHandle register(String plugin, String feature, Class<T> type, T service) { - FeatureServiceInfo info = new FeatureServiceInfo(plugin, feature, type, service.getClass().getName()); - Entry<T> entry = new Entry<>(info, service); - entries.compute(type, (ignored, existing) -> { - if (existing != null - && (!existing.info().ownerPlugin().equals(info.ownerPlugin()) - || !existing.info().ownerFeature().equals(info.ownerFeature()))) { - throw new IllegalStateException("Feature service is already registered by another owner: " - + type.getName()); - } - return entry; - }); - return new FeatureServiceHandle() { - @Override public FeatureServiceInfo info() { return info; } - @Override public void close() { entries.remove(type, entry); } - }; - } - - @Override public <T> Optional<T> find(Class<T> type) { - Entry<?> entry = entries.get(type); - return entry == null ? Optional.empty() : Optional.of(type.cast(entry.service())); - } - @Override public <T> T require(Class<T> type) { return find(type).orElseThrow(); } - @Override public boolean contains(Class<?> type) { return entries.containsKey(type); } - @Override public Optional<FeatureServiceInfo> describe(Class<?> type) { - Entry<?> entry = entries.get(type); - return entry == null ? Optional.empty() : Optional.of(entry.info()); - } - @Override public List<FeatureServiceInfo> list() { return entries.values().stream().map(Entry::info).toList(); } - @Override public boolean unregister(Class<?> type, Object service) { - Entry<?> entry = entries.get(type); - return entry != null && entry.service() == service && entries.remove(type, entry); - } - @Override public int unregisterOwner(String plugin, String feature) { - int before = entries.size(); - entries.entrySet().removeIf(entry -> entry.getValue().info().ownerPlugin().equals(plugin) - && entry.getValue().info().ownerFeature().equals(feature)); - return before - entries.size(); - } - @Override public void clear() { entries.clear(); } - - private record Entry<T>(FeatureServiceInfo info, T service) { } -} diff --git a/proxyfeatures-toolkit/pom.xml b/proxyfeatures-toolkit/pom.xml new file mode 100644 index 00000000..35ac2989 --- /dev/null +++ b/proxyfeatures-toolkit/pom.xml @@ -0,0 +1,103 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>nl.hauntedmc.proxyfeatures</groupId> + <artifactId>proxyfeatures-parent</artifactId> + <version>${revision}</version> + </parent> + <artifactId>proxyfeatures-toolkit</artifactId> + <name>ProxyFeatures Toolkit</name> + <description>Optional reusable configuration, cache, HTTP, and text implementations.</description> + <properties> + <coverage.line.minimum>0.90</coverage.line.minimum> + </properties> + <dependencies> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-api</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-key</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-text-minimessage</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-text-serializer-gson</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-text-serializer-legacy</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-text-serializer-plain</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-text-logger-slf4j</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.spongepowered</groupId> + <artifactId>configurate-yaml</artifactId> + <version>${configurate.version}</version> + </dependency> + <dependency> + <groupId>org.spongepowered</groupId> + <artifactId>configurate-core</artifactId> + <version>${configurate.version}</version> + </dependency> + <dependency> + <groupId>com.google.code.gson</groupId> + <artifactId>gson</artifactId> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <version>${junit.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>${project.groupId}</groupId> + <artifactId>proxyfeatures-testkit</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>${mockito.version}</version> + <scope>test</scope> + </dependency> + </dependencies> + <build> + <plugins> + <plugin> + <artifactId>maven-jar-plugin</artifactId> + <configuration> + <archive> + <manifestEntries> + <Automatic-Module-Name>nl.hauntedmc.proxyfeatures.toolkit</Automatic-Module-Name> + </manifestEntries> + </archive> + </configuration> + </plugin> + </plugins> + </build> +</project> diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/ToolkitContext.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/ToolkitContext.java new file mode 100644 index 00000000..1688bddc --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/ToolkitContext.java @@ -0,0 +1,16 @@ +package nl.hauntedmc.proxyfeatures.toolkit; + +import org.slf4j.Logger; + +import java.nio.file.Path; + +/** Minimal host contract required by toolkit configuration and resource services. */ +public interface ToolkitContext { + Path getDataDirectory(); + + Logger getLogger(); + + default ClassLoader getResourceClassLoader() { + return getClass().getClassLoader(); + } +} diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/AsyncHttpTransport.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/AsyncHttpTransport.java new file mode 100644 index 00000000..807631f3 --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/AsyncHttpTransport.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.toolkit.http; + +import java.net.URI; +import java.util.concurrent.CompletionStage; + +/** Injectable, non-blocking transport. Implementations must document redirect and retry policy. */ +@FunctionalInterface +public interface AsyncHttpTransport { + CompletionStage<HttpResponseData> post(URI uri, String contentType, String body, boolean requireHttps); +} diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpResponseData.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpResponseData.java new file mode 100644 index 00000000..081b8607 --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpResponseData.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.proxyfeatures.toolkit.http; + +import java.net.URI; +import java.util.Objects; + +/** Bounded asynchronous HTTP response. */ +public record HttpResponseData(int statusCode, URI uri, String body) { + public HttpResponseData { Objects.requireNonNull(uri, "uri"); body = body == null ? "" : body; } + public boolean successful() { return statusCode >= 200 && statusCode < 300; } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/http/SimpleHttpClient.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpTransport.java similarity index 78% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/http/SimpleHttpClient.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpTransport.java index a5207f3f..9ed41865 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/http/SimpleHttpClient.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpTransport.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.http; +package nl.hauntedmc.proxyfeatures.toolkit.http; import java.io.IOException; import java.io.InputStream; @@ -13,7 +13,8 @@ import java.util.Objects; import java.util.stream.Collectors; -public final class SimpleHttpClient { +/** Compatibility helper for legacy callers; new code must inject {@link AsyncHttpTransport}. */ +public final class HttpTransport { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8); private static final int MAX_RESPONSE_BYTES = 1024 * 1024; @@ -23,7 +24,7 @@ public final class SimpleHttpClient { .followRedirects(HttpClient.Redirect.NORMAL) .build(); - private SimpleHttpClient() { + private HttpTransport() { } public static String post(String url, List<FormParameter> args) throws IOException, InterruptedException { @@ -38,6 +39,11 @@ public static String postHttps(String url, List<FormParameter> args) throws IOEx return post(url, args, CLIENT, true); } + /** Sends a bounded JSON request over HTTPS using the shared transport and timeout policy. */ + public static String postJsonHttps(String url, String payload) throws IOException, InterruptedException { + return send(url, "application/json", payload == null ? "" : payload, CLIENT, true); + } + static String post(String url, List<FormParameter> args, HttpClient httpClient) throws IOException, InterruptedException { return post(url, args, httpClient, false); @@ -60,6 +66,17 @@ private static String post( .map(p -> encodeFormComponent(p.name()) + "=" + encodeFormComponent(p.value())) .collect(Collectors.joining("&")); + return send(url, "application/x-www-form-urlencoded", form, httpClient, requireHttps); + } + + private static String send( + String url, + String contentType, + String body, + HttpClient httpClient, + boolean requireHttps + ) throws IOException, InterruptedException { + Objects.requireNonNull(httpClient, "httpClient"); URI uri = URI.create(Objects.requireNonNull(url, "url")); String scheme = uri.getScheme(); if (scheme == null || (!scheme.equalsIgnoreCase("https") && !scheme.equalsIgnoreCase("http"))) { @@ -72,8 +89,8 @@ private static String post( HttpRequest request = HttpRequest.newBuilder() .uri(uri) .timeout(REQUEST_TIMEOUT) - .header("Content-Type", "application/x-www-form-urlencoded") - .POST(HttpRequest.BodyPublishers.ofString(form)) + .header("Content-Type", contentType) + .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (requireHttps && !"https".equalsIgnoreCase(response.uri().getScheme())) { @@ -83,16 +100,16 @@ private static String post( } throw new IOException("HTTPS is required for the final response"); } - String body; + String responseText; try (InputStream stream = response.body()) { byte[] bytes = stream.readNBytes(MAX_RESPONSE_BYTES + 1); if (bytes.length > MAX_RESPONSE_BYTES) { throw new IOException("Response too large"); } - body = new String(bytes, StandardCharsets.UTF_8); + responseText = new String(bytes, StandardCharsets.UTF_8); } if (response.statusCode() >= 200 && response.statusCode() < 300) { - return body; + return responseText; } else { throw new IOException("Unexpected response code: " + response.statusCode()); } diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/JdkAsyncHttpTransport.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/JdkAsyncHttpTransport.java new file mode 100644 index 00000000..3b3f8095 --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/http/JdkAsyncHttpTransport.java @@ -0,0 +1,42 @@ +package nl.hauntedmc.proxyfeatures.toolkit.http; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** JDK transport with an explicit no-redirect policy and bounded response body. */ +public final class JdkAsyncHttpTransport implements AsyncHttpTransport { + public static final int MAX_RESPONSE_BYTES = 1024 * 1024; + private final HttpClient client; + private final Duration timeout; + + public JdkAsyncHttpTransport(HttpClient client, Duration timeout) { + this.client = Objects.requireNonNull(client, "client"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + } + public static JdkAsyncHttpTransport defaults() { + return new JdkAsyncHttpTransport(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NEVER).build(), Duration.ofSeconds(8)); + } + @Override public CompletionStage<HttpResponseData> post(URI uri, String contentType, String body, boolean requireHttps) { + Objects.requireNonNull(uri, "uri"); + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) + || (requireHttps && !scheme.equalsIgnoreCase("https"))) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Unsupported HTTP URI")); + } + HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", contentType) + .POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)).build(); + return client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()).thenApply(response -> { + if (response.body().length > MAX_RESPONSE_BYTES) throw new IllegalStateException("Response too large"); + return new HttpResponseData(response.statusCode(), response.uri(), + new String(response.body(), StandardCharsets.UTF_8)); + }); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheDirectory.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheDirectory.java similarity index 85% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheDirectory.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheDirectory.java index dd866c62..5463358b 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheDirectory.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheDirectory.java @@ -1,7 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; -import nl.hauntedmc.proxyfeatures.api.io.cache.impl.JsonCacheFile; -import nl.hauntedmc.proxyfeatures.api.io.cache.impl.SqliteCacheFile; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.impl.JsonCacheFile; import java.io.File; import java.io.IOException; @@ -49,7 +48,7 @@ public File getDirectory() { * Create or open a cache store file inside this directory. * * @param fileName name without extension — e.g. a player name or "logs" - * @param type YAML, JSON, or SQLITE + * @param type cache serialization type */ public CacheStore getStore(String fileName, CacheType type) { File file; @@ -59,10 +58,6 @@ public CacheStore getStore(String fileName, CacheType type) { file = new File(dir, safeName + ".json"); yield new JsonCacheFile(file); } - case SQLITE -> { - file = new File(dir, safeName + ".db"); - yield new SqliteCacheFile(file); - } }; } diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheStore.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheStore.java similarity index 91% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheStore.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheStore.java index 0204d9f9..314c8541 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheStore.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheStore.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; import java.io.File; diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheType.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheType.java new file mode 100644 index 00000000..fa7f87bc --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheType.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; + +/** + * Supported cache back-ends. + */ +public enum CacheType { + JSON +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheValue.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheValue.java similarity index 97% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheValue.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheValue.java index 4c83c328..86f8d754 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheValue.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheValue.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; import java.util.LinkedHashMap; import java.util.Map; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/FileCacheStore.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/FileCacheStore.java similarity index 91% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/FileCacheStore.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/FileCacheStore.java index 89e34bbd..6c4360ad 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/FileCacheStore.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/FileCacheStore.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; import java.util.Map; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/JsonCacheFile.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/impl/JsonCacheFile.java similarity index 82% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/JsonCacheFile.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/impl/JsonCacheFile.java index e47bc672..529ef51d 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/JsonCacheFile.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/impl/JsonCacheFile.java @@ -1,12 +1,17 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache.impl; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache.impl; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue; -import nl.hauntedmc.proxyfeatures.api.io.cache.FileCacheStore; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.FileCacheStore; import java.io.*; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -62,8 +67,25 @@ private void load() { } private void saveLocked() { - try (Writer w = new FileWriter(file)) { - gson.toJson(rawMap, w); + Path target = file.toPath().toAbsolutePath(); + Path parent = target.getParent(); + try { + if (parent != null) Files.createDirectories(parent); + Path temporary = Files.createTempFile(parent, file.getName(), ".tmp"); + try { + try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) { + gson.toJson(rawMap, writer); + } + try { + Files.move(temporary, target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } } catch (IOException ex) { throw new IllegalStateException("Cannot save cache file " + file, ex); } diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigLoadException.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigLoadException.java new file mode 100644 index 00000000..2ff7c6ec --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigLoadException.java @@ -0,0 +1,20 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import java.nio.file.Path; +import java.util.Objects; + +/** Raised when a YAML file cannot be parsed without replacing its last-known-good state. */ +public final class ConfigLoadException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private final String path; + + public ConfigLoadException(Path path, Throwable cause) { + super("Unable to load configuration file: " + Objects.requireNonNull(path, "path"), cause); + this.path = path.toString(); + } + + public Path path() { + return Path.of(path); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigMap.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigMap.java similarity index 96% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigMap.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigMap.java index c47b9372..75c5c732 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigMap.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigMap.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import java.util.HashMap; import java.util.Map; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigNode.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigNode.java similarity index 95% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigNode.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigNode.java index aa7228dc..f90bb56d 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigNode.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigNode.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import java.util.*; @@ -23,6 +23,9 @@ public static ConfigNode ofRaw(Object raw, String path) { /** @return true if this node is null/absent. */ public boolean isNull() { return value == null; } + /** @return true if this node has a non-null value. */ + public boolean isPresent() { return !isNull(); } + /** Return this node as a given type or default if missing/invalid. */ public <T> T as(Class<T> type, T defaultValue) { return ConfigTypes.convertOrDefault(value, type, defaultValue); diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigPersistenceException.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigPersistenceException.java new file mode 100644 index 00000000..924f837f --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigPersistenceException.java @@ -0,0 +1,21 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import java.nio.file.Path; +import java.util.Objects; + +/** Raised when a configuration mutation cannot be durably persisted. */ +public final class ConfigPersistenceException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private final String path; + + public ConfigPersistenceException(Path path, String operation, Throwable cause) { + super("Unable to " + Objects.requireNonNull(operation, "operation") + + " configuration file: " + Objects.requireNonNull(path, "path"), cause); + this.path = path.toString(); + } + + public Path path() { + return Path.of(path); + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigService.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigService.java similarity index 96% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigService.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigService.java index f5068d94..fb7ab7b7 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigService.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigService.java @@ -1,6 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; -import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesContext; +import nl.hauntedmc.proxyfeatures.toolkit.ToolkitContext; import org.slf4j.Logger; import java.io.IOException; @@ -24,7 +24,7 @@ public final class ConfigService { private final ConcurrentHashMap<Path, YamlFile> cache = new ConcurrentHashMap<>(); /** Preferred: build from the plugin for data dir, logger, and resource classloader. */ - public ConfigService(ProxyFeaturesContext plugin) { + public ConfigService(ToolkitContext plugin) { this(Objects.requireNonNull(plugin.getDataDirectory(), "dataDirectory"), Objects.requireNonNull(plugin.getLogger(), "logger"), plugin.getResourceClassLoader()); diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigTypes.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTypes.java similarity index 99% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigTypes.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTypes.java index a019a754..52ac95a5 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigTypes.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTypes.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigView.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigView.java similarity index 61% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigView.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigView.java index 5f8e6d1e..e1bfec76 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigView.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigView.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.serialize.SerializationException; @@ -92,11 +92,21 @@ public void put(String dottedPath, Object value) { public boolean putIfAbsent(String dottedPath, Object value) { String p = base(dottedPath); - if (!file.contains(p)) { - file.setRawAndSave(p, value); + file.lock().writeLock().lock(); + try { + CommentedConfigurationNode candidate = file.copyRootUnsafe(); + CommentedConfigurationNode node = candidate.node(YamlFile.splitPath(p)); + if (!node.virtual()) { + return false; + } + node.set(value); + file.commitCandidateUnsafe(candidate); return true; + } catch (SerializationException exception) { + throw new IllegalStateException("Unable to set absent configuration value: " + p, exception); + } finally { + file.lock().writeLock().unlock(); } - return false; } public <T> T compute(String dottedPath, Class<T> type, @@ -106,18 +116,18 @@ public <T> T compute(String dottedPath, Class<T> type, file.lock().writeLock().lock(); try { - CommentedConfigurationNode root = file.snapshotUnsafe(); - CommentedConfigurationNode n = root.node(YamlFile.splitPath(p)); - T cur; + CommentedConfigurationNode candidate = file.copyRootUnsafe(); + CommentedConfigurationNode node = candidate.node(YamlFile.splitPath(p)); + T current; try { - cur = n.virtual() ? null : ConfigTypes.convert(n.get(Object.class), type); - } catch (RuntimeException e) { - cur = null; + current = node.virtual() ? null : ConfigTypes.convert(node.get(Object.class), type); + } catch (RuntimeException exception) { + current = null; } - if (cur == null && init != null) cur = init.get(); - T next = Objects.requireNonNull(updateFn.apply(cur), "updateFn returned null"); - n.set(next); - file.saveNow(); + if (current == null && init != null) current = init.get(); + T next = Objects.requireNonNull(updateFn.apply(current), "updateFn returned null"); + node.set(next); + file.commitCandidateUnsafe(candidate); return next; } finally { file.lock().writeLock().unlock(); @@ -128,53 +138,63 @@ public void appendToList(String dottedPath, Object value) { String p = base(dottedPath); file.lock().writeLock().lock(); try { - CommentedConfigurationNode root = file.snapshotUnsafe(); - CommentedConfigurationNode n = root.node(YamlFile.splitPath(p)); - List<?> current = n.getList(Object.class); - List<Object> list = new ArrayList<>(); - if (current != null) list.addAll(current); + CommentedConfigurationNode candidate = file.copyRootUnsafe(); + CommentedConfigurationNode node = candidate.node(YamlFile.splitPath(p)); + List<Object> list = mutableRawList(node); list.add(value); - n.set(list); - file.saveNow(); - } catch (SerializationException | RuntimeException ex) { - // Keep legacy behavior for list nodes lacking serializer support. + node.raw(list); + file.commitCandidateUnsafe(candidate); + } catch (ConfigPersistenceException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IllegalStateException("Unable to append configuration list: " + p, exception); } finally { file.lock().writeLock().unlock(); } } public int removeFromList(String dottedPath, Predicate<Object> predicate) { + Objects.requireNonNull(predicate, "predicate"); String p = base(dottedPath); file.lock().writeLock().lock(); try { - CommentedConfigurationNode root = file.snapshotUnsafe(); - CommentedConfigurationNode n = root.node(YamlFile.splitPath(p)); - List<?> current = n.getList(Object.class); - if (current == null || current.isEmpty()) return 0; - List<Object> list = new ArrayList<>(current); + CommentedConfigurationNode candidate = file.copyRootUnsafe(); + CommentedConfigurationNode node = candidate.node(YamlFile.splitPath(p)); + List<Object> list = mutableRawList(node); + if (list.isEmpty()) return 0; int before = list.size(); list.removeIf(predicate); - if (list.size() != before) { - n.set(list); - file.saveNow(); + int removed = before - list.size(); + if (removed > 0) { + node.raw(list); + file.commitCandidateUnsafe(candidate); } - return before - list.size(); - } catch (SerializationException | RuntimeException e) { + return removed; + } catch (ConfigPersistenceException exception) { + throw exception; + } catch (RuntimeException exception) { return 0; } finally { file.lock().writeLock().unlock(); } } - /** Transaction-style batch mutation: everything saved once at the end if anything changed. */ + private static List<Object> mutableRawList(CommentedConfigurationNode node) { + Object raw = node.raw(); + if (raw == null) return new ArrayList<>(); + if (raw instanceof List<?> list) return new ArrayList<>(list); + throw new IllegalStateException("Configuration value is not a list: " + node.path()); + } + + /** Transaction-style batch mutation committed atomically once at the end. */ public void batch(Consumer<Batch> tx) { Objects.requireNonNull(tx, "tx"); file.lock().writeLock().lock(); try { - CommentedConfigurationNode root = file.snapshotUnsafe(); - Batch b = new Batch(root); - tx.accept(b); - if (b.changed) file.saveNow(); + CommentedConfigurationNode candidate = file.copyRootUnsafe(); + Batch batch = new Batch(candidate); + tx.accept(batch); + if (batch.changed) file.commitCandidateUnsafe(candidate); } finally { file.lock().writeLock().unlock(); } @@ -198,9 +218,9 @@ public Batch put(String dottedPath, Object value) throws SerializationException } public Batch putIfAbsent(String dottedPath, Object value) throws SerializationException { - CommentedConfigurationNode n = root.node(YamlFile.splitPath(base(dottedPath))); - if (n.virtual()) { - n.set(value); + CommentedConfigurationNode node = root.node(YamlFile.splitPath(base(dottedPath))); + if (node.virtual()) { + node.set(value); changed = true; } return this; @@ -208,37 +228,34 @@ public Batch putIfAbsent(String dottedPath, Object value) throws SerializationEx public <T> Batch compute(String dottedPath, Class<T> type, UnaryOperator<T> updateFn, Supplier<T> init) throws SerializationException { - CommentedConfigurationNode n = root.node(YamlFile.splitPath(base(dottedPath))); - T cur; - try { cur = n.virtual() ? null : ConfigTypes.convert(n.get(Object.class), type); } - catch (RuntimeException e) { cur = null; } - if (cur == null && init != null) cur = init.get(); - T next = Objects.requireNonNull(updateFn.apply(cur)); - n.set(next); + CommentedConfigurationNode node = root.node(YamlFile.splitPath(base(dottedPath))); + T current; + try { current = node.virtual() ? null : ConfigTypes.convert(node.get(Object.class), type); } + catch (RuntimeException exception) { current = null; } + if (current == null && init != null) current = init.get(); + T next = Objects.requireNonNull(updateFn.apply(current)); + node.set(next); changed = true; return this; } public Batch appendToList(String dottedPath, Object value) throws SerializationException { - CommentedConfigurationNode n = root.node(YamlFile.splitPath(base(dottedPath))); - List<?> current = n.getList(Object.class); - List<Object> list = new ArrayList<>(); - if (current != null) list.addAll(current); + CommentedConfigurationNode node = root.node(YamlFile.splitPath(base(dottedPath))); + List<Object> list = mutableRawList(node); list.add(value); - n.set(list); + node.raw(list); changed = true; return this; } - public Batch removeFromList(String dottedPath, java.util.function.Predicate<Object> predicate) throws SerializationException { - CommentedConfigurationNode n = root.node(YamlFile.splitPath(base(dottedPath))); - List<?> current = n.getList(Object.class); - if (current == null || current.isEmpty()) return this; - List<Object> list = new ArrayList<>(current); + public Batch removeFromList(String dottedPath, Predicate<Object> predicate) throws SerializationException { + CommentedConfigurationNode node = root.node(YamlFile.splitPath(base(dottedPath))); + List<Object> list = mutableRawList(node); + if (list.isEmpty()) return this; int before = list.size(); list.removeIf(predicate); if (list.size() != before) { - n.set(list); + node.raw(list); changed = true; } return this; diff --git a/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/YamlFile.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/YamlFile.java new file mode 100644 index 00000000..a01b7f8b --- /dev/null +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/YamlFile.java @@ -0,0 +1,191 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import org.slf4j.Logger; +import org.spongepowered.configurate.CommentedConfigurationNode; +import org.spongepowered.configurate.ConfigurationOptions; +import org.spongepowered.configurate.yaml.NodeStyle; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Consumer; + +/** + * Owns a single YAML file (Configurate) + its in-memory root node + a read/write lock. + */ +public final class YamlFile { + private final Path path; + private final Logger logger; + private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); + private final YamlConfigurationLoader loader; + private volatile CommentedConfigurationNode root; + private volatile ConfigLoadException loadFailure; + + public YamlFile(Path path, Logger logger) { + this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + this.logger = Objects.requireNonNull(logger, "logger"); + this.loader = loaderFor(this.path); + reload(); // initial load + } + + public ReentrantReadWriteLock lock() { return rw; } + + /** Load from disk without replacing a previously valid in-memory tree on failure. */ + public void reload() { + rw.writeLock().lock(); + try { + CommentedConfigurationNode loaded = loader.load(); + this.root = loaded; + this.loadFailure = null; + } catch (IOException exception) { + logger.error("[ProxyFeatures] Could not load YAML '{}': {}", path, exception.getMessage()); + ConfigLoadException failure = new ConfigLoadException(path, exception); + this.loadFailure = failure; + throw failure; + } finally { + rw.writeLock().unlock(); + } + } + + /** Persist the currently installed tree atomically. Caller must hold the write lock. */ + void saveNow() { + saveCandidate(root); + } + + /** Direct raw mutation with copy-on-write persistence under the write lock. */ + public void mutateAndSave(Consumer<CommentedConfigurationNode> mutator) { + Objects.requireNonNull(mutator, "mutator"); + rw.writeLock().lock(); + try { + CommentedConfigurationNode candidate = copyRootUnsafe(); + mutator.accept(candidate); + commitCandidateUnsafe(candidate); + } finally { + rw.writeLock().unlock(); + } + } + + // -------- Low-level access used by ConfigView -------- + + Object getRaw(String absolutePath) { + rw.readLock().lock(); + try { + if (absolutePath == null || absolutePath.isBlank()) { + return root.get(Object.class); + } + return root.node(splitPath(absolutePath)).get(Object.class); + } catch (Exception e) { + return null; + } finally { + rw.readLock().unlock(); + } + } + + boolean contains(String absolutePath) { + rw.readLock().lock(); + try { + if (absolutePath == null || absolutePath.isBlank()) { + return !root.virtual(); + } + return !root.node(splitPath(absolutePath)).virtual(); + } finally { + rw.readLock().unlock(); + } + } + + void setRawAndSave(String absolutePath, Object value) { + rw.writeLock().lock(); + try { + CommentedConfigurationNode candidate = copyRootUnsafe(); + if (absolutePath == null || absolutePath.isBlank()) { + candidate.set(value); + } else { + candidate.node(splitPath(absolutePath)).set(value); + } + commitCandidateUnsafe(candidate); + } catch (ConfigPersistenceException exception) { + throw exception; + } catch (Exception exception) { + logger.error("[ProxyFeatures] Failed setting '{}': {}", absolutePath, exception.getMessage()); + throw new ConfigPersistenceException(path, "update '" + absolutePath + "' in", exception); + } finally { + rw.writeLock().unlock(); + } + } + + CommentedConfigurationNode snapshotUnsafe() { // guarded by external lock in ConfigView when used + return root; + } + + CommentedConfigurationNode copyRootUnsafe() { // caller holds the write lock + return root.copy(); + } + + void commitCandidateUnsafe(CommentedConfigurationNode candidate) { // caller holds the write lock + Objects.requireNonNull(candidate, "candidate"); + saveCandidate(candidate); + root = candidate; + } + + private void saveCandidate(CommentedConfigurationNode candidate) { + ConfigLoadException currentLoadFailure = loadFailure; + if (currentLoadFailure != null) { + throw new ConfigPersistenceException( + path, + "save while the latest disk version is invalid for", + currentLoadFailure + ); + } + + Path parent = path.getParent(); + Path temporary = null; + try { + Files.createDirectories(parent); + temporary = Files.createTempFile(parent, "." + path.getFileName(), ".tmp"); + loaderFor(temporary).save(candidate); + try { + Files.move( + temporary, + path, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException exception) { + logger.error("[ProxyFeatures] Could not save YAML '{}': {}", path, exception.getMessage()); + throw new ConfigPersistenceException(path, "save", exception); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + logger.warn("[ProxyFeatures] Could not remove temporary YAML '{}': {}", + temporary, cleanupFailure.getMessage()); + } + } + } + } + + private static YamlConfigurationLoader loaderFor(Path target) { + return YamlConfigurationLoader.builder() + .path(target) + .nodeStyle(NodeStyle.BLOCK) + .defaultOptions(ConfigurationOptions.defaults()) + .build(); + } + + static Object[] splitPath(String dotted) { + if (dotted == null || dotted.isBlank()) return new Object[0]; + String[] parts = dotted.split("\\."); + Object[] out = new Object[parts.length]; + System.arraycopy(parts, 0, out, 0, parts.length); + return out; + } +} diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/localization/Language.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/Language.java similarity index 92% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/localization/Language.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/Language.java index 063de474..6e650c51 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/localization/Language.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/Language.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.localization; +package nl.hauntedmc.proxyfeatures.toolkit.io.localization; import java.util.Arrays; import java.util.List; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/localization/MessageMap.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/MessageMap.java similarity index 89% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/localization/MessageMap.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/MessageMap.java index 026e3268..1e582fc0 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/io/localization/MessageMap.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/MessageMap.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.localization; +package nl.hauntedmc.proxyfeatures.toolkit.io.localization; import java.util.HashMap; import java.util.Map; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/parse/JsonUtils.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/json/JsonStrings.java similarity index 81% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/parse/JsonUtils.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/json/JsonStrings.java index aeda671b..5766a385 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/parse/JsonUtils.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/json/JsonStrings.java @@ -1,6 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.util.parse; +package nl.hauntedmc.proxyfeatures.toolkit.json; -public class JsonUtils { +public class JsonStrings { /** * Escapes special characters in a string for JSON payloads. diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/tools/Paginator.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/pagination/Paginator.java similarity index 93% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/tools/Paginator.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/pagination/Paginator.java index 0daaa557..b5e2d6fa 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/tools/Paginator.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/pagination/Paginator.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.tools; +package nl.hauntedmc.proxyfeatures.toolkit.pagination; import java.util.Collections; import java.util.List; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/TextPatterns.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/TextPatterns.java similarity index 97% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/TextPatterns.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/TextPatterns.java index 1a73fa28..ce9a9e6e 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/TextPatterns.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/TextPatterns.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text; +package nl.hauntedmc.proxyfeatures.toolkit.text; import java.time.format.DateTimeFormatter; import java.util.regex.Pattern; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/ComponentFormatter.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/ComponentFormatter.java similarity index 99% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/ComponentFormatter.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/ComponentFormatter.java index 33858819..f394e02a 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/ComponentFormatter.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/ComponentFormatter.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format; +package nl.hauntedmc.proxyfeatures.toolkit.text.format; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -10,8 +10,8 @@ import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import nl.hauntedmc.proxyfeatures.api.util.text.TextPatterns; -import nl.hauntedmc.proxyfeatures.api.util.text.format.constants.FormatConstants; +import nl.hauntedmc.proxyfeatures.toolkit.text.TextPatterns; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.constants.FormatConstants; import java.util.*; import java.util.function.UnaryOperator; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/TextFormatter.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/TextFormatter.java similarity index 98% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/TextFormatter.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/TextFormatter.java index 05a7b4b4..e56a56fd 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/TextFormatter.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/TextFormatter.java @@ -1,9 +1,9 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format; +package nl.hauntedmc.proxyfeatures.toolkit.text.format; import net.kyori.adventure.text.minimessage.MiniMessage; -import nl.hauntedmc.proxyfeatures.api.util.text.TextPatterns; -import nl.hauntedmc.proxyfeatures.api.util.text.format.color.LegacyColorUtils; -import nl.hauntedmc.proxyfeatures.api.util.text.format.constants.FormatConstants; +import nl.hauntedmc.proxyfeatures.toolkit.text.TextPatterns; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.color.LegacyColorUtils; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.constants.FormatConstants; import java.util.EnumSet; import java.util.Objects; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/color/LegacyColorUtils.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/color/LegacyColorUtils.java similarity index 98% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/color/LegacyColorUtils.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/color/LegacyColorUtils.java index 322036c9..6adba73f 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/color/LegacyColorUtils.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/color/LegacyColorUtils.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format.color; +package nl.hauntedmc.proxyfeatures.toolkit.text.format.color; import java.util.Map; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/constants/FormatConstants.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/constants/FormatConstants.java similarity index 82% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/constants/FormatConstants.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/constants/FormatConstants.java index d1aedf01..e1e4a830 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/constants/FormatConstants.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/constants/FormatConstants.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format.constants; +package nl.hauntedmc.proxyfeatures.toolkit.text.format.constants; public class FormatConstants { public static final char AMP_CHAR = '&'; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/inspect/FormatInspector.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/inspect/FormatInspector.java similarity index 96% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/inspect/FormatInspector.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/inspect/FormatInspector.java index a42750a8..6e8ed3ad 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/format/inspect/FormatInspector.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/inspect/FormatInspector.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format.inspect; +package nl.hauntedmc.proxyfeatures.toolkit.text.format.inspect; import net.kyori.adventure.text.*; import net.kyori.adventure.text.event.ClickEvent; @@ -6,8 +6,8 @@ import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; -import nl.hauntedmc.proxyfeatures.api.util.text.TextPatterns; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.TextPatterns; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; import java.util.EnumSet; import java.util.Objects; diff --git a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/placeholder/MessagePlaceholders.java b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/placeholder/MessagePlaceholders.java similarity index 98% rename from proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/placeholder/MessagePlaceholders.java rename to proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/placeholder/MessagePlaceholders.java index 06821531..eacac67a 100644 --- a/proxyfeatures-api/src/main/java/nl/hauntedmc/proxyfeatures/api/util/text/placeholder/MessagePlaceholders.java +++ b/proxyfeatures-toolkit/src/main/java/nl/hauntedmc/proxyfeatures/toolkit/text/placeholder/MessagePlaceholders.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.placeholder; +package nl.hauntedmc.proxyfeatures.toolkit.text.placeholder; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/http/SimpleHttpClientTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpTransportTest.java similarity index 77% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/http/SimpleHttpClientTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpTransportTest.java index c2dff919..642a793e 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/http/SimpleHttpClientTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/http/HttpTransportTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.http; +package nl.hauntedmc.proxyfeatures.toolkit.http; import org.junit.jupiter.api.Test; @@ -19,7 +19,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -class SimpleHttpClientTest { +class HttpTransportTest { @Test void postReturnsBodyFor2xxUsingInjectedHttpClient() throws Exception { @@ -31,10 +31,10 @@ void postReturnsBodyFor2xxUsingInjectedHttpClient() throws Exception { when(mockClient.send(any(HttpRequest.class), org.mockito.ArgumentMatchers.<HttpResponse.BodyHandler<InputStream>>any())).thenReturn(response); - String body = SimpleHttpClient.post("http://example.test/path", + String body = HttpTransport.post("http://example.test/path", List.of( - new SimpleHttpClient.FormParameter("a b", "x/y"), - new SimpleHttpClient.FormParameter("nullable", null) + new HttpTransport.FormParameter("a b", "x/y"), + new HttpTransport.FormParameter("nullable", null) ), mockClient); assertEquals("ok", body); @@ -51,9 +51,9 @@ void postThrowsForNon2xxAndOversizedResponses() throws Exception { org.mockito.ArgumentMatchers.<HttpResponse.BodyHandler<InputStream>>any())).thenReturn(non2xx); assertThrows(IOException.class, - () -> SimpleHttpClient.post( + () -> HttpTransport.post( "http://example.test/error", - List.of(new SimpleHttpClient.FormParameter("k", "v")), + List.of(new HttpTransport.FormParameter("k", "v")), mockClient )); @@ -65,17 +65,17 @@ void postThrowsForNon2xxAndOversizedResponses() throws Exception { org.mockito.ArgumentMatchers.<HttpResponse.BodyHandler<InputStream>>any())).thenReturn(oversized); assertThrows(IOException.class, - () -> SimpleHttpClient.post("http://example.test/large", List.of(), mockClient)); + () -> HttpTransport.post("http://example.test/large", List.of(), mockClient)); } @Test void postRejectsUnsupportedSchemesAndNullUrl() { Exception ex = assertThrows(IOException.class, - () -> SimpleHttpClient.post("file:///tmp/x", List.of(), mock(HttpClient.class))); + () -> HttpTransport.post("file:///tmp/x", List.of(), mock(HttpClient.class))); assertTrue(ex.getMessage().contains("Unsupported URI scheme")); assertThrows(NullPointerException.class, - () -> SimpleHttpClient.post(null, List.of(new SimpleHttpClient.FormParameter("a b", "x/y")))); + () -> HttpTransport.post(null, List.of(new HttpTransport.FormParameter("a b", "x/y")))); } @Test @@ -83,9 +83,9 @@ void postHttpsRejectsInsecureRequestAndRedirectDowngrade() throws Exception { HttpClient mockClient = mock(HttpClient.class); IOException insecureRequest = assertThrows(IOException.class, - () -> SimpleHttpClient.postHttps( + () -> HttpTransport.postHttps( "http://example.test/path", - List.of(new SimpleHttpClient.FormParameter("k", "v")), + List.of(new HttpTransport.FormParameter("k", "v")), mockClient )); assertTrue(insecureRequest.getMessage().contains("HTTPS is required")); @@ -99,7 +99,7 @@ void postHttpsRejectsInsecureRequestAndRedirectDowngrade() throws Exception { org.mockito.ArgumentMatchers.<HttpResponse.BodyHandler<InputStream>>any())).thenReturn(downgraded); IOException redirect = assertThrows(IOException.class, - () -> SimpleHttpClient.postHttps( + () -> HttpTransport.postHttps( "https://example.test/path", List.of(), mockClient @@ -109,11 +109,11 @@ void postHttpsRejectsInsecureRequestAndRedirectDowngrade() throws Exception { @Test void formParametersRequireNames() { - assertThrows(NullPointerException.class, () -> new SimpleHttpClient.FormParameter(null, "value")); + assertThrows(NullPointerException.class, () -> new HttpTransport.FormParameter(null, "value")); assertThrows(NullPointerException.class, - () -> SimpleHttpClient.post("http://example.test", null, mock(HttpClient.class))); + () -> HttpTransport.post("http://example.test", null, mock(HttpClient.class))); assertThrows(NullPointerException.class, - () -> SimpleHttpClient.post("http://example.test", Collections.singletonList(null), + () -> HttpTransport.post("http://example.test", Collections.singletonList(null), mock(HttpClient.class))); } } diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheDirectoryTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheDirectoryTest.java similarity index 91% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheDirectoryTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheDirectoryTest.java index bece75f6..baa400db 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheDirectoryTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheDirectoryTest.java @@ -1,7 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; -import nl.hauntedmc.proxyfeatures.api.io.cache.impl.JsonCacheFile; -import nl.hauntedmc.proxyfeatures.api.io.cache.impl.SqliteCacheFile; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.impl.JsonCacheFile; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -25,9 +24,7 @@ void createsSanitizedDirectoryAndBuildsRequestedStoreTypes() { assertTrue(directory.getDirectory().getName().contains("cache")); CacheStore json = directory.getStore("../players", CacheType.JSON); - CacheStore sqlite = directory.getStore("db", CacheType.SQLITE); assertInstanceOf(JsonCacheFile.class, json); - assertInstanceOf(SqliteCacheFile.class, sqlite); } @Test diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheValueTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheValueTest.java similarity index 95% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheValueTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheValueTest.java index 80596b31..a96f5bec 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/CacheValueTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/CacheValueTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/JsonCacheFileTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/impl/JsonCacheFileTest.java similarity index 97% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/JsonCacheFileTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/impl/JsonCacheFileTest.java index 656fb8b3..7b4ae231 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/cache/impl/JsonCacheFileTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/cache/impl/JsonCacheFileTest.java @@ -1,6 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.io.cache.impl; +package nl.hauntedmc.proxyfeatures.toolkit.io.cache.impl; -import nl.hauntedmc.proxyfeatures.api.io.cache.CacheValue; +import nl.hauntedmc.proxyfeatures.toolkit.io.cache.CacheValue; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigMapTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigMapTest.java similarity index 95% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigMapTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigMapTest.java index 32e7b512..dfb4f1c4 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigMapTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigMapTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigNodeTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigNodeTest.java similarity index 97% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigNodeTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigNodeTest.java index bbd9ae4d..4a309397 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigNodeTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigNodeTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigPersistenceFailureTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigPersistenceFailureTest.java new file mode 100644 index 00000000..2df72465 --- /dev/null +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigPersistenceFailureTest.java @@ -0,0 +1,57 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ConfigPersistenceFailureTest { + + @TempDir + Path tempDir; + + @Test + void writeFailuresReachCallersInsteadOfBeingLoggedAndSwallowed() throws Exception { + Path directory = tempDir.resolve("readonly"); + Files.createDirectories(directory); + Path path = directory.resolve("config.yml"); + Files.createFile(path); + assumeTrue(Files.getFileAttributeView(directory, PosixFileAttributeView.class) != null); + + ConfigView view = new ConfigView( + new YamlFile(path, LoggerFactory.getLogger(ConfigPersistenceFailureTest.class)), + "" + ); + view.appendToList("items", "one"); + + Set<PosixFilePermission> original = Files.getPosixFilePermissions(directory); + Files.setPosixFilePermissions(directory, EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_EXECUTE + )); + try { + ConfigPersistenceException putFailure = assertThrows( + ConfigPersistenceException.class, + () -> view.put("value", "unpersisted") + ); + assertEquals(path, putFailure.path()); + assertThrows( + ConfigPersistenceException.class, + () -> view.removeFromList("items", ignored -> true) + ); + assertEquals("one", view.getList("items", String.class).getFirst()); + } finally { + Files.setPosixFilePermissions(directory, original); + } + } +} diff --git a/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigReloadSafetyTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigReloadSafetyTest.java new file mode 100644 index 00000000..7ec79476 --- /dev/null +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigReloadSafetyTest.java @@ -0,0 +1,42 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ConfigReloadSafetyTest { + + @TempDir + Path tempDir; + + @Test + void malformedReloadRetainsLastGoodTreeAndBlocksOverwrite() throws Exception { + Path path = tempDir.resolve("config.yml"); + Files.writeString(path, "value: good\n"); + + YamlFile file = new YamlFile(path, LoggerFactory.getLogger(ConfigReloadSafetyTest.class)); + ConfigView view = new ConfigView(file, ""); + assertEquals("good", view.get("value", String.class)); + + String malformed = "value: [unterminated\n"; + Files.writeString(path, malformed); + + ConfigLoadException loadFailure = assertThrows(ConfigLoadException.class, file::reload); + assertEquals(path, loadFailure.path()); + assertEquals("good", view.get("value", String.class)); + assertThrows(ConfigPersistenceException.class, () -> view.put("other", "value")); + assertEquals(malformed, Files.readString(path)); + + Files.writeString(path, "value: fixed\n"); + file.reload(); + view.put("other", "persisted"); + assertEquals("fixed", view.get("value", String.class)); + assertEquals("persisted", view.get("other", String.class)); + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigServiceTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigServiceTest.java similarity index 94% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigServiceTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigServiceTest.java index d07a37c5..67d14cf0 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigServiceTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigServiceTest.java @@ -1,6 +1,6 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; -import nl.hauntedmc.proxyfeatures.api.ProxyFeaturesContext; +import nl.hauntedmc.proxyfeatures.toolkit.ToolkitContext; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -53,7 +53,7 @@ public InputStream getResourceAsStream(String name) { @Test void viewHelpersReturnUsableViews() { ComponentLogger logger = ComponentLogger.logger("ConfigServiceTest"); - ProxyFeaturesContext plugin = mock(ProxyFeaturesContext.class); + ToolkitContext plugin = mock(ToolkitContext.class); when(plugin.getDataDirectory()).thenReturn(tempDir); when(plugin.getLogger()).thenReturn(logger); diff --git a/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTransactionSafetyTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTransactionSafetyTest.java new file mode 100644 index 00000000..d27ebb95 --- /dev/null +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTransactionSafetyTest.java @@ -0,0 +1,97 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigTransactionSafetyTest { + + @TempDir + Path tempDir; + + @Test + void failedBatchLeavesMemoryAndDiskUnchanged() throws Exception { + Path path = tempDir.resolve("batch.yml"); + Files.createFile(path); + ConfigView view = view(path); + view.put("value", "before"); + String persistedBefore = Files.readString(path); + + assertThrows(RuntimeException.class, () -> view.batch(batch -> { + try { + batch.put("value", "after"); + batch.put("other", true); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + throw new RuntimeException("abort"); + })); + + assertEquals("before", view.get("value", String.class)); + assertFalse(view.node("other").isPresent()); + assertEquals(persistedBefore, Files.readString(path)); + } + + @Test + void concurrentPutIfAbsentHasExactlyOneWinner() throws Exception { + Path path = tempDir.resolve("concurrent.yml"); + Files.createFile(path); + ConfigView view = view(path); + int contenders = 16; + ExecutorService executor = Executors.newFixedThreadPool(contenders); + CountDownLatch ready = new CountDownLatch(contenders); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger winners = new AtomicInteger(); + List<Future<?>> futures = new ArrayList<>(); + + try { + for (int index = 0; index < contenders; index++) { + int value = index; + futures.add(executor.submit(() -> { + ready.countDown(); + assertTrue(start.await(5, TimeUnit.SECONDS)); + if (view.putIfAbsent("winner", value)) { + winners.incrementAndGet(); + } + return null; + })); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + for (Future<?> future : futures) { + future.get(10, TimeUnit.SECONDS); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(1, winners.get()); + assertTrue(view.node("winner").isPresent()); + try (var files = Files.list(tempDir)) { + assertFalse(files.anyMatch(candidate -> candidate.getFileName().toString().endsWith(".tmp"))); + } + } + + private static ConfigView view(Path path) { + return new ConfigView( + new YamlFile(path, LoggerFactory.getLogger(ConfigTransactionSafetyTest.class)), + "" + ); + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigTypesTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTypesTest.java similarity index 98% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigTypesTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTypesTest.java index 552d70bd..06fc1b71 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigTypesTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigTypesTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigViewListMutationTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigViewListMutationTest.java new file mode 100644 index 00000000..599dea65 --- /dev/null +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigViewListMutationTest.java @@ -0,0 +1,43 @@ +package nl.hauntedmc.proxyfeatures.toolkit.io.config; + +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.CommentedConfigurationNode; + +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ConfigViewListMutationTest { + + @Test + void listMutationsRejectScalarValuesWithoutOverwritingThem() { + YamlFile file = mock(YamlFile.class); + ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + CommentedConfigurationNode root = CommentedConfigurationNode.root(); + root.node("scope", "items").raw("not-a-list"); + + when(file.lock()).thenReturn(lock); + when(file.copyRootUnsafe()).thenReturn(root.copy()); + + ConfigView view = new ConfigView(file, "scope"); + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> view.appendToList("items", "new-value")); + + assertTrue(exception.getMessage().contains("Unable to append configuration list")); + IllegalStateException cause = assertInstanceOf(IllegalStateException.class, exception.getCause()); + assertTrue(cause.getMessage().contains("not a list")); + assertEquals("not-a-list", root.node("scope", "items").raw()); + assertEquals(0, view.removeFromList("items", ignored -> true)); + assertEquals("not-a-list", root.node("scope", "items").raw()); + assertFalse(lock.isWriteLocked()); + verify(file, never()).saveNow(); + } +} diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigViewTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigViewTest.java similarity index 66% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigViewTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigViewTest.java index 2acd4b9a..b6678263 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/ConfigViewTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/ConfigViewTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -7,15 +7,12 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantReadWriteLock; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class ConfigViewTest { @@ -65,13 +62,14 @@ void computeListMutationsBatchAndRawMutationWork() throws Exception { view.appendToList("list", "a"); view.appendToList("list", "b"); - // Configurate can reject Object.class list conversion in append/remove internals; API intentionally swallows. - assertNull(view.getList("list", String.class)); - assertEquals(0, view.removeFromList("list", o -> "a".equals(o))); + assertEquals(List.of("a", "b"), view.getList("list", String.class)); + assertEquals(1, view.removeFromList("list", o -> "a".equals(o))); + assertEquals(List.of("b"), view.getList("list", String.class)); assertEquals(0, view.removeFromList("missing", o -> true)); assertEquals(0, view.removeFromList("list", o -> { throw new RuntimeException("boom"); })); + assertEquals(List.of("b"), view.getList("list", String.class)); view.batch(b -> { try { @@ -85,22 +83,17 @@ void computeListMutationsBatchAndRawMutationWork() throws Exception { }); assertNull(view.get("batch.a")); - assertNull(view.getList("batch.items", String.class)); - assertThrows(RuntimeException.class, () -> view.batch(b -> { - try { - b.appendToList("batch.items", "x"); - } catch (Exception e) { - throw new RuntimeException(e); - } - })); - assertThrows(RuntimeException.class, () -> view.batch(b -> { + view.batch(b -> { try { - b.removeFromList("batch.items", o -> false); + b.appendToList("batch.items", "x") + .appendToList("batch.items", "y") + .removeFromList("batch.items", "x"::equals); } catch (Exception e) { throw new RuntimeException(e); } - })); + }); + assertEquals(List.of("y"), view.getList("batch.items", String.class)); view.mutateRaw(root -> root.node("raw", "ok").raw(true)); assertEquals(true, view.get("raw.ok", Boolean.class)); @@ -129,71 +122,40 @@ void fallbackReadersAndPathHelpersCoverEdgeBranches() throws Exception { } @Test - void listMutationsAndComputePathsAreCoveredWithMockedBackingNode() throws Exception { + void listMutationsAndComputePathsAreCoveredWithBackingNode() { YamlFile file = mock(YamlFile.class); - CommentedConfigurationNode root = mock(CommentedConfigurationNode.class); - CommentedConfigurationNode node = mock(CommentedConfigurationNode.class); ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - AtomicReference<List<Object>> listRef = new AtomicReference<>(null); + CommentedConfigurationNode root = CommentedConfigurationNode.root(); + root.node("base", "counter").raw("bad value"); when(file.lock()).thenReturn(lock); - when(file.snapshotUnsafe()).thenReturn(root); - when(root.node(any(Object[].class))).thenReturn(node); - when(node.virtual()).thenReturn(false); - when(node.get(org.mockito.ArgumentMatchers.<Class<Object>>any())) - .thenThrow(new IllegalArgumentException("bad value")); - when(node.getList(eq(Object.class))).thenAnswer(inv -> { - List<Object> cur = listRef.get(); - return cur == null ? null : new ArrayList<>(cur); - }); - doAnswer(inv -> { - Object value = inv.getArgument(0); - if (value instanceof List<?> list) { - listRef.set(new ArrayList<>(list)); - } - return null; - }).when(node).set(any()); + stubCopyOnWrite(file, root); ConfigView view = new ConfigView(file, "base"); - Integer next = view.compute("counter", Integer.class, i -> i + 1, () -> 5); + Integer next = assertDoesNotThrow( + () -> view.compute("counter", Integer.class, i -> i + 1, () -> 5)); assertEquals(6, next); view.appendToList("items", "a"); view.appendToList("items", "b"); - assertEquals(List.of("a", "b"), listRef.get()); + assertEquals(List.of("a", "b"), root.node("base", "items").raw()); assertEquals(1, view.removeFromList("items", o -> "a".equals(o))); - listRef.set(List.of()); + assertEquals(List.of("b"), root.node("base", "items").raw()); + root.node("base", "items").raw(List.of()); assertEquals(0, view.removeFromList("items", o -> true)); + verify(file, atLeastOnce()).commitCandidateUnsafe(any(CommentedConfigurationNode.class)); } @Test - void batchMutationsCoverPutIfAbsentComputeAndListBranches() throws Exception { + void batchMutationsCoverPutIfAbsentComputeAndListBranches() { YamlFile file = mock(YamlFile.class); - CommentedConfigurationNode root = mock(CommentedConfigurationNode.class); - CommentedConfigurationNode node = mock(CommentedConfigurationNode.class); ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - AtomicReference<List<Object>> listRef = new AtomicReference<>(null); + CommentedConfigurationNode root = CommentedConfigurationNode.root(); when(file.lock()).thenReturn(lock); - when(file.snapshotUnsafe()).thenReturn(root); - when(root.node(any(Object[].class))).thenReturn(node); - when(node.virtual()).thenReturn(true, false); - when(node.get(eq(Object.class))).thenReturn(new Object()); - when(node.getList(eq(Object.class))).thenAnswer(inv -> { - List<Object> cur = listRef.get(); - return cur == null ? null : new ArrayList<>(cur); - }); - doAnswer(inv -> { - Object value = inv.getArgument(0); - if (value instanceof List<?> list) { - listRef.set(new ArrayList<>(list)); - } else if (value == null) { - listRef.set(null); - } - return null; - }).when(node).set(any()); + stubCopyOnWrite(file, root); ConfigView view = new ConfigView(file, "scope"); view.batch(b -> { @@ -208,8 +170,8 @@ void batchMutationsCoverPutIfAbsentComputeAndListBranches() throws Exception { } }); - assertTrue(listRef.get().isEmpty()); - verify(file, atLeastOnce()).saveNow(); + assertEquals(List.of(), root.node("scope", "list").raw()); + verify(file).commitCandidateUnsafe(any(CommentedConfigurationNode.class)); } @Test @@ -220,13 +182,22 @@ void computeCoversCurrentValueConversionPath() throws Exception { root.node("scope", "count").set(1); when(file.lock()).thenReturn(lock); - when(file.snapshotUnsafe()).thenReturn(root); + stubCopyOnWrite(file, root); ConfigView view = new ConfigView(file, "scope"); Integer next = view.compute("count", Integer.class, i -> i + 1, () -> 0); assertEquals(2, next); - verify(file).saveNow(); + verify(file).commitCandidateUnsafe(any(CommentedConfigurationNode.class)); + } + + private static void stubCopyOnWrite(YamlFile file, CommentedConfigurationNode root) { + when(file.copyRootUnsafe()).thenAnswer(ignored -> root.copy()); + doAnswer(invocation -> { + CommentedConfigurationNode candidate = invocation.getArgument(0); + root.from(candidate); + return null; + }).when(file).commitCandidateUnsafe(any(CommentedConfigurationNode.class)); } private ConfigView createView(String fileName, String base) throws Exception { diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/YamlFileTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/YamlFileTest.java similarity index 81% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/YamlFileTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/YamlFileTest.java index 5124829e..0d2da479 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/config/YamlFileTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/config/YamlFileTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.config; +package nl.hauntedmc.proxyfeatures.toolkit.io.config; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -39,14 +39,15 @@ void readWriteMutateAndContainsWork() throws IOException { } @Test - void reloadHandlesMalformedYamlGracefully() throws IOException { + void malformedYamlIsRejectedDuringInitialLoad() throws IOException { Path path = tempDir.resolve("broken.yml"); Files.writeString(path, "global: [broken"); - YamlFile yaml = new YamlFile(path, LoggerFactory.getLogger(YamlFileTest.class)); - yaml.reload(); - assertNull(yaml.getRaw("global.name")); - assertFalse(yaml.contains("global.name")); + ConfigLoadException exception = assertThrows( + ConfigLoadException.class, + () -> new YamlFile(path, LoggerFactory.getLogger(YamlFileTest.class)) + ); + assertEquals(path, exception.path()); } @Test @@ -65,7 +66,7 @@ void containsRootAndErrorBranchesAreHandled() throws Exception { Files.delete(path); Files.createDirectory(path); - yaml.saveNow(); + assertThrows(ConfigPersistenceException.class, yaml::saveNow); assertEquals(2, yaml.getRaw("b")); } } diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/localization/LanguageTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/LanguageTest.java similarity index 83% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/localization/LanguageTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/LanguageTest.java index 2ef8f163..bf5a8bc0 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/io/localization/LanguageTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/io/localization/LanguageTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.io.localization; +package nl.hauntedmc.proxyfeatures.toolkit.io.localization; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/parse/JsonUtilsTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/json/JsonStringsTest.java similarity index 59% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/parse/JsonUtilsTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/json/JsonStringsTest.java index 0351f092..3c818e27 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/parse/JsonUtilsTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/json/JsonStringsTest.java @@ -1,14 +1,14 @@ -package nl.hauntedmc.proxyfeatures.api.util.parse; +package nl.hauntedmc.proxyfeatures.toolkit.json; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; -class JsonUtilsTest { +class JsonStringsTest { @Test void escapeJsonEscapesBackslashQuotesAndNewlines() { String input = "a\\b\"c\nd"; - assertEquals("a\\\\b\\\"c\\nd", JsonUtils.escapeJson(input)); + assertEquals("a\\\\b\\\"c\\nd", JsonStrings.escapeJson(input)); } } diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/tools/PaginatorTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/pagination/PaginatorTest.java similarity index 95% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/tools/PaginatorTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/pagination/PaginatorTest.java index d60e29b2..0d2c4535 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/tools/PaginatorTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/pagination/PaginatorTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.tools; +package nl.hauntedmc.proxyfeatures.toolkit.pagination; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/TextPatternsTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/TextPatternsTest.java similarity index 97% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/TextPatternsTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/TextPatternsTest.java index ff02a020..f0663eea 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/TextPatternsTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/TextPatternsTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text; +package nl.hauntedmc.proxyfeatures.toolkit.text; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/ComponentFormatterTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/ComponentFormatterTest.java similarity index 98% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/ComponentFormatterTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/ComponentFormatterTest.java index 94b9b7da..58f353a7 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/ComponentFormatterTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/ComponentFormatterTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format; +package nl.hauntedmc.proxyfeatures.toolkit.text.format; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; @@ -6,7 +6,7 @@ import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import nl.hauntedmc.proxyfeatures.api.util.text.format.inspect.FormatInspector; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.inspect.FormatInspector; import org.junit.jupiter.api.Test; import java.util.Set; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/TextFormatterTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/TextFormatterTest.java similarity index 98% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/TextFormatterTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/TextFormatterTest.java index 73a877fe..4f2237ba 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/TextFormatterTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/TextFormatterTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format; +package nl.hauntedmc.proxyfeatures.toolkit.text.format; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/color/LegacyColorUtilsTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/color/LegacyColorUtilsTest.java similarity index 93% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/color/LegacyColorUtilsTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/color/LegacyColorUtilsTest.java index 98dcb36f..1f6d90c7 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/color/LegacyColorUtilsTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/color/LegacyColorUtilsTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format.color; +package nl.hauntedmc.proxyfeatures.toolkit.text.format.color; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/constants/FormatConstantsTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/constants/FormatConstantsTest.java similarity index 88% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/constants/FormatConstantsTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/constants/FormatConstantsTest.java index 680fabca..0d7ac102 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/constants/FormatConstantsTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/constants/FormatConstantsTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format.constants; +package nl.hauntedmc.proxyfeatures.toolkit.text.format.constants; import org.junit.jupiter.api.Test; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/inspect/FormatInspectorTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/inspect/FormatInspectorTest.java similarity index 96% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/inspect/FormatInspectorTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/inspect/FormatInspectorTest.java index b87d34c2..c42c5299 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/format/inspect/FormatInspectorTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/format/inspect/FormatInspectorTest.java @@ -1,11 +1,11 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.format.inspect; +package nl.hauntedmc.proxyfeatures.toolkit.text.format.inspect; import net.kyori.adventure.key.Key; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; -import nl.hauntedmc.proxyfeatures.api.util.text.format.TextFormatter; +import nl.hauntedmc.proxyfeatures.toolkit.text.format.TextFormatter; import org.junit.jupiter.api.Test; import java.util.EnumSet; diff --git a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/placeholder/MessagePlaceholdersTest.java b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/placeholder/MessagePlaceholdersTest.java similarity index 97% rename from proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/placeholder/MessagePlaceholdersTest.java rename to proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/placeholder/MessagePlaceholdersTest.java index ae5591fa..89f53e8e 100644 --- a/proxyfeatures-api/src/test/java/nl/hauntedmc/proxyfeatures/api/util/text/placeholder/MessagePlaceholdersTest.java +++ b/proxyfeatures-toolkit/src/test/java/nl/hauntedmc/proxyfeatures/toolkit/text/placeholder/MessagePlaceholdersTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.proxyfeatures.api.util.text.placeholder; +package nl.hauntedmc.proxyfeatures.toolkit.text.placeholder; import net.kyori.adventure.text.Component; import org.junit.jupiter.api.Test;