From f791d8e3ac99ea5e8e92c0f516a6002496b6e249 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:33:08 +0200 Subject: [PATCH 1/8] docs(openspec): propose navigator-build-destination Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../.openspec.yaml | 2 + .../navigator-build-destination/design.md | 85 +++++++++++++++++++ .../navigator-build-destination/proposal.md | 41 +++++++++ .../specs/lobby-navigator/spec.md | 59 +++++++++++++ .../navigator-build-destination/tasks.md | 37 ++++++++ 5 files changed, 224 insertions(+) create mode 100644 openspec/changes/navigator-build-destination/.openspec.yaml create mode 100644 openspec/changes/navigator-build-destination/design.md create mode 100644 openspec/changes/navigator-build-destination/proposal.md create mode 100644 openspec/changes/navigator-build-destination/specs/lobby-navigator/spec.md create mode 100644 openspec/changes/navigator-build-destination/tasks.md diff --git a/openspec/changes/navigator-build-destination/.openspec.yaml b/openspec/changes/navigator-build-destination/.openspec.yaml new file mode 100644 index 00000000..3b4044e8 --- /dev/null +++ b/openspec/changes/navigator-build-destination/.openspec.yaml @@ -0,0 +1,2 @@ +schema: olf-spec-driven +created: 2026-09-29 diff --git a/openspec/changes/navigator-build-destination/design.md b/openspec/changes/navigator-build-destination/design.md new file mode 100644 index 00000000..0eded83f --- /dev/null +++ b/openspec/changes/navigator-build-destination/design.md @@ -0,0 +1,85 @@ +# Design + +## Context + +Motivation: siehe proposal.md. Anforderungen: `specs/lobby-navigator`. + +Ist-Zustand (`features/navigator`): + +- `Destination` ist ein Enum mit festen Plätzen 0 (ElytraRace), 4 (Survival), 5 (Slender, Flag `NAVIGATOR_SLENDER`) und 8 (Creative, Task `MemberBuild`). **Platz 7 ist frei** (heute graue Glasscheibe). `Destination.visible(FeatureFlags)` filtert nach Flag. +- `NavigatorModule` (`@Singleton`) besitzt genau einen `GlobalInventoryBuilder`, ruft `builder.register()` in `@PostConstruct` und `builder.unregister()` in `@PreDestroy`. `open(player)` ruft `applyLayoutIfChanged()` (`synchronized`, vergleicht die sichtbare Zielliste mit `appliedVisible`, setzt dann Layout und `invalidateLayout()`) und öffnet `builder.getInventory()`. Der Klick-Handler je Ziel ruft `Deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName(task).player(player).build())` und schließt das Inventar. +- `package-info.java`: `@InjectModule(name = "navigatorColumn", requires = {EventNode.class, Deliver.class, FeatureFlags.class}, ...)`. `ColumnArchitectureTest` erzwingt Listener nur über `FeatureNode`, `@PostConstruct` nur an `@Singleton`, kein `BeanScope` und **kein `io.avaje.config`** im Navigator. +- `PermissionService` (`core`, `check(UUID, String)` liefert `ALLOWED`/`DENIED`/`NOT_SET`). `runtime` deklariert ihn in `provides`; `platform/luckperms` liefert `LuckPermsPermissionService`, sonst greift `DenyAllPermissionService` (`@Secondary`, immer `NOT_SET`). Bisher verlangt keine Column den Dienst (`TitanPlayer` in `runtime` nutzt ihn). +- Tests: `NavigatorFixture.start(env, deliver, flags)` baut Modul und Feder wie in Produktion; `RecordingDeliver`, `FakeFeatureFlags`; `NavigatorModuleLeakTest` zählt Listener am Knoten des Moduls und am `eventNode()` des geteilten Inventars. + +Befunde, die geprüft wurden und **keinen** Konflikt ergeben: + +- Die Column darf `PermissionService` verlangen: Er liegt in `core` (die Column hängt schon daran), ist weder generisch noch `@Named`, also reicht `requires = {..., PermissionService.class}` ohne `requiresString`. Keine Regel in `ColumnArchitectureRules` oder im `ColumnArchitectureTest` spricht dagegen; ohne Plattform erfüllt `runtime`s Fallback die Anforderung. +- Zwei Inventare machen die Neuberechnung nicht kompliziert: Der Zustand `appliedVisible` gehört pro Inventar und wird pro Inventar synchronisiert (D2). Kein gemeinsamer Zustand zwischen beiden. +- Platz 7 ist unbelegt; das öffentliche Inventar behält dort seine Glasscheibe. + +## Goals / Non-Goals + +**Goals:** +- Berechtigte sehen `BUILD`, alle anderen ein Menü, das sich durch nichts vom heutigen unterscheidet. +- Das Recht wird beim Öffnen und beim Klick geprüft; nie pro Öffnung entstehen Inventare, Listener oder Objekte, die bleiben. +- Bestehendes Verhalten (Flag-Neuberechnung beim Öffnen, Klick-Ablauf, Leak-Freiheit) bleibt für beide Inventare gleich. + +**Non-Goals:** +- Konfigurierbarkeit von Recht, Platz, Icon oder Task; Liste einzelner Server; Meldung an den Spieler bei entzogenem Recht; ein offenes Team-Inventar sofort umbauen, wenn das Recht entzogen wird (siehe Risiken). + +## Decisions + +### D1: `BUILD` als festes Ziel mit optionalem Recht + +`Destination` bekommt ein weiteres Feld `@Nullable String permission` und den Eintrag `BUILD(7, Material.SCAFFOLDING, "Build", "Build", null, "titan.navigator.buildserver")`. Der Wert `titan.navigator.buildserver` ist eine Konstante im Enum, nicht Konfiguration (die Regel „kein `io.avaje.config`“ bleibt erfüllt). `visible(FeatureFlags)` bleibt für die Flags zuständig; neu ist `visible(FeatureFlags, boolean withPermissioned)`, das Ziele mit Recht nur bei `true` einschließt. Das öffentliche Inventar nutzt `false`, das Team-Inventar `true`. + +**Annahmen (vom Nutzer nicht bestätigt, ändern weder Specs-Struktur noch Aufgaben):** Platz 7 (neben Creative), Icon `Material.SCAFFOLDING`, Anzeigename „Build“ im Stil der übrigen Ziele (``), kein Feature-Flag. +Built-in geprüft: Enum und `Material` reichen; keine Registry nötig. Verworfen: Ziele aus der Konfiguration (widerspricht der bestehenden Anforderung „im Modul festgelegt“) und eine Liste einzelner CloudNet-Dienste (PR #219; braucht Diensterkennung, `:bridge`-Halter und Aktualisierung, während der Task `Build` CloudNets Routing nutzt). +SOLID: OCP im Kleinen (ein Eintrag docken an `visible`/Layout an, ohne den Klick-Ablauf zu ändern); SRP bleibt: `Destination` beschreibt, `NavigatorModule` verdrahtet. +Test: Unit `NavigatorDestinationTest` (Platz 7, Task `Build`, paarweise verschiedene Plätze, `visible` mit und ohne Recht, mit Slender-Flag an/aus). + +### D2: Zwei geteilte Inventare, je einmal gebaut + +`NavigatorModule` hält zwei Aves-`GlobalInventoryBuilder` (öffentlich, Team), jeden in einer kleinen paketinternen Hülle `SharedNavigator` (Builder, `withPermissioned`, `appliedVisible`). Beide werden im Konstruktor gebaut, in `@PostConstruct` mit `register()` angemeldet und in `@PreDestroy` mit `unregister()` abgemeldet. `open(player)` wählt per D3 die Hülle, ruft deren `applyLayoutIfChanged()` (`synchronized` auf der Hülle, sonst wie heute) und öffnet ihr Inventar. Beide tragen den Titel „Navigator“. + +Warum zwei statt eines mit Umbau pro Spieler: Ein Inventar ist für alle geteilt; ein Umbau je Öffnung würde einem gleichzeitigen zweiten Spieler den falschen Stand zeigen. Ein Inventar je Spieler oder je Öffnung ist ausdrücklich ausgeschlossen (Listener- und Objekthaufen, Regel des Nutzers und Anforderung „Öffnen häuft nichts an“). +Built-in geprüft: Aves' `GlobalInventoryBuilder` ist bereits das geteilte Inventar; ein eigener Cache entfällt. Die Flag-Neuberechnung bleibt je Hülle unverändert, also gilt sie für beide Inventare. +SOLID: SRP (Hülle kapselt Builder plus Stand), DRY (dieselbe `toAvesLayout` für beide, nur die Zielliste unterscheidet sich). +Test: Integration (Cyano-`Env`): Berechtigter sieht Platz 7, andere nicht; Flag-Wechsel wirkt auf beide Inventare beim nächsten Öffnen; `NavigatorModuleLeakTest` zählt Listener an beiden Inventar-Knoten und am Modulknoten über 50 Öffnungen und 100 Spieler; nach `stop()` reagiert keines der beiden Inventare mehr. +**Zu prüfen als erster Test:** Dass zwei angemeldete `GlobalInventoryBuilder` sich nicht gegenseitig die Klicks zuordnen (jeder hängt am eigenen `eventNode()` seines Inventars). Bricht das, wird es vor der Umsetzung gemeldet, nicht still umgangen. + +### D3: Recht über `PermissionService`, beim Öffnen und beim Klick + +`NavigatorModule` bekommt `PermissionService` per Konstruktor. Eine Methode `isAllowed(UUID, Destination)` liefert `destination.permission() == null || service.check(uuid, permission) == ALLOWED`; `NOT_SET` und `DENIED` gelten gleich als nicht erteilt (`lobby-permissions`). Beim Öffnen: `ALLOWED` für das Recht von `BUILD` → Team-Inventar, sonst öffentliches. Im Klick-Handler eines Ziels mit Recht: nochmals prüfen; ist es weg, `cancelClick`, **keine** Weiterleitung, `player.closeInventory()`, keine Chat-Nachricht. Ein Spieler, der im Team-Inventar sitzt, dem das Recht entzogen wurde, sieht das Symbol also bis zum Schließen, kann es aber nicht mehr nutzen. +`Deliver` bleibt unverändert: `DeliverComponent.taskBuilder().taskName("Build")` über denselben Pfad wie die anderen Ziele; kein `GuardedDeliver`, kein FeatureGate. +Die Prüfung läuft synchron auf dem Tick-Thread; `LuckPermsPermissionService` und `DenyAllPermissionService` antworten aus dem Speicher (das ist der Vertrag von `check`), also keine Blockierung. +Built-in geprüft: `PermissionService` ist der vorhandene Vertrag (`permission-spi`); Minestoms `Player#hasPermission` entfällt, weil `TitanPlayer` dasselbe nur über denselben Dienst leitet und der Test dann einen Spieler mit Verbindung bräuchte. +SOLID: DIP (Konstruktor, Schnittstelle aus `core`), ISP (nur `check` wird benutzt). +Test: Unit/Integration mit einer `FakePermissionService` je Test (frisch, Ergebnis pro UUID einstellbar): `ALLOWED` → Team-Inventar; `NOT_SET`/`DENIED` → öffentliches; Recht zwischen Öffnen und Klick entzogen → `RecordingDeliver` leer, Inventar geschlossen, Klick abgebrochen; Recht erteilt → genau eine Weiterleitung an `Build`. + +### D4: Column verlangt `PermissionService` + +`package-info.java`: `requires = {EventNode.class, Deliver.class, FeatureFlags.class, PermissionService.class}`, `requiresString` und `provides` unverändert. `docs/lobby-modules.md` (Tabelle der Columns) ergänzt die Zeile `navigator`. +Test: bestehende Verdrahtungstests in `apps/cloudnet` und der Starttest von `apps/local` laufen unverändert grün (Kontrolle beim Build); `ColumnArchitectureTest` bleibt unverändert und grün. +SOLID: DIP. + +### D5: Logging, Metriken, Spans, Nutzertexte + +- Keine neuen Logs, Metriken oder Spans: Öffnen und Klick sind pro Spieleraktion und gehören nach der Regel nicht in Logs oberhalb `DEBUG`; ein abgewiesener Klick ist ein Randfall ohne Betreiberhandlung. +- Nutzertext: Der Anzeigename „Build“ steht wie die übrigen Ziele als MiniMessage-Literal im Code. Das weicht von der Regel „Text aus Übersetzungsdateien“ ab und bleibt bewusst so, weil alle Ziele heute Eigennamen sind und eine Übersetzung der Destinationsnamen ein eigener Change wäre. Keine Chat-Nachricht. + +## Risks / Trade-offs + +- [Zwei Aves-Builder stören sich gegenseitig (D2)] → erster Test prüft das; bei Fehlschlag Meldung an den Nutzer vor der Umsetzung. +- [Ein bereits geöffnetes Team-Inventar zeigt `BUILD` nach Rechtentzug weiter] → Klick prüft erneut und blockt (US-5.03); Umbau des offenen Inventars ist Non-Goal. +- [`NavigatorDestinationTest.visibleIncludesSlenderWhileItsFlagIsOn` erwartet „alle Ziele“] → der Test ändert sich mit `BUILD`: Er erwartet alle Ziele ohne Recht bzw. alle mit Recht; die Anpassung ist Teil von Aufgabe 2. +- [Task `Build` existiert in CloudNet nicht] → die Weiterleitung schlägt wie bei jedem fehlenden Task im vorhandenen Pfad fehl; Betriebsvoraussetzung, siehe Migration. +- [Ohne Plattform (`local`) sieht niemand `BUILD`] → gewollt; `local` braucht keinen Zugang zu Build-Servern. + +## Migration Plan + +1. In CloudNet den Task `Build` anlegen (Build-Server, getrennt von `MemberBuild`). +2. In LuckPerms `titan.navigator.buildserver` der Build-Gruppe erteilen. +3. `titan-cloudnet.jar` deployen (AOT-Cache neu trainieren). Ohne Recht ändert sich für niemanden etwas. +4. Zurück: Recht entziehen (wirkt beim nächsten Öffnen bzw. Klick) oder Revert des Squash-Commits. diff --git a/openspec/changes/navigator-build-destination/proposal.md b/openspec/changes/navigator-build-destination/proposal.md new file mode 100644 index 00000000..0e1edc8d --- /dev/null +++ b/openspec/changes/navigator-build-destination/proposal.md @@ -0,0 +1,41 @@ +# Proposal + +## Why + +Teammitglieder des Build-Teams müssen vom Navigator aus auf die laufenden Build-Server kommen, ohne dass Spieler ohne dieses Recht davon etwas sehen. PR #219 („show build servers in navigator“) wollte das mit einer Liste einzelner CloudNet-Dienste, FeatureGate und einem `GuardedDeliver` lösen. Er hängt gestapelt an #216, das nie in `main` gemergt wurde. Dieser Change ersetzt ihn durch das Kleinste, das trägt: ein festes Ziel, das nur mit Recht erscheint, und CloudNets Task-Routing wählt den Server. + +## What Changes + +- Neues festes Ziel `BUILD` im `Destination`-Enum des Navigators (im Code, nicht in der Konfiguration): Platz 7, Weiterleitung an den CloudNet-Task `Build` (getrennt vom Task `MemberBuild` hinter dem öffentlichen Creative auf Platz 8). Kein Feature-Flag. +- Sichtbar nur, wenn `PermissionService.check(uuid, "titan.navigator.buildserver")` `ALLOWED` ergibt (`NOT_SET` und `DENIED` blenden aus). +- Zwei geteilte Aves-Inventare, je einmal im Modul-Lebenszyklus gebaut, angemeldet und beim Herunterfahren abgemeldet: das öffentliche (unverändert) und das Team-Inventar (öffentliche Ziele plus `BUILD`). Beim Öffnen entscheidet das Recht, welches der Spieler bekommt. +- Klick auf `BUILD` prüft das Recht erneut; ist es weg, gibt es keine Weiterleitung, das Inventar schließt, keine Chat-Nachricht. +- Die Navigator-Column verlangt zusätzlich `PermissionService` (aus `core`; `runtime` liefert ihn, `DenyAllPermissionService` als Fallback). +- Variante `local` ohne Permission-Plattform: `NOT_SET`, also sieht niemand `BUILD`; nichts weiter zu ändern. +- Kein Anzeigen einzelner Server, keine CloudNet-Diensterkennung, kein `:bridge`-Halter, kein FeatureGate, kein `GuardedDeliver`. „Nur laufende Server“ (US-5.04) leistet CloudNets Task-Routing. + +## Capabilities + +### New Capabilities + +Keine. + +### Modified Capabilities + +- `lobby-navigator`: neue Anforderung „Das Ziel Build erscheint nur mit Recht“; die Anforderung „Navigator-Ziele sind im Navigator-Modul festgelegt“ ändert ihren Wortlaut, weil Platz 7 für Berechtigte kein Glas mehr ist und das Ziel Build zu den festen Zielen zählt. + +`lobby-permissions` bleibt unverändert: Die Anforderungen dort regeln, wie der Dienst Rechte liefert (inklusive „nicht gesetzt gilt als nicht erteilt“ und Verhalten ohne Plattform), nicht, wer ihn wofür abfragt. + +## Impact + +- **Code**: `features/navigator` (`Destination`, `NavigatorModule`, `package-info.java` mit `requires += PermissionService.class`); Tests und Fixtures dort (`NavigatorFixture` bekommt einen `PermissionService`-Parameter, neue `FakePermissionService`). `docs/lobby-modules.md` (Tabelle: `navigator` verlangt zusätzlich `PermissionService`). +- **Abhängigkeiten**: keine neuen; `PermissionService` liegt in `core`, die Column hängt bereits daran. +- **Nutzertexte**: ein neuer Anzeigename „Build“ im Navigator, wie die übrigen Ziele als MiniMessage-Literal im Code (kein i18n, wie bei den bestehenden Zielen); keine Chat-Nachricht. +- **Betrieb**: der CloudNet-Task `Build` muss existieren; das Recht `titan.navigator.buildserver` wird in LuckPerms an die Build-Gruppe vergeben. +- **Ersetzt**: PR #219 wird nach dem Merge als überholt geschlossen. + +## Delivery + +PR-Titel: `feat(navigator): show the build server destination to the team` + +Nicht-Ziele: Liste einzelner Build-Server, Chat-Meldung bei entzogenem Recht, Konfigurierbarkeit von Recht, Platz oder Task, Feature-Flag für `BUILD`, weitere rechtegebundene Ziele. diff --git a/openspec/changes/navigator-build-destination/specs/lobby-navigator/spec.md b/openspec/changes/navigator-build-destination/specs/lobby-navigator/spec.md new file mode 100644 index 00000000..a63816f7 --- /dev/null +++ b/openspec/changes/navigator-build-destination/specs/lobby-navigator/spec.md @@ -0,0 +1,59 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Das Ziel Build erscheint nur mit Recht +Der Navigator MUSS das Ziel Build auf Platz 7 zeigen, wenn die Rechteprüfung für `titan.navigator.buildserver` beim Öffnen „erteilt“ ergibt. Ergibt sie „verboten“ oder „nicht gesetzt“, MUSS der Navigator genau das Menü zeigen, das Spieler ohne dieses Ziel sehen: kein Eintrag, keine Lücke, kein Hinweis auf Build. Ein Klick auf Build MUSS das Recht erneut prüfen. Ist es erteilt, MUSS die Lobby den Spieler an das Weiterleitungsziel „Build“ übergeben und den Navigator schließen; sonst DARF sie keine Weiterleitung anstoßen, MUSS den Navigator schließen und DARF dem Spieler keine Chat-Nachricht senden. Welcher Server das Ziel „Build“ bedient, entscheidet die Cloud; die Lobby zeigt keine einzelnen Server. Das Verhalten der übrigen Ziele und der Feature-Flag von Slender MUSS für beide Menüs gleich bleiben. Für das Öffnen, egal wie oft und von wie vielen Spielern, DÜRFEN keine zusätzlichen Event-Listener oder dauerhaft gehaltenen Objekte entstehen. + +#### Scenario: Spieler mit Recht sieht Build +- **WHEN** einem Spieler `titan.navigator.buildserver` erteilt ist und er den Navigator öffnet +- **THEN** liegt auf Platz 7 das Ziel Build, und die übrigen Ziele liegen unverändert auf ihren Plätzen + +#### Scenario: Spieler mit Recht wählt Build +- **WHEN** einem Spieler `titan.navigator.buildserver` erteilt ist und er im Navigator auf Build klickt +- **THEN** wird eine Weiterleitung zum Ziel „Build“ angestoßen, und der Navigator schließt + +#### Scenario: Spieler ohne Recht sieht dasselbe Menü wie bisher +- **WHEN** einem Spieler `titan.navigator.buildserver` nicht gesetzt oder verboten ist und er den Navigator öffnet +- **THEN** liegt auf Platz 7 eine graue Glasscheibe, und das Menü ist in Titel, Zielen und Plätzen identisch mit dem Menü vor dieser Änderung + +#### Scenario: Recht wird zwischen Öffnen und Klick entzogen +- **WHEN** ein Spieler mit Recht den Navigator öffnet, ihm danach das Recht `titan.navigator.buildserver` entzogen wird und er auf Build klickt +- **THEN** wird keine Weiterleitung angestoßen, der Navigator schließt, und der Spieler erhält keine Chat-Nachricht + +#### Scenario: Ohne Permission-Plattform +- **WHEN** die Lobby ohne Permission-Plattform läuft und ein Spieler den Navigator öffnet +- **THEN** liegt auf Platz 7 eine graue Glasscheibe + +#### Scenario: Kein Durchsickern bei wiederholtem Öffnen +- **WHEN** ein Spieler mit Recht und ein Spieler ohne Recht den Navigator abwechselnd 50 Mal öffnen und schließen +- **THEN** sieht jeder bei jedem Öffnen sein eigenes Menü, der Spieler ohne Recht nie Build, und die Anzahl angemeldeter Event-Listener ist unverändert + +#### Scenario: Flag-Wechsel wirkt in beiden Menüs +- **WHEN** `features.NAVIGATOR_SLENDER` zur Laufzeit umgeschaltet wird und je ein Spieler mit und ohne Recht den Navigator danach öffnet +- **THEN** zeigen beide Menüs Slender auf Platz 5 entsprechend dem neuen Zustand der Flag + +## MODIFIED Requirements + +### Requirement: Navigator-Ziele sind im Navigator-Modul festgelegt +Titel und Ziele des Navigators MÜSSEN fest im Navigator-Modul festgelegt sein. Die Lobby DARF Titel und Ziele NICHT aus der Konfiguration lesen, und andere Module DÜRFEN KEINE Ziele beisteuern. Werte unter `navigator.*` aus Dateien, Profilen, Env-Variablen oder System-Properties DÜRFEN den Navigator NICHT beeinflussen. Der Navigator MUSS einen Titel „Navigator“ und eine Reihe mit neun Plätzen zeigen: ElytraRace auf Platz 0, Survival auf Platz 4, Slender auf Platz 5 und Creative auf Platz 8. Spielern mit dem Recht `titan.navigator.buildserver` MUSS er zusätzlich Build auf Platz 7 zeigen (siehe „Das Ziel Build erscheint nur mit Recht“). Die übrigen Plätze MÜSSEN mit grauen Glasscheiben gefüllt sein. + +#### Scenario: Standardziele +- **WHEN** `features.NAVIGATOR_SLENDER` an ist und ein Spieler ohne das Recht `titan.navigator.buildserver` den Navigator öffnet +- **THEN** sieht er den Titel „Navigator“, ElytraRace auf Platz 0, Survival auf Platz 4, Slender auf Platz 5, Creative auf Platz 8 und auf den Plätzen 1, 2, 3, 6 und 7 graue Glasscheiben + +#### Scenario: Standardziele mit Recht +- **WHEN** `features.NAVIGATOR_SLENDER` an ist und ein Spieler mit dem Recht `titan.navigator.buildserver` den Navigator öffnet +- **THEN** sieht er den Titel „Navigator“, ElytraRace auf Platz 0, Survival auf Platz 4, Slender auf Platz 5, Build auf Platz 7, Creative auf Platz 8 und auf den Plätzen 1, 2, 3 und 6 graue Glasscheiben + +#### Scenario: Weiterleitungsziele +- **WHEN** ein Spieler im Navigator ElytraRace, Survival, Slender oder Creative anklickt +- **THEN** wird eine Weiterleitung zum Ziel „ElytraRace“, „Survival“, „cygnus“ bzw. „MemberBuild“ angestoßen + +#### Scenario: Navigator-Werte in der Konfiguration werden ignoriert +- **WHEN** die `application.yaml` des Betreibers `navigator.title` sowie einen Eintrag `navigator.entries.parkour` mit Platz 2 setzt und die Lobby startet +- **THEN** startet die Lobby ohne Fehler, der Titel bleibt „Navigator“, und auf Platz 2 liegt eine graue Glasscheibe + +#### Scenario: Navigator-Wert per Env-Variable wird ignoriert +- **WHEN** die Env-Variable `NAVIGATOR_ENTRIES_SURVIVAL_DESTINATION` auf `Lobby2` steht und ein Spieler Survival anklickt +- **THEN** wird eine Weiterleitung zum Ziel „Survival“ angestoßen diff --git a/openspec/changes/navigator-build-destination/tasks.md b/openspec/changes/navigator-build-destination/tasks.md new file mode 100644 index 00000000..2d04cbde --- /dev/null +++ b/openspec/changes/navigator-build-destination/tasks.md @@ -0,0 +1,37 @@ +# Tasks + +## Execution Plan + +Integrationszweig: `feat/navigator-build-destination` von `origin/main`. Jede Welle endet mit grünem `./gradlew build` und geprüften Diffs. Vor dem Abhaken einer Aufgabe läuft `./gradlew build`. + +| Wave | Agent | Task IDs | Model | May Touch | Must Not Touch | +| ---- | ----- | -------- | ----- | --------- | -------------- | +| 1 | navigator | 1.1–1.2, 2.1–2.6 | sonnet | `features/navigator/**` | `core/**`, `runtime/**`, `platform/**`, `apps/**`, `buildSrc/**`, andere `features/**` | +| 1 | docs | 3.1 | sonnet | `docs/lobby-modules.md` | Code, `features/**`, `openspec/**` | +| 2 | verify | 3.2 | haiku | read-only | alles | +| 3 | pr | 4.1 | sonnet | Git/GitHub | Code | + +Der Agent `navigator` arbeitet in einem Modul und in dieser Reihenfolge, weil `Destination`, `NavigatorModule` und die Fixtures sich gegenseitig bedingen. Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhandenes nutzen (`PermissionService` aus `core`, Aves' `GlobalInventoryBuilder`, `Deliver`), Java 25 ohne Preview, Nutzertext wie die übrigen Ziele als MiniMessage-Literal (Abweichung siehe design.md D5), keine neuen Logs, Metriken oder Spans, Test zuerst, schlanke Kommentare nur fürs Warum, Conventional Commits `feat(navigator): …`. Kein `io.avaje.config` im Navigator (`ColumnArchitectureTest`). F.I.R.S.T.: je Test eine frische `FakePermissionService`, `FakeFeatureFlags` und `NavigatorFixture` (kein geteilter statischer Zustand), `RecordingDeliver` für Weiterleitungen, `env.tick()` statt Warten, kein `Thread.sleep`, keine Systemzeit, Erfolg nur über Assertions mit Meldung. + +## 1. Grundlage: Dienst, Ziel und Fixtures (Welle 1) + +- [ ] 1.1 Test zuerst (Unit, `NavigatorDestinationTest`): `BUILD` liegt auf Platz 7, hat den Task `Build`, kein Feature-Flag und das Recht `titan.navigator.buildserver`; Plätze bleiben paarweise verschieden; `visible(flags, false)` enthält `BUILD` nie, `visible(flags, true)` enthält es; das bestehende „alle Ziele bei Flag an“ gilt nun für `visible(flags, true)`; rot. Dann `Destination` um Recht und `BUILD` erweitern; grün. Nachweis: `./gradlew :features:navigator:test` grün. +- [ ] 1.2 Test-Fixtures ohne Produktionsänderung vorbereiten: `FakePermissionService` (Ergebnis je UUID einstellbar, Standard `NOT_SET`, frisch je Test) anlegen und `NavigatorFixture.start(env, deliver, flags, permissions)` um den vierten Parameter erweitern; alle bestehenden Aufrufer bekommen eine `FakePermissionService` ohne Rechte. Nachweis: Testquellen kompilieren, sobald 2.3 den Konstruktor liefert (bis dahin bewusst rot). + +## 2. Zwei Inventare und Rechteprüfung (Welle 1) + +- [ ] 2.1 Charakterisierung zuerst (Integration, Cyano-`Env`): Die bestehenden Tests `NavigatorModuleTest`, `NavigatorFeatureFlagTest` und `NavigatorModuleLeakTest` pinnen das heutige Menü ohne Recht (Plätze 0, 4, 5, 8, Glas auf 1, 2, 3, 6, 7; Klicks; Flag-Wechsel; Listener-Zahl). Nachweis: alle grün auf dem unveränderten Stand von `origin/main`, bevor 2.3 beginnt. +- [ ] 2.2 Test zuerst (Integration, erster Test dieser Gruppe): Zwei angemeldete `GlobalInventoryBuilder` ordnen Klicks je ihrem eigenen Inventar zu und stören sich nicht (D2). Schlägt das fehl, Umsetzung anhalten und dem Nutzer melden. Nachweis: Test grün oder Meldung an den Nutzer. +- [ ] 2.3 Tests zuerst (Integration, `NavigatorBuildDestinationTest` oder Erweiterung von `NavigatorModuleTest`), je ein Verhalten pro Test: Recht erteilt → Platz 7 zeigt `SCAFFOLDING`, übrige Plätze wie öffentlich; `NOT_SET` → Glas auf Platz 7, Menü gleich dem öffentlichen; `DENIED` → wie `NOT_SET`; Klick mit Recht → `RecordingDeliver` hält genau eine Weiterleitung an `Build`, Klick abgebrochen, Inventar geschlossen; Recht zwischen Öffnen und Klick entzogen → keine Weiterleitung, Inventar geschlossen, Klick abgebrochen, kein Chat; Klick auf Glas auf Platz 7 im öffentlichen Menü → nichts; rot. Dann `NavigatorModule` umsetzen: `PermissionService` im Konstruktor, Hülle `SharedNavigator` je Inventar (Builder, Flag `withPermissioned`, `appliedVisible`, `synchronized` je Hülle), beide in `start()` angemeldet und in `stop()` abgemeldet, Wahl des Inventars beim Öffnen, erneute Prüfung im Klick-Handler eines Ziels mit Recht; grün. Nachweis: `./gradlew :features:navigator:test` grün. +- [ ] 2.4 Test zuerst (Integration): Flag-Wechsel wirkt beim nächsten Öffnen in beiden Menüs (Slender an/aus mit und ohne Recht); nach `stopModule()` reagiert keines der beiden Inventare mehr (kein Abbruch des Klicks, keine Weiterleitung). Nachweis: Tests grün. +- [ ] 2.5 `NavigatorModuleLeakTest` erweitern (Integration): Listener-Zahl am Modulknoten und an den `eventNode()`s **beider** geteilter Inventare bleibt über 50 Öffnungen abwechselnd mit und ohne Recht sowie über 100 Spieler unverändert; das Test-Zugangsmittel `sharedInventory()` wird zu einem Zugang je Inventar. Nachweis: Tests grün, Meldungen nennen das jeweilige Inventar. +- [ ] 2.6 `package-info.java`: `PermissionService.class` in `requires` ergänzen; `ColumnArchitectureTest` bleibt unverändert. Nachweis: `./gradlew build` grün, einschließlich der Verdrahtungstests in `apps/cloudnet` und des Starttests von `apps/local` (kein Wiring-Fehler, `local` zeigt `deny-all`). + +## 3. Doku und Abnahme + +- [ ] 3.1 `docs/lobby-modules.md`: Tabellenzeile `navigator` um `PermissionService.class` ergänzen und im Abschnitt zum Navigator (Aves-Inventar) beschreiben, dass es zwei geteilte Inventare gibt, das Recht `titan.navigator.buildserver`, den Task `Build` und den Betriebsschritt (CloudNet-Task, LuckPerms-Recht). Nachweis: Doku nennt Recht, Task, Platz 7 und beide Inventare; kein anderer Abschnitt geändert. +- [ ] 3.2 Verifikation (Haiku, read-only): Jedes Szenario aus `specs/lobby-navigator` (ADDED und MODIFIED) Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, keine geteilten statischen Zustände, frische Fakes je Test, Assertions mit Meldungen); prüfen, dass `Deliver`/`GuardedDeliver`/FeatureGate/`io.avaje.config` nicht angefasst wurden. Nachweis: Bericht ohne Lücken. + +## 4. Pull Request + +- [ ] 4.1 Pull Request vom Integrationszweig auf `main` unter dem Titel `feat(navigator): show the build server destination to the team` öffnen (Titel und Beschreibung Englisch), mit dem Hinweis, dass er PR #219 ersetzt (Variante: festes Ziel `Build` statt Serverliste, keine Abhängigkeit von #216); danach #219 mit einem Kommentar schließen, der auf den neuen PR verweist (superseded). Nachweis: PR-URL, CI grün, #219 geschlossen. From c65c4d4bc3fdfdaf05c8cdcec796c3bd2fd46863 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:33:51 +0200 Subject: [PATCH 2/8] docs(navigator): document the build destination Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- docs/lobby-modules.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/lobby-modules.md b/docs/lobby-modules.md index fbe3aed0..9e28c053 100644 --- a/docs/lobby-modules.md +++ b/docs/lobby-modules.md @@ -252,11 +252,22 @@ mit festem Platz - das rufen Spawn- und Respawn-Feature auf, nicht jedes Feature `LobbyItems#stack(key)` gibt den gestempelten Stack für ein unplatziertes Item heraus, das ein Feature selbst aushändigt (`ElytraModule`s Feuerwerk). -Der Navigator ist der einzige Sonderfall ohne Andockpunkt: Seine vier Ziele stehen fest im +Der Navigator ist der einzige Sonderfall ohne Andockpunkt: Seine Ziele stehen fest im package-privaten `enum Destination` (s. `openspec/changes/navigator-entries-in-code/design.md`). Nur `Destination.SLENDER` bleibt hinter der Feature-Flag `NAVIGATOR_SLENDER` versteckt, ausgewertet über `FeatureFlags`, dem `NavigatorModule` per Konstruktor übergeben. +Der Navigator besitzt zwei geteilte Aves-Inventare: das öffentliche (unverändert) und das +Team-Inventar (öffentlich plus `Destination.BUILD`). Das Recht `titan.navigator.buildserver` +entscheidet beim Öffnen, welches sich öffnet: `PermissionService#check` liefert `ALLOWED` -> +Team-Inventar, `NOT_SET` und `DENIED` -> öffentliches. `BUILD` liegt auf Platz 7 und leitet an den +CloudNet-Task `Build` weiter (getrennt von `MemberBuild` des Creative-Ziels). Beim Klick wird das +Recht erneut geprüft; ist es inzwischen weg, gibt es keine Weiterleitung, das Inventar schließt. + +Betrieb: Der CloudNet-Task `Build` muss existieren, und das LuckPerms-Recht +`titan.navigator.buildserver` wird der Team-Gruppe erteilt. In der lokalen Variante gilt +`deny-all` (`DenyAllPermissionService`), dort sieht niemand das Ziel. + ## Tasks über den injizierten `Scheduler` Ein wiederkehrender Task wird direkt über den injizierten Minestom-`Scheduler` geplant, nicht über @@ -623,7 +634,7 @@ als `provides`): | `admin` | `CommandManager.class` | - | - | | `spawn` | `Instance.class`, `LobbySpawn.class`, `EventNode.class`, `LobbyItems.class` | `EventNode:titan` | - | | `respawn` | `EventNode.class`, `LobbyItems.class` | `EventNode:titan` | - | -| `navigator` | `EventNode.class`, `Deliver.class`, `FeatureFlags.class` | `EventNode:titan` | `LobbyItem.class` | +| `navigator` | `EventNode.class`, `Deliver.class`, `FeatureFlags.class`, `PermissionService.class` | `EventNode:titan` | `LobbyItem.class` | | `sit` | `EventNode.class` | `EventNode:titan` | - | | `tickle` | `EventNode.class`, `Clock.class` | `EventNode:titan` | - | | `elytra` | `EventNode.class`, `Scheduler.class` | `EventNode:titan` | `LobbyItem.class` | From 021dcbba59edd2b31282d6872c6d5bee580a2f63 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:34:01 +0200 Subject: [PATCH 3/8] docs(openspec): tick docs task of navigator-build-destination Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/navigator-build-destination/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/navigator-build-destination/tasks.md b/openspec/changes/navigator-build-destination/tasks.md index 2d04cbde..19792e4f 100644 --- a/openspec/changes/navigator-build-destination/tasks.md +++ b/openspec/changes/navigator-build-destination/tasks.md @@ -29,7 +29,7 @@ Der Agent `navigator` arbeitet in einem Modul und in dieser Reihenfolge, weil `D ## 3. Doku und Abnahme -- [ ] 3.1 `docs/lobby-modules.md`: Tabellenzeile `navigator` um `PermissionService.class` ergänzen und im Abschnitt zum Navigator (Aves-Inventar) beschreiben, dass es zwei geteilte Inventare gibt, das Recht `titan.navigator.buildserver`, den Task `Build` und den Betriebsschritt (CloudNet-Task, LuckPerms-Recht). Nachweis: Doku nennt Recht, Task, Platz 7 und beide Inventare; kein anderer Abschnitt geändert. +- [x] 3.1 `docs/lobby-modules.md`: Tabellenzeile `navigator` um `PermissionService.class` ergänzen und im Abschnitt zum Navigator (Aves-Inventar) beschreiben, dass es zwei geteilte Inventare gibt, das Recht `titan.navigator.buildserver`, den Task `Build` und den Betriebsschritt (CloudNet-Task, LuckPerms-Recht). Nachweis: Doku nennt Recht, Task, Platz 7 und beide Inventare; kein anderer Abschnitt geändert. - [ ] 3.2 Verifikation (Haiku, read-only): Jedes Szenario aus `specs/lobby-navigator` (ADDED und MODIFIED) Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, keine geteilten statischen Zustände, frische Fakes je Test, Assertions mit Meldungen); prüfen, dass `Deliver`/`GuardedDeliver`/FeatureGate/`io.avaje.config` nicht angefasst wurden. Nachweis: Bericht ohne Lücken. ## 4. Pull Request From 919c62beff989debe81aa1d1e5df91f1ad550f13 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:34:27 +0200 Subject: [PATCH 4/8] test(navigator): pin click routing of two shared aves inventories Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../navigator/AvesSharedInventoriesTest.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/AvesSharedInventoriesTest.java diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/AvesSharedInventoriesTest.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/AvesSharedInventoriesTest.java new file mode 100644 index 00000000..d3e736a7 --- /dev/null +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/AvesSharedInventoriesTest.java @@ -0,0 +1,76 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.navigator; + +import java.util.ArrayList; +import java.util.List; +import net.kyori.adventure.text.Component; +import net.minestom.server.entity.Player; +import net.minestom.server.event.inventory.InventoryPreClickEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.inventory.InventoryType; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.aves.inventory.GlobalInventoryBuilder; +import net.theevilreaper.aves.inventory.layout.InventoryLayout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Pins the assumption behind the navigator's two shared inventories: two registered Aves + * {@link GlobalInventoryBuilder}s each route a click only to their own inventory's handler. + */ +@ExtendWith(MicrotusExtension.class) +class AvesSharedInventoriesTest { + + private static GlobalInventoryBuilder builderRecordingInto(String name, List clicks) { + GlobalInventoryBuilder builder = new GlobalInventoryBuilder(Component.text(name), InventoryType.CHEST_1_ROW); + InventoryLayout layout = InventoryLayout.fromType(InventoryType.CHEST_1_ROW); + layout.setItem(0, ItemStack.of(Material.STONE), (player, slot, click, stack, result) -> clicks.add(name)); + builder.setLayout(layout); + builder.invalidateLayout(); + builder.register(); + return builder; + } + + @DisplayName("Two registered global inventories route a click only to their own handler") + @Test + void twoRegisteredBuildersRouteClicksToTheirOwnInventory(Env env) { + List clicks = new ArrayList<>(); + GlobalInventoryBuilder first = builderRecordingInto("first", clicks); + GlobalInventoryBuilder second = builderRecordingInto("second", clicks); + try { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + player.openInventory(first.getInventory()); + env.process().eventHandler().call(new InventoryPreClickEvent(player.getOpenInventory(), player, new Click.Left(0))); + Assertions.assertEquals(List.of("first"), clicks, "a click in the first inventory must reach only the first handler"); + + player.openInventory(second.getInventory()); + env.process().eventHandler().call(new InventoryPreClickEvent(player.getOpenInventory(), player, new Click.Left(0))); + Assertions.assertEquals(List.of("first", "second"), clicks, "a click in the second inventory must reach only the second handler"); + } finally { + first.unregister(); + second.unregister(); + } + } +} From 555b4702d3bbc618b693a6f183d91f332e69664c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:35:09 +0200 Subject: [PATCH 5/8] feat(navigator): add the permissioned build destination Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/feature/navigator/Destination.java | 23 +++++++---- .../feature/navigator/NavigatorModule.java | 2 +- .../navigator/NavigatorDestinationTest.java | 40 +++++++++++++++++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/Destination.java b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/Destination.java index a1b5c192..0607c1f2 100644 --- a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/Destination.java +++ b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/Destination.java @@ -24,26 +24,28 @@ import org.jetbrains.annotations.Nullable; /** - * One destination shown in the shared navigator inventory, fixed in code as a Java {@code enum} - * since destinations change rarely. Only {@link #SLENDER} is gated behind a feature flag; the - * other three are always visible. + * One destination shown in the shared navigator inventories, fixed in code as a Java {@code enum} + * since destinations change rarely. {@link #SLENDER} is gated behind a feature flag and {@link + * #BUILD} behind a permission; the others are always visible. */ enum Destination { - ELYTRA_RACE(0, Material.ELYTRA, "ElytraRace", "ElytraRace", null), SURVIVAL(4, Material.GRASS_BLOCK, "Survival", "Survival", null), SLENDER(5, Material.ENDERMAN_SPAWN_EGG, "Slender", "cygnus", "NAVIGATOR_SLENDER"), CREATIVE(8, Material.WOODEN_AXE, "Creative", "MemberBuild", null); + ELYTRA_RACE(0, Material.ELYTRA, "ElytraRace", "ElytraRace", null, null), SURVIVAL(4, Material.GRASS_BLOCK, "Survival", "Survival", null, null), SLENDER(5, Material.ENDERMAN_SPAWN_EGG, "Slender", "cygnus", "NAVIGATOR_SLENDER", null), BUILD(7, Material.SCAFFOLDING, "Build", "Build", null, "titan.navigator.buildserver"), CREATIVE(8, Material.WOODEN_AXE, "Creative", "MemberBuild", null, null); private final int slot; private final Material icon; private final String displayName; private final String task; private final @Nullable String feature; + private final @Nullable String permission; - Destination(int slot, Material icon, String displayName, String task, @Nullable String feature) { + Destination(int slot, Material icon, String displayName, String task, @Nullable String feature, @Nullable String permission) { this.slot = slot; this.icon = icon; this.displayName = displayName; this.task = task; this.feature = feature; + this.permission = permission; } int slot() { @@ -59,12 +61,19 @@ String feature() { return this.feature; } + @Nullable + String permission() { + return this.permission; + } + // Rebuilds the icon on every call so callers never share one ItemStack instance. ItemStack item() { return ItemStack.builder(this.icon).customName(MiniMessage.miniMessage().deserialize(this.displayName)).build(); } - static List visible(FeatureFlags featureFlags) { - return Arrays.stream(values()).filter(destination -> destination.feature == null || featureFlags.isActive(destination.feature)).toList(); + // withPermissioned is the caller's verdict for a whole menu: the team menu passes true, the + // public one false. + static List visible(FeatureFlags featureFlags, boolean withPermissioned) { + return Arrays.stream(values()).filter(destination -> destination.feature == null || featureFlags.isActive(destination.feature)).filter(destination -> destination.permission == null || withPermissioned).toList(); } } diff --git a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java index a136efa8..0e34aa74 100644 --- a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java +++ b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java @@ -99,7 +99,7 @@ Inventory sharedInventory() { // Synchronized so two threads opening the navigator at once can't observe, or trigger, half of // a rebuild. private synchronized void applyLayoutIfChanged() { - List visible = Destination.visible(this.featureFlags); + List visible = Destination.visible(this.featureFlags, false); if (visible.equals(this.appliedVisible)) { return; } diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorDestinationTest.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorDestinationTest.java index 55d25cdf..96efcb61 100644 --- a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorDestinationTest.java +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorDestinationTest.java @@ -26,8 +26,7 @@ /** * Plain unit coverage for {@link Destination}: no {@code Env}, no Aves, no configuration - just the - * enum's fixed slots and the pure {@link Destination#visible(net.onelitefeather.titan.core. - * feature.FeatureFlags)} function. + * enum's fixed slots and the pure {@code Destination.visible} function. * *

Pairwise-distinct slots are checked here rather than at start-up: a duplicate slot is a * programming error caught by this test. @@ -40,9 +39,26 @@ void destinationsOccupyTheirFixedSlots() { Assertions.assertEquals(0, Destination.ELYTRA_RACE.slot()); Assertions.assertEquals(4, Destination.SURVIVAL.slot()); Assertions.assertEquals(5, Destination.SLENDER.slot()); + Assertions.assertEquals(7, Destination.BUILD.slot()); Assertions.assertEquals(8, Destination.CREATIVE.slot()); } + @DisplayName("Build forwards to the Build task, has no feature flag and needs titan.navigator.buildserver") + @Test + void buildForwardsToTheBuildTaskBehindItsPermission() { + Assertions.assertEquals("Build", Destination.BUILD.task()); + Assertions.assertNull(Destination.BUILD.feature(), "Build must not sit behind a feature flag"); + Assertions.assertEquals("titan.navigator.buildserver", Destination.BUILD.permission()); + } + + @DisplayName("Only Build needs a permission") + @Test + void onlyBuildNeedsAPermission() { + List permissioned = Arrays.stream(Destination.values()).filter(destination -> destination.permission() != null).toList(); + + Assertions.assertEquals(List.of(Destination.BUILD), permissioned); + } + @DisplayName("No two destinations share a slot") @Test void destinationsHavePairwiseDistinctSlots() { @@ -58,18 +74,34 @@ void destinationsHavePairwiseDistinctSlots() { void visibleExcludesSlenderWhileItsFlagIsOff() { FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", false); - List visible = Destination.visible(flags); + List visible = Destination.visible(flags, false); Assertions.assertFalse(visible.contains(Destination.SLENDER), "Slender must not be visible while NAVIGATOR_SLENDER is off"); Assertions.assertTrue(visible.containsAll(List.of(Destination.ELYTRA_RACE, Destination.SURVIVAL, Destination.CREATIVE)), "the ungated destinations must stay visible regardless of the flag"); } + @DisplayName("visible() without permissioned destinations never lists Build") + @Test + void visibleWithoutPermissionedNeverListsBuild() { + FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true); + + Assertions.assertFalse(Destination.visible(flags, false).contains(Destination.BUILD), "the public menu must never contain Build"); + } + + @DisplayName("visible() with permissioned destinations lists Build") + @Test + void visibleWithPermissionedListsBuild() { + FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", false); + + Assertions.assertTrue(Destination.visible(flags, true).contains(Destination.BUILD), "the team menu must contain Build"); + } + @DisplayName("visible() lists every destination, including Slender, while NAVIGATOR_SLENDER is on") @Test void visibleIncludesSlenderWhileItsFlagIsOn() { FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true); - List visible = Destination.visible(flags); + List visible = Destination.visible(flags, true); Assertions.assertEquals(Set.copyOf(Arrays.asList(Destination.values())), Set.copyOf(visible), "every destination must be visible while NAVIGATOR_SLENDER is on, was: " + visible.stream().map(Enum::name).collect(Collectors.joining(", "))); } From 5b9dfd81b0f73f799c61b224de1a150ceefc57bc Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:38:34 +0200 Subject: [PATCH 6/8] feat(navigator): show build to permitted players in a team inventory Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../feature/navigator/NavigatorModule.java | 62 +++-- .../feature/navigator/SharedNavigator.java | 66 +++++ .../titan/feature/navigator/package-info.java | 3 +- .../navigator/FakePermissionService.java | 51 ++++ .../NavigatorBuildDestinationTest.java | 246 ++++++++++++++++++ .../navigator/NavigatorFeatureFlagTest.java | 6 +- .../feature/navigator/NavigatorFixture.java | 5 +- .../navigator/NavigatorModuleLeakTest.java | 47 +++- .../navigator/NavigatorModuleTest.java | 14 +- 9 files changed, 447 insertions(+), 53 deletions(-) create mode 100644 features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/SharedNavigator.java create mode 100644 features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/FakePermissionService.java create mode 100644 features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorBuildDestinationTest.java diff --git a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java index 0e34aa74..0ec61a31 100644 --- a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java +++ b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/NavigatorModule.java @@ -22,7 +22,6 @@ import java.util.List; import java.util.Objects; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; import net.minestom.server.entity.Player; import net.minestom.server.event.Event; import net.minestom.server.event.EventNode; @@ -34,15 +33,17 @@ import net.onelitefeather.titan.api.deliver.Deliver; import net.onelitefeather.titan.core.module.FeatureNode; import net.onelitefeather.titan.core.feature.FeatureFlags; -import net.theevilreaper.aves.inventory.GlobalInventoryBuilder; +import net.onelitefeather.titan.core.permission.PermissionResult; +import net.onelitefeather.titan.core.permission.PermissionService; import net.theevilreaper.aves.inventory.click.ClickHolder; import net.theevilreaper.aves.inventory.layout.InventoryLayout; /** - * The lobby's navigator: a feather in hotbar slot 4 that opens one Aves-built inventory, shared by - * every player, listing the four destinations fixed in {@link Destination}. + * The lobby's navigator: a feather in hotbar slot 4 that opens one of two Aves-built inventories, + * each shared by every player who gets it: the public one, or the team one that adds the + * permissioned {@code BUILD} destination fixed in {@link Destination}. Opening picks by permission. * - *

Aves maps its click listener directly onto the built {@link GlobalInventoryBuilder} inventory + *

Aves maps its click listener directly onto each built {@code GlobalInventoryBuilder} inventory * and dispatches it before any regular event node's listeners, so this click handling always * completes before another feature could cancel the event first. */ @@ -59,15 +60,16 @@ public final class NavigatorModule { private final EventNode titan; private final Deliver deliver; private final FeatureFlags featureFlags; - private final GlobalInventoryBuilder builder; + private final PermissionService permissions; + private final SharedNavigator publicNavigator = new SharedNavigator(false); + private final SharedNavigator teamNavigator = new SharedNavigator(true); private FeatureNode node; - private List appliedVisible; - public NavigatorModule(@Named(FeatureNode.TITAN_NODE) EventNode titan, Deliver deliver, FeatureFlags featureFlags) { + public NavigatorModule(@Named(FeatureNode.TITAN_NODE) EventNode titan, Deliver deliver, FeatureFlags featureFlags, PermissionService permissions) { this.titan = Objects.requireNonNull(titan, "titan must not be null"); this.deliver = Objects.requireNonNull(deliver, "deliver must not be null"); this.featureFlags = Objects.requireNonNull(featureFlags, "featureFlags must not be null"); - this.builder = new GlobalInventoryBuilder(MiniMessage.miniMessage().deserialize("Navigator"), InventoryType.CHEST_1_ROW); + this.permissions = Objects.requireNonNull(permissions, "permissions must not be null"); } @PostConstruct @@ -75,37 +77,39 @@ void start() { // Listener-less: only attached so this feature shows up in the fixed EVENT_PRIORITY order // and the leak test; Aves handles every inventory click itself. this.node = FeatureNode.attach(this.titan, ID, EVENT_PRIORITY); - applyLayoutIfChanged(); - this.builder.register(); + this.publicNavigator.applyLayoutIfChanged(this.featureFlags, this::toAvesLayout); + this.teamNavigator.applyLayoutIfChanged(this.featureFlags, this::toAvesLayout); + this.publicNavigator.register(); + this.teamNavigator.register(); } @PreDestroy void stop() { this.node.close(); - this.builder.unregister(); + this.publicNavigator.unregister(); + this.teamNavigator.unregister(); } void open(Player player) { - applyLayoutIfChanged(); - player.openInventory(this.builder.getInventory()); + SharedNavigator navigator = isAllowed(player, Destination.BUILD) ? this.teamNavigator : this.publicNavigator; + navigator.applyLayoutIfChanged(this.featureFlags, this::toAvesLayout); + player.openInventory(navigator.inventory()); } - // Test-only: lets a leak test assert the listener count on Aves' event node stays constant + // Test-only: lets a leak test assert the listener count on each Aves event node stays constant // across opens. - Inventory sharedInventory() { - return this.builder.getInventory(); + Inventory publicInventory() { + return this.publicNavigator.inventory(); } - // Synchronized so two threads opening the navigator at once can't observe, or trigger, half of - // a rebuild. - private synchronized void applyLayoutIfChanged() { - List visible = Destination.visible(this.featureFlags, false); - if (visible.equals(this.appliedVisible)) { - return; - } - this.builder.setLayout(toAvesLayout(visible)); - this.builder.invalidateLayout(); - this.appliedVisible = visible; + Inventory teamInventory() { + return this.teamNavigator.inventory(); + } + + // NOT_SET counts as not granted, like DENIED (lobby-permissions). + private boolean isAllowed(Player player, Destination destination) { + String permission = destination.permission(); + return permission == null || this.permissions.check(player.getUuid(), permission) == PermissionResult.ALLOWED; } private InventoryLayout toAvesLayout(List visible) { @@ -116,6 +120,10 @@ private InventoryLayout toAvesLayout(List visible) { for (Destination destination : visible) { layout.setItem(destination.slot(), destination.item(), (player, clickedSlot, click, stack, result) -> { result.accept(ClickHolder.cancelClick()); + if (!isAllowed(player, destination)) { + player.closeInventory(); + return; + } this.deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName(destination.task()).player(player).build()); player.closeInventory(); }); diff --git a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/SharedNavigator.java b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/SharedNavigator.java new file mode 100644 index 00000000..d081aef5 --- /dev/null +++ b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/SharedNavigator.java @@ -0,0 +1,66 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.navigator; + +import java.util.List; +import java.util.function.Function; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.minestom.server.inventory.Inventory; +import net.minestom.server.inventory.InventoryType; +import net.onelitefeather.titan.core.feature.FeatureFlags; +import net.theevilreaper.aves.inventory.GlobalInventoryBuilder; +import net.theevilreaper.aves.inventory.layout.InventoryLayout; + +/** + * One inventory shared by every player who gets this menu, plus the layout state that belongs to + * it. Each instance rebuilds independently, so the public and the team menu never see each + * other's layout. + */ +final class SharedNavigator { + + private final GlobalInventoryBuilder builder; + private final boolean withPermissioned; + private List appliedVisible; + + SharedNavigator(boolean withPermissioned) { + this.builder = new GlobalInventoryBuilder(MiniMessage.miniMessage().deserialize("Navigator"), InventoryType.CHEST_1_ROW); + this.withPermissioned = withPermissioned; + } + + void register() { + this.builder.register(); + } + + void unregister() { + this.builder.unregister(); + } + + Inventory inventory() { + return this.builder.getInventory(); + } + + // Synchronized so two threads opening the navigator at once can't observe, or trigger, half of + // a rebuild. + synchronized void applyLayoutIfChanged(FeatureFlags featureFlags, Function, InventoryLayout> layoutFor) { + List visible = Destination.visible(featureFlags, this.withPermissioned); + if (visible.equals(this.appliedVisible)) { + return; + } + this.builder.setLayout(layoutFor.apply(visible)); + this.builder.invalidateLayout(); + this.appliedVisible = visible; + } +} diff --git a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/package-info.java b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/package-info.java index 427ae352..78a2ec33 100644 --- a/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/package-info.java +++ b/features/navigator/src/main/java/net/onelitefeather/titan/feature/navigator/package-info.java @@ -18,7 +18,7 @@ * Plattform-Beans bekommt" - {@code provides = {LobbyItem.class}}: the navigator feather is * collected by {@code HotbarLobbyItems} in {@code features/hotbar}. */ -@InjectModule(name = "navigatorColumn", requires = {EventNode.class, Deliver.class, FeatureFlags.class}, requiresString = {"net.minestom.server.event.EventNode:titan"}, provides = {LobbyItem.class}) +@InjectModule(name = "navigatorColumn", requires = {EventNode.class, Deliver.class, FeatureFlags.class, PermissionService.class}, requiresString = {"net.minestom.server.event.EventNode:titan"}, provides = {LobbyItem.class}) package net.onelitefeather.titan.feature.navigator; import io.avaje.inject.InjectModule; @@ -26,3 +26,4 @@ import net.onelitefeather.titan.api.deliver.Deliver; import net.onelitefeather.titan.core.feature.FeatureFlags; import net.onelitefeather.titan.core.module.item.LobbyItem; +import net.onelitefeather.titan.core.permission.PermissionService; diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/FakePermissionService.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/FakePermissionService.java new file mode 100644 index 00000000..35841651 --- /dev/null +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/FakePermissionService.java @@ -0,0 +1,51 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.navigator; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.onelitefeather.titan.core.permission.PermissionResult; +import net.onelitefeather.titan.core.permission.PermissionService; + +/** + * A test-only {@link PermissionService}: every check answers {@link PermissionResult#NOT_SET} + * until {@link #set(UUID, String, PermissionResult)} says otherwise for that player and + * permission. + */ +final class FakePermissionService implements PermissionService { + + private final Map results = new HashMap<>(); + + FakePermissionService set(UUID playerId, String permission, PermissionResult result) { + this.results.put(key(playerId, permission), result); + return this; + } + + @Override + public PermissionResult check(UUID playerId, String permission) { + return this.results.getOrDefault(key(playerId, permission), PermissionResult.NOT_SET); + } + + @Override + public String name() { + return "fake"; + } + + private static String key(UUID playerId, String permission) { + return playerId + "/" + permission; + } +} diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorBuildDestinationTest.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorBuildDestinationTest.java new file mode 100644 index 00000000..2656a710 --- /dev/null +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorBuildDestinationTest.java @@ -0,0 +1,246 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.navigator; + +import java.util.List; +import net.minestom.server.entity.Player; +import net.minestom.server.event.inventory.InventoryPreClickEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.inventory.AbstractInventory; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.Material; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.core.permission.PermissionResult; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * End-to-end coverage for the permission-gated {@code BUILD} destination: which of the two shared + * inventories a player gets on open, what a click on Build does, and that both inventories follow + * the feature flag and stop reacting once the module has stopped. + */ +@ExtendWith(MicrotusExtension.class) +class NavigatorBuildDestinationTest { + + private static final int BUILD_SLOT = 7; + private static final String BUILD_PERMISSION = "titan.navigator.buildserver"; + + private static FakeFeatureFlags slenderActive() { + return new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true); + } + + private static AbstractInventory openNavigator(NavigatorFixture fixture, Player player) { + fixture.useFeather(player); + return player.getOpenInventory(); + } + + private static InventoryPreClickEvent click(Env env, AbstractInventory inventory, Player player, int slot) { + InventoryPreClickEvent event = new InventoryPreClickEvent(inventory, player, new Click.Left(slot)); + env.process().eventHandler().call(event); + return event; + } + + private static void assertPublicMenu(AbstractInventory inventory, boolean slenderVisible) { + Assertions.assertEquals(Material.ELYTRA, inventory.getItemStack(0).material()); + Assertions.assertEquals(Material.GRASS_BLOCK, inventory.getItemStack(4).material()); + Assertions.assertEquals(slenderVisible ? Material.ENDERMAN_SPAWN_EGG : Material.GRAY_STAINED_GLASS_PANE, inventory.getItemStack(5).material(), "slot 5 has the wrong material"); + Assertions.assertEquals(Material.WOODEN_AXE, inventory.getItemStack(8).material()); + for (int slot : List.of(1, 2, 3, 6, BUILD_SLOT)) { + Assertions.assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItemStack(slot).material(), "slot " + slot + " should be a blank glass pane"); + } + } + + @DisplayName("A player with the build permission sees Build on slot 7 and the other destinations unchanged") + @Test + void permittedPlayerSeesBuildOnSlotSeven(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + FakePermissionService permissions = new FakePermissionService().set(player.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive(), permissions)) { + fixture.equip(player); + + AbstractInventory inventory = openNavigator(fixture, player); + + Assertions.assertEquals(Material.SCAFFOLDING, inventory.getItemStack(BUILD_SLOT).material(), "slot 7 must show Build"); + Assertions.assertEquals(Material.ELYTRA, inventory.getItemStack(0).material()); + Assertions.assertEquals(Material.GRASS_BLOCK, inventory.getItemStack(4).material()); + Assertions.assertEquals(Material.ENDERMAN_SPAWN_EGG, inventory.getItemStack(5).material()); + Assertions.assertEquals(Material.WOODEN_AXE, inventory.getItemStack(8).material()); + for (int slot : List.of(1, 2, 3, 6)) { + Assertions.assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItemStack(slot).material(), "slot " + slot + " should be a blank glass pane"); + } + } + } + + @DisplayName("A player whose build permission is not set gets the public menu, glass on slot 7") + @Test + void notSetPlayerSeesThePublicMenu(Env env) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive(), new FakePermissionService())) { + Player player = env.createPlayer(env.createFlatInstance()); + fixture.equip(player); + + AbstractInventory inventory = openNavigator(fixture, player); + + assertPublicMenu(inventory, true); + } + } + + @DisplayName("A player whose build permission is denied gets the public menu, glass on slot 7") + @Test + void deniedPlayerSeesThePublicMenu(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + FakePermissionService permissions = new FakePermissionService().set(player.getUuid(), BUILD_PERMISSION, PermissionResult.DENIED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive(), permissions)) { + fixture.equip(player); + + AbstractInventory inventory = openNavigator(fixture, player); + + assertPublicMenu(inventory, true); + } + } + + @DisplayName("Clicking Build with the permission forwards to the Build task and closes the inventory") + @Test + void clickingBuildForwardsToTheBuildTask(Env env) { + RecordingDeliver deliver = new RecordingDeliver(); + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + FakePermissionService permissions = new FakePermissionService().set(player.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), permissions)) { + fixture.equip(player); + AbstractInventory inventory = openNavigator(fixture, player); + + InventoryPreClickEvent event = click(env, inventory, player, BUILD_SLOT); + + Assertions.assertTrue(event.isCancelled(), "the click must be cancelled so the icon stays in place"); + Assertions.assertEquals(1, deliver.deliveries().size(), "exactly one delivery must be recorded"); + Assertions.assertEquals("Build", deliver.deliveries().get(0).taskName()); + Assertions.assertNotSame(inventory, player.getOpenInventory(), "the navigator must close after a click"); + } + } + + @DisplayName("Clicking Build after the permission was revoked forwards nothing, cancels the click and closes the inventory") + @Test + void clickingBuildAfterRevocationIsRefused(Env env) { + RecordingDeliver deliver = new RecordingDeliver(); + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + FakePermissionService permissions = new FakePermissionService().set(player.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), permissions)) { + fixture.equip(player); + AbstractInventory inventory = openNavigator(fixture, player); + permissions.set(player.getUuid(), BUILD_PERMISSION, PermissionResult.NOT_SET); + + InventoryPreClickEvent event = click(env, inventory, player, BUILD_SLOT); + + Assertions.assertTrue(event.isCancelled(), "the click must still be cancelled"); + Assertions.assertTrue(deliver.deliveries().isEmpty(), "a revoked permission must not forward the player"); + Assertions.assertNotSame(inventory, player.getOpenInventory(), "the navigator must close"); + } + } + + @DisplayName("Clicking the glass pane on slot 7 of the public menu forwards nothing and keeps it open") + @Test + void clickingTheGlassPaneOnSlotSevenDoesNothing(Env env) { + RecordingDeliver deliver = new RecordingDeliver(); + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { + Player player = env.createPlayer(env.createFlatInstance()); + fixture.equip(player); + AbstractInventory inventory = openNavigator(fixture, player); + + click(env, inventory, player, BUILD_SLOT); + + Assertions.assertTrue(deliver.deliveries().isEmpty(), "the public menu has no Build to forward to"); + Assertions.assertSame(inventory, player.getOpenInventory(), "the navigator must stay open"); + } + } + + @DisplayName("A flag change shows in the public menu on the next open") + @Test + void flagChangeReachesThePublicMenu(Env env) { + FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", false); + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), flags, new FakePermissionService())) { + Player player = env.createPlayer(env.createFlatInstance()); + fixture.equip(player); + assertPublicMenu(openNavigator(fixture, player), false); + player.closeInventory(); + + flags.set("NAVIGATOR_SLENDER", true); + + assertPublicMenu(openNavigator(fixture, player), true); + } + } + + @DisplayName("A flag change shows in the team menu on the next open") + @Test + void flagChangeReachesTheTeamMenu(Env env) { + FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", false); + Player player = env.createPlayer(env.createFlatInstance()); + FakePermissionService permissions = new FakePermissionService().set(player.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), flags, permissions)) { + fixture.equip(player); + AbstractInventory first = openNavigator(fixture, player); + Assertions.assertEquals(Material.GRAY_STAINED_GLASS_PANE, first.getItemStack(5).material(), "slot 5 must be blank while the flag is off"); + Assertions.assertEquals(Material.SCAFFOLDING, first.getItemStack(BUILD_SLOT).material()); + player.closeInventory(); + + flags.set("NAVIGATOR_SLENDER", true); + + AbstractInventory second = openNavigator(fixture, player); + Assertions.assertEquals(Material.ENDERMAN_SPAWN_EGG, second.getItemStack(5).material(), "slot 5 must show Slender after the flag flipped"); + Assertions.assertEquals(Material.SCAFFOLDING, second.getItemStack(BUILD_SLOT).material(), "Build must survive the relayout"); + } + } + + @DisplayName("Once the module is stopped, the public inventory no longer reacts to clicks") + @Test + void publicInventoryIsDeadAfterStop(Env env) { + RecordingDeliver deliver = new RecordingDeliver(); + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { + Player player = env.createPlayer(env.createFlatInstance()); + fixture.equip(player); + AbstractInventory inventory = openNavigator(fixture, player); + + fixture.stopModule(); + + InventoryPreClickEvent event = click(env, inventory, player, 4); + Assertions.assertFalse(event.isCancelled(), "no feature code may run once the module has stopped"); + Assertions.assertTrue(deliver.deliveries().isEmpty(), "no delivery may happen once the module has stopped"); + } + } + + @DisplayName("Once the module is stopped, the team inventory no longer reacts to clicks") + @Test + void teamInventoryIsDeadAfterStop(Env env) { + RecordingDeliver deliver = new RecordingDeliver(); + Player player = env.createPlayer(env.createFlatInstance()); + FakePermissionService permissions = new FakePermissionService().set(player.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), permissions)) { + fixture.equip(player); + AbstractInventory inventory = openNavigator(fixture, player); + + fixture.stopModule(); + + InventoryPreClickEvent event = click(env, inventory, player, BUILD_SLOT); + Assertions.assertFalse(event.isCancelled(), "no feature code may run once the module has stopped"); + Assertions.assertTrue(deliver.deliveries().isEmpty(), "no delivery may happen once the module has stopped"); + } + } +} diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFeatureFlagTest.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFeatureFlagTest.java index 6c3ba5b1..00d67650 100644 --- a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFeatureFlagTest.java +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFeatureFlagTest.java @@ -40,7 +40,7 @@ class NavigatorFeatureFlagTest { @Test void slenderHiddenWhenFlagIsOff(Env env) { FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", false); - try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), flags)) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), flags, new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -61,7 +61,7 @@ void slenderHiddenWhenFlagIsOff(Env env) { void slenderShownAndForwardsWhenFlagIsOn(Env env) { FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true); RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, flags)) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, flags, new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -83,7 +83,7 @@ void slenderShownAndForwardsWhenFlagIsOn(Env env) { @Test void togglingTheFlagBetweenTwoOpensShowsSlenderOnTheSecondOpen(Env env) { FakeFeatureFlags flags = new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", false); - try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), flags)) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), flags, new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFixture.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFixture.java index 3dafdb4f..42645b45 100644 --- a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFixture.java +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorFixture.java @@ -22,6 +22,7 @@ import net.onelitefeather.titan.api.deliver.Deliver; import net.onelitefeather.titan.core.feature.FeatureFlags; import net.onelitefeather.titan.core.module.item.ItemSlot; +import net.onelitefeather.titan.core.permission.PermissionService; import net.onelitefeather.titan.core.module.item.LobbyItem; import net.onelitefeather.titan.core.testfixtures.TestTitanNode; @@ -51,9 +52,9 @@ private NavigatorFixture(TestTitanNode titan, NavigatorModule module, LobbyItem this.feather = feather; } - static NavigatorFixture start(Env env, Deliver deliver, FeatureFlags featureFlags) { + static NavigatorFixture start(Env env, Deliver deliver, FeatureFlags featureFlags, PermissionService permissions) { TestTitanNode titan = TestTitanNode.attach(env); - NavigatorModule module = new NavigatorModule(titan.node(), deliver, featureFlags); + NavigatorModule module = new NavigatorModule(titan.node(), deliver, featureFlags, permissions); module.start(); LobbyItem feather = new NavigatorItems().navigatorFeather(module); return new NavigatorFixture(titan, module, feather); diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleLeakTest.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleLeakTest.java index 0d01002e..1ea537b6 100644 --- a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleLeakTest.java +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleLeakTest.java @@ -24,6 +24,7 @@ import net.minestom.server.instance.Instance; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.core.permission.PermissionResult; import net.onelitefeather.titan.core.testfixtures.EventListenerCounter; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -33,7 +34,7 @@ /** * Leak coverage for {@link NavigatorModule}: repeatedly opening and closing the shared navigator, * and many players joining, opening it once and leaving, must never change the listener count on - * the module's own node or the shared inventory's node. + * the module's own node or either shared inventory's node. * *

{@link NavigatorModule} registers no listener of its own; {@link NavigatorModule#start()} * only attaches an empty {@link net.onelitefeather.titan.core.module.FeatureNode} and registers @@ -55,40 +56,59 @@ private static EventNode navigatorNode(Env env) { return children.get(0); } - @DisplayName("Opening and closing the navigator 50 times registers no extra listeners") + private static final String BUILD_PERMISSION = "titan.navigator.buildserver"; + + private static FakeFeatureFlags slenderActive() { + return new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true); + } + + @DisplayName("Opening and closing the navigator 50 times, alternating with and without permission, registers no extra listeners") @Test void openingAndClosingRepeatedlyDoesNotLeakListeners(Env env) { - try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true))) { - Instance instance = env.createFlatInstance(); - Player player = env.createPlayer(instance); - fixture.equip(player); + Instance instance = env.createFlatInstance(); + Player team = env.createPlayer(instance); + Player other = env.createPlayer(instance); + FakePermissionService permissions = new FakePermissionService().set(team.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive(), permissions)) { + fixture.equip(team); + fixture.equip(other); EventNode navigatorNode = navigatorNode(env); - EventNode avesInventoryNode = fixture.module().sharedInventory().eventNode(); + EventNode publicNode = fixture.module().publicInventory().eventNode(); + EventNode teamNode = fixture.module().teamInventory().eventNode(); int moduleListenersBefore = EventListenerCounter.countListeners(navigatorNode); - int avesListenersBefore = EventListenerCounter.countListeners(avesInventoryNode); + int publicListenersBefore = EventListenerCounter.countListeners(publicNode); + int teamListenersBefore = EventListenerCounter.countListeners(teamNode); for (int i = 0; i < OPEN_CLOSE_COUNT; i++) { + Player player = i % 2 == 0 ? team : other; fixture.useFeather(player); player.closeInventory(); } Assertions.assertEquals(moduleListenersBefore, EventListenerCounter.countListeners(navigatorNode), "opening and closing the navigator must never register another listener on the module's own node"); - Assertions.assertEquals(avesListenersBefore, EventListenerCounter.countListeners(avesInventoryNode), "opening and closing the navigator must never register another listener on the shared inventory's node"); + Assertions.assertEquals(publicListenersBefore, EventListenerCounter.countListeners(publicNode), "opening and closing the navigator must never register another listener on the public inventory's node"); + Assertions.assertEquals(teamListenersBefore, EventListenerCounter.countListeners(teamNode), "opening and closing the navigator must never register another listener on the team inventory's node"); } } @DisplayName("100 players joining, opening the navigator once and leaving leaves the listener count unchanged") @Test void manyPlayersJoinOpenAndLeaveWithoutLeakingListeners(Env env) { - try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), new FakeFeatureFlags().declare("NAVIGATOR_SLENDER", true))) { + FakePermissionService permissions = new FakePermissionService(); + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive(), permissions)) { Instance instance = env.createFlatInstance(); EventNode navigatorNode = navigatorNode(env); - EventNode avesInventoryNode = fixture.module().sharedInventory().eventNode(); + EventNode publicNode = fixture.module().publicInventory().eventNode(); + EventNode teamNode = fixture.module().teamInventory().eventNode(); int moduleListenersBefore = EventListenerCounter.countListeners(navigatorNode); - int avesListenersBefore = EventListenerCounter.countListeners(avesInventoryNode); + int publicListenersBefore = EventListenerCounter.countListeners(publicNode); + int teamListenersBefore = EventListenerCounter.countListeners(teamNode); for (int i = 0; i < PLAYER_COUNT; i++) { Player player = env.createPlayer(instance); + if (i % 2 == 0) { + permissions.set(player.getUuid(), BUILD_PERMISSION, PermissionResult.ALLOWED); + } fixture.equip(player); fixture.useFeather(player); @@ -97,7 +117,8 @@ void manyPlayersJoinOpenAndLeaveWithoutLeakingListeners(Env env) { } Assertions.assertEquals(moduleListenersBefore, EventListenerCounter.countListeners(navigatorNode), "100 players opening the navigator and leaving must not change the listener count on the module's own node"); - Assertions.assertEquals(avesListenersBefore, EventListenerCounter.countListeners(avesInventoryNode), "100 players opening the navigator and leaving must not change the listener count on the shared inventory's node"); + Assertions.assertEquals(publicListenersBefore, EventListenerCounter.countListeners(publicNode), "100 players opening the navigator and leaving must not change the listener count on the public inventory's node"); + Assertions.assertEquals(teamListenersBefore, EventListenerCounter.countListeners(teamNode), "100 players opening the navigator and leaving must not change the listener count on the team inventory's node"); } } } diff --git a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleTest.java b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleTest.java index 3c0a6c13..61cddb9c 100644 --- a/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleTest.java +++ b/features/navigator/src/test/java/net/onelitefeather/titan/feature/navigator/NavigatorModuleTest.java @@ -56,7 +56,7 @@ private static AbstractInventory openNavigator(NavigatorFixture fixture, Player @DisplayName("Opening the navigator via the feather shows the four fixed destinations, synchronously") @Test void openingTheNavigatorShowsTheFourDestinations(Env env) { - try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, new RecordingDeliver(), slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -80,7 +80,7 @@ void openingTheNavigatorShowsTheFourDestinations(Env env) { @Test void clickingElytraRaceForwardsToElytraRace(Env env) { RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -100,7 +100,7 @@ void clickingElytraRaceForwardsToElytraRace(Env env) { @Test void clickingSurvivalForwardsToSurvival(Env env) { RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -120,7 +120,7 @@ void clickingSurvivalForwardsToSurvival(Env env) { @Test void clickingSlenderForwardsToCygnus(Env env) { RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -140,7 +140,7 @@ void clickingSlenderForwardsToCygnus(Env env) { @Test void clickingCreativeForwardsToMemberBuild(Env env) { RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -160,7 +160,7 @@ void clickingCreativeForwardsToMemberBuild(Env env) { @Test void clickingABlankSlotTriggersNoDeliveryAndKeepsTheNavigatorOpen(Env env) { RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); @@ -178,7 +178,7 @@ void clickingABlankSlotTriggersNoDeliveryAndKeepsTheNavigatorOpen(Env env) { @Test void clicksNoLongerForwardOnceTheModuleIsStopped(Env env) { RecordingDeliver deliver = new RecordingDeliver(); - try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive())) { + try (NavigatorFixture fixture = NavigatorFixture.start(env, deliver, slenderActive(), new FakePermissionService())) { Instance instance = env.createFlatInstance(); Player player = env.createPlayer(instance); fixture.equip(player); From 9c06020bab8cd792046f6b84cf914e1548fbb6d1 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:38:34 +0200 Subject: [PATCH 7/8] docs(openspec): tick navigator tasks of navigator-build-destination Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../changes/navigator-build-destination/tasks.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openspec/changes/navigator-build-destination/tasks.md b/openspec/changes/navigator-build-destination/tasks.md index 2d04cbde..0b749a85 100644 --- a/openspec/changes/navigator-build-destination/tasks.md +++ b/openspec/changes/navigator-build-destination/tasks.md @@ -15,17 +15,17 @@ Der Agent `navigator` arbeitet in einem Modul und in dieser Reihenfolge, weil `D ## 1. Grundlage: Dienst, Ziel und Fixtures (Welle 1) -- [ ] 1.1 Test zuerst (Unit, `NavigatorDestinationTest`): `BUILD` liegt auf Platz 7, hat den Task `Build`, kein Feature-Flag und das Recht `titan.navigator.buildserver`; Plätze bleiben paarweise verschieden; `visible(flags, false)` enthält `BUILD` nie, `visible(flags, true)` enthält es; das bestehende „alle Ziele bei Flag an“ gilt nun für `visible(flags, true)`; rot. Dann `Destination` um Recht und `BUILD` erweitern; grün. Nachweis: `./gradlew :features:navigator:test` grün. -- [ ] 1.2 Test-Fixtures ohne Produktionsänderung vorbereiten: `FakePermissionService` (Ergebnis je UUID einstellbar, Standard `NOT_SET`, frisch je Test) anlegen und `NavigatorFixture.start(env, deliver, flags, permissions)` um den vierten Parameter erweitern; alle bestehenden Aufrufer bekommen eine `FakePermissionService` ohne Rechte. Nachweis: Testquellen kompilieren, sobald 2.3 den Konstruktor liefert (bis dahin bewusst rot). +- [x] 1.1 Test zuerst (Unit, `NavigatorDestinationTest`): `BUILD` liegt auf Platz 7, hat den Task `Build`, kein Feature-Flag und das Recht `titan.navigator.buildserver`; Plätze bleiben paarweise verschieden; `visible(flags, false)` enthält `BUILD` nie, `visible(flags, true)` enthält es; das bestehende „alle Ziele bei Flag an“ gilt nun für `visible(flags, true)`; rot. Dann `Destination` um Recht und `BUILD` erweitern; grün. Nachweis: `./gradlew :features:navigator:test` grün. +- [x] 1.2 Test-Fixtures ohne Produktionsänderung vorbereiten: `FakePermissionService` (Ergebnis je UUID einstellbar, Standard `NOT_SET`, frisch je Test) anlegen und `NavigatorFixture.start(env, deliver, flags, permissions)` um den vierten Parameter erweitern; alle bestehenden Aufrufer bekommen eine `FakePermissionService` ohne Rechte. Nachweis: Testquellen kompilieren, sobald 2.3 den Konstruktor liefert (bis dahin bewusst rot). ## 2. Zwei Inventare und Rechteprüfung (Welle 1) -- [ ] 2.1 Charakterisierung zuerst (Integration, Cyano-`Env`): Die bestehenden Tests `NavigatorModuleTest`, `NavigatorFeatureFlagTest` und `NavigatorModuleLeakTest` pinnen das heutige Menü ohne Recht (Plätze 0, 4, 5, 8, Glas auf 1, 2, 3, 6, 7; Klicks; Flag-Wechsel; Listener-Zahl). Nachweis: alle grün auf dem unveränderten Stand von `origin/main`, bevor 2.3 beginnt. -- [ ] 2.2 Test zuerst (Integration, erster Test dieser Gruppe): Zwei angemeldete `GlobalInventoryBuilder` ordnen Klicks je ihrem eigenen Inventar zu und stören sich nicht (D2). Schlägt das fehl, Umsetzung anhalten und dem Nutzer melden. Nachweis: Test grün oder Meldung an den Nutzer. -- [ ] 2.3 Tests zuerst (Integration, `NavigatorBuildDestinationTest` oder Erweiterung von `NavigatorModuleTest`), je ein Verhalten pro Test: Recht erteilt → Platz 7 zeigt `SCAFFOLDING`, übrige Plätze wie öffentlich; `NOT_SET` → Glas auf Platz 7, Menü gleich dem öffentlichen; `DENIED` → wie `NOT_SET`; Klick mit Recht → `RecordingDeliver` hält genau eine Weiterleitung an `Build`, Klick abgebrochen, Inventar geschlossen; Recht zwischen Öffnen und Klick entzogen → keine Weiterleitung, Inventar geschlossen, Klick abgebrochen, kein Chat; Klick auf Glas auf Platz 7 im öffentlichen Menü → nichts; rot. Dann `NavigatorModule` umsetzen: `PermissionService` im Konstruktor, Hülle `SharedNavigator` je Inventar (Builder, Flag `withPermissioned`, `appliedVisible`, `synchronized` je Hülle), beide in `start()` angemeldet und in `stop()` abgemeldet, Wahl des Inventars beim Öffnen, erneute Prüfung im Klick-Handler eines Ziels mit Recht; grün. Nachweis: `./gradlew :features:navigator:test` grün. -- [ ] 2.4 Test zuerst (Integration): Flag-Wechsel wirkt beim nächsten Öffnen in beiden Menüs (Slender an/aus mit und ohne Recht); nach `stopModule()` reagiert keines der beiden Inventare mehr (kein Abbruch des Klicks, keine Weiterleitung). Nachweis: Tests grün. -- [ ] 2.5 `NavigatorModuleLeakTest` erweitern (Integration): Listener-Zahl am Modulknoten und an den `eventNode()`s **beider** geteilter Inventare bleibt über 50 Öffnungen abwechselnd mit und ohne Recht sowie über 100 Spieler unverändert; das Test-Zugangsmittel `sharedInventory()` wird zu einem Zugang je Inventar. Nachweis: Tests grün, Meldungen nennen das jeweilige Inventar. -- [ ] 2.6 `package-info.java`: `PermissionService.class` in `requires` ergänzen; `ColumnArchitectureTest` bleibt unverändert. Nachweis: `./gradlew build` grün, einschließlich der Verdrahtungstests in `apps/cloudnet` und des Starttests von `apps/local` (kein Wiring-Fehler, `local` zeigt `deny-all`). +- [x] 2.1 Charakterisierung zuerst (Integration, Cyano-`Env`): Die bestehenden Tests `NavigatorModuleTest`, `NavigatorFeatureFlagTest` und `NavigatorModuleLeakTest` pinnen das heutige Menü ohne Recht (Plätze 0, 4, 5, 8, Glas auf 1, 2, 3, 6, 7; Klicks; Flag-Wechsel; Listener-Zahl). Nachweis: alle grün auf dem unveränderten Stand von `origin/main`, bevor 2.3 beginnt. +- [x] 2.2 Test zuerst (Integration, erster Test dieser Gruppe): Zwei angemeldete `GlobalInventoryBuilder` ordnen Klicks je ihrem eigenen Inventar zu und stören sich nicht (D2). Schlägt das fehl, Umsetzung anhalten und dem Nutzer melden. Nachweis: Test grün oder Meldung an den Nutzer. +- [x] 2.3 Tests zuerst (Integration, `NavigatorBuildDestinationTest` oder Erweiterung von `NavigatorModuleTest`), je ein Verhalten pro Test: Recht erteilt → Platz 7 zeigt `SCAFFOLDING`, übrige Plätze wie öffentlich; `NOT_SET` → Glas auf Platz 7, Menü gleich dem öffentlichen; `DENIED` → wie `NOT_SET`; Klick mit Recht → `RecordingDeliver` hält genau eine Weiterleitung an `Build`, Klick abgebrochen, Inventar geschlossen; Recht zwischen Öffnen und Klick entzogen → keine Weiterleitung, Inventar geschlossen, Klick abgebrochen, kein Chat; Klick auf Glas auf Platz 7 im öffentlichen Menü → nichts; rot. Dann `NavigatorModule` umsetzen: `PermissionService` im Konstruktor, Hülle `SharedNavigator` je Inventar (Builder, Flag `withPermissioned`, `appliedVisible`, `synchronized` je Hülle), beide in `start()` angemeldet und in `stop()` abgemeldet, Wahl des Inventars beim Öffnen, erneute Prüfung im Klick-Handler eines Ziels mit Recht; grün. Nachweis: `./gradlew :features:navigator:test` grün. +- [x] 2.4 Test zuerst (Integration): Flag-Wechsel wirkt beim nächsten Öffnen in beiden Menüs (Slender an/aus mit und ohne Recht); nach `stopModule()` reagiert keines der beiden Inventare mehr (kein Abbruch des Klicks, keine Weiterleitung). Nachweis: Tests grün. +- [x] 2.5 `NavigatorModuleLeakTest` erweitern (Integration): Listener-Zahl am Modulknoten und an den `eventNode()`s **beider** geteilter Inventare bleibt über 50 Öffnungen abwechselnd mit und ohne Recht sowie über 100 Spieler unverändert; das Test-Zugangsmittel `sharedInventory()` wird zu einem Zugang je Inventar. Nachweis: Tests grün, Meldungen nennen das jeweilige Inventar. +- [x] 2.6 `package-info.java`: `PermissionService.class` in `requires` ergänzen; `ColumnArchitectureTest` bleibt unverändert. Nachweis: `./gradlew build` grün, einschließlich der Verdrahtungstests in `apps/cloudnet` und des Starttests von `apps/local` (kein Wiring-Fehler, `local` zeigt `deny-all`). ## 3. Doku und Abnahme From 7ea6937dbfbcc4a2fa2884b91a24f4419c722fa4 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 13:42:38 +0200 Subject: [PATCH 8/8] docs(openspec): tick verification task of navigator-build-destination Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/navigator-build-destination/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/navigator-build-destination/tasks.md b/openspec/changes/navigator-build-destination/tasks.md index 40557f6e..7f1a3ad2 100644 --- a/openspec/changes/navigator-build-destination/tasks.md +++ b/openspec/changes/navigator-build-destination/tasks.md @@ -30,7 +30,7 @@ Der Agent `navigator` arbeitet in einem Modul und in dieser Reihenfolge, weil `D ## 3. Doku und Abnahme - [x] 3.1 `docs/lobby-modules.md`: Tabellenzeile `navigator` um `PermissionService.class` ergänzen und im Abschnitt zum Navigator (Aves-Inventar) beschreiben, dass es zwei geteilte Inventare gibt, das Recht `titan.navigator.buildserver`, den Task `Build` und den Betriebsschritt (CloudNet-Task, LuckPerms-Recht). Nachweis: Doku nennt Recht, Task, Platz 7 und beide Inventare; kein anderer Abschnitt geändert. -- [ ] 3.2 Verifikation (Haiku, read-only): Jedes Szenario aus `specs/lobby-navigator` (ADDED und MODIFIED) Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, keine geteilten statischen Zustände, frische Fakes je Test, Assertions mit Meldungen); prüfen, dass `Deliver`/`GuardedDeliver`/FeatureGate/`io.avaje.config` nicht angefasst wurden. Nachweis: Bericht ohne Lücken. +- [x] 3.2 Verifikation (Haiku, read-only): Jedes Szenario aus `specs/lobby-navigator` (ADDED und MODIFIED) Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, keine geteilten statischen Zustände, frische Fakes je Test, Assertions mit Meldungen); prüfen, dass `Deliver`/`GuardedDeliver`/FeatureGate/`io.avaje.config` nicht angefasst wurden. Nachweis: Bericht ohne Lücken. ## 4. Pull Request