From b8b9a69b5016fe41502604cc312b1c57eefe3714 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:44:59 +0200 Subject: [PATCH 01/17] docs(openspec): propose setup-portal-command Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../setup-portal-command/.openspec.yaml | 2 + .../changes/setup-portal-command/design.md | 141 ++++++++++ .../changes/setup-portal-command/proposal.md | 47 ++++ .../specs/setup-portals/spec.md | 255 ++++++++++++++++++ .../changes/setup-portal-command/tasks.md | 51 ++++ 5 files changed, 496 insertions(+) create mode 100644 openspec/changes/setup-portal-command/.openspec.yaml create mode 100644 openspec/changes/setup-portal-command/design.md create mode 100644 openspec/changes/setup-portal-command/proposal.md create mode 100644 openspec/changes/setup-portal-command/specs/setup-portals/spec.md create mode 100644 openspec/changes/setup-portal-command/tasks.md diff --git a/openspec/changes/setup-portal-command/.openspec.yaml b/openspec/changes/setup-portal-command/.openspec.yaml new file mode 100644 index 00000000..3b4044e8 --- /dev/null +++ b/openspec/changes/setup-portal-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: olf-spec-driven +created: 2026-09-29 diff --git a/openspec/changes/setup-portal-command/design.md b/openspec/changes/setup-portal-command/design.md new file mode 100644 index 00000000..d2c323ad --- /dev/null +++ b/openspec/changes/setup-portal-command/design.md @@ -0,0 +1,141 @@ +# Design + +## Context + +Motivation: siehe proposal.md. Anforderungen: `specs/setup-portals`. **Voraussetzung:** `lobby-portals` ist in `main` (die Typen `Portal`, `PortalShape` mit `Box`/`Disc` in `net.onelitefeather.titan.core.portal`, `PortalValidator`/`PortalProblem`, `LobbyMap.portals` samt Builder-Methode und Gson-Format sind Arbeitsnamen aus dem Entwurf dieses Changes; Aufgabe 1.1 gleicht sie mit dem gelieferten Code ab). + +Ist-Zustand des Setup-Servers: + +- Eigene, DI-freie `Titan`-Klasse (`setup`): privater Konstruktor legt Instanz und `MapProvider` an, liest `SetupSpawnConfig`, registriert `BlockHandlerHelper` sowie `SetupCommand(mapProvider)` und die Listener von Hand. Kein Avaje, keine Column, keine Module. +- `SetupCommand` (`/setup`, `Conditions::playerOnly`) hängt `MapCommand` als Unterbefehl ein. `MapCommand` (`setspawn`, `setname`, `setauthor`) speichert mit `mapProvider.saveMap(LobbyMap.lobbyMapBuilder(mapProvider.getActiveLobby()).x(...).build())`. Es gibt kein Recht auf den `/setup`-Befehlen; der Setup-Server ist ein internes Werkzeug des Build-Teams. +- `MapProvider.saveMap` schreibt die `map.json` der aktiven Welt (`GsonFileHandler` von Aves, Gson mit `PositionGsonAdapter`) und lädt sie neu (`loadMapData`). `LobbyMap.lobbyMapBuilder(map)` kopiert Spawn, Name und Autoren in den Builder; Felder, die dort fehlen, gehen beim Speichern verloren. +- Nachrichten sind englische MiniMessage-Strings mit ``, aufgelöst durch `TitanMiniMessageImpl` (`common`). Das Repository hat keine `TranslationStore`-Infrastruktur. +- Der Setup-Server nutzt Aves nur für Map und Gson, keine Inventare, kein Wand-Item, keine Partikel. Tests: JUnit ohne Cyano (`TitanLauncherTest`, `SetupSpawnSettingsTest`). + +## Goals / Non-Goals + +**Goals:** +- Portale ohne JSON-Handarbeit anlegen, nie ungültig speichern. +- Bearbeitungslogik, Ringbildung, Umriss, Führungsschritte, Vorschau-Geometrie und Vervollständigung ohne Minestom-Server testbar. +- Den Nutzer aktiv unterstützen: geführter Ablauf mit Knöpfen, Live-Vorschau des Entwurfs, Tab-Vervollständigung. + +**Non-Goals:** +- Rückgängig-Stapel, Wand-Item (mögliches Folge-Change), Bearbeiten anderer Welten, Vorschau in der Lobby (siehe proposal.md). +- Die Ladelogik und Validierung der Lobby (liegt in `lobby-portals`). + +## Decisions + +### D1: `PortalCommand` als Unterbefehl von `SetupCommand` + +`/setup portal ...` ist ein weiterer Unterbefehl neben `MapCommand`; `SetupCommand` bekommt eine Zeile, `Titan` keine (der `MapProvider` wird schon durchgereicht). Syntaxen (Minestom `addSyntax`), `` ist `ArgumentType.Word` mit Prüfung `[a-z0-9_-]+`: + +- `list`, `show` als Literale allein +- ` pos1`, ` pos2`, ` remove` +- ` disc ` (`ArgumentType.Double`) +- ` task ` (`ArgumentType.StringArray`, zusammengefügt; Aufgaben können Leerzeichen enthalten, leer wird abgelehnt) +- ` permission ` (`Word`; `none` entfernt) + +`list` und `show` sind als Id gesperrt, damit `/setup portal list` nie doppeldeutig ist. Die Prüfung der Id sitzt in der Logik (D2), nicht im Argument, damit der Chat den Grund nennen kann. + +- Built-in first: Minestoms Command-Framework, so wie `MapCommand` es nutzt. Kein eigenes Parsing. +- Neue kleine Verben für den geführten Ablauf (D9): `create `, ` shape box|ring`, ` centre`, ` radius `, ` save`, ` cancel`; `create` ist wie `list`/`show` als Id gesperrt. `centre` setzt Mittelpunkt und Normale wie `disc`, ohne Radius; `radius` ändert nur den Radius (sonst risse ein Klick auf einen Radius-Knopf den Mittelpunkt an die neue Position des Spielers). `disc ` bleibt als Kurzform für beides. +- Test: Der Befehl ist eine dünne Hülle ohne Logik; getestet über D2. Ein Integrationstest mit Cyano prüft, dass jede Syntax ihre Methode erreicht (Aufgabe 3.3), soweit Cyano im Setup-Modul verfügbar ist. +- SOLID: Open/Closed, ein neuer Unterbefehl statt Änderung an `MapCommand`; Single Responsibility, der Befehl parst und rendert, die Logik liegt woanders. + +### D2: `PortalEditor` als reine Logik hinter der Naht `PortalStore` + +`PortalEditor` (Paket `net.onelitefeather.titan.setup.portal`) hält die Entwürfe je Spieler-UUID und führt jede Änderung aus. Er bekommt `PortalStore` (`List portals()`, `void save(List)`) und gibt ein sealed Ergebnis zurück (`Saved`, `Updated`, `Pending(fehlt)`, `Complete`, `Removed`, `Rejected(Grund)`, `Cancelled`, `Unknown`), das der Befehl per `switch` in Text übersetzt. Die Standardimplementierung `MapProviderPortalStore` liest `getActiveLobby().portals()` und speichert per `saveMap(LobbyMap.lobbyMapBuilder(active).portals(list).build())` (D6). + +- Built-in first: Records, sealed Interfaces und Pattern-Matching-`switch` (Java 25) statt Exceptions als Steuerfluss oder Statuscodes. Kein Framework nötig. +- Test (Unit, ohne Server): `PortalEditorTest` mit einem In-Memory-`PortalStore`. +- SOLID: Dependency Inversion (`PortalStore`), Single Responsibility (Logik getrennt von Befehl und Datei). + +### D3: Entwurf je Spieler, gespeichert wird nur per `save` + +Ein Entwurf (`PortalDraft`, veränderlich: Ecke 1, Ecke 2, Ring (Mittelpunkt, Normale, Radius oder nichts) oder nichts, gewählte Form, Aufgabe, Recht, Flag `guided`) gehört einem Spieler und einer Id. Beim ersten Zugriff wird er aus dem gespeicherten Portal mit dieser Id befüllt (Quader: Ecken aus `min`/`max`), sonst leer. Jeder Bearbeitungsbefehl ändert nur den Entwurf und liefert `Pending(fehlt)` bzw. `Complete` (Form und Aufgabe vorhanden; der Chat zeigt „vollständig" mit anklickbarem [save]). Gespeichert wird ausschließlich durch `save`, für Befehle und geführten Ablauf gleich: `save` prüft Vollständigkeit, Regeln des Befehls und `PortalValidator.problems(...)` für die Id (D5); ist alles gut, ersetzt der Entwurf ein Portal mit derselben Id an dessen Listenposition, sonst wird er angehängt (`Saved`/`Updated`); sonst `Pending`/`Rejected`, der Entwurf bleibt offen. Das Flag `guided` entfällt als Speicherregel; es steuert nur, ob nach einem Befehl der nächste Schritt des Ablaufs gezeigt wird (D9). Der Entwurf eines gespeicherten Portals bleibt auch nach einer Änderung Entwurf, bis `save` läuft. Bei `disc` ersetzt der Ring die Form und verwirft die Ecken; bei `pos1`/`pos2` verwirft der Quader-Entwurf den Ring, das gespeicherte Portal bleibt, bis der Quader vollständig ist. `cancel` verwirft den Entwurf. `list` zeigt die gespeicherten Portale und darunter getrennt die offenen Entwürfe des Spielers mit Stand (KISS: ein zweiter Abschnitt in `PortalMessages`, keine eigene Ansicht). Entwürfe leben nur im Speicher und werden beim Verlassen des Spielers verworfen (ein `PlayerDisconnectEvent`-Listener in `Titan`). + +Verworfen: Die Reihenfolge „erst `task`, dann Form“ zu erzwingen (lästig) und ein einziger globaler Entwurf je Id (zwei Builder überschreiben einander die Ecken). Verworfen: Portale mit leerer Aufgabe zu speichern (die Lobby bräche beim Start ab) und Auto-Speichern bei Vollständigkeit (unbeabsichtigte Zwischenstände in der `map.json`, zwei Speicherwege). + +- Test (Unit): jede Szene aus `specs/setup-portals` als Test in `PortalEditorTest`, auch zwei Spieler, dieselbe Id. +- SOLID: Single Responsibility; der Entwurf kennt die Regeln nicht, die prüft D5. + +### D4: Ring aus Augenposition und Blickrichtung + +`DiscPlacement.of(eye, direction, radius)` (reine Funktion, Records `Vec`/`Pos` aus Minestom) liefert `Disc`: Mittelpunkt = **Augenposition**, jede Koordinate auf 0,5 gerundet (`Math.round(v * 2) / 2.0`); Normale = normierte Blickrichtung, eingerastet auf die nächste der sechs Achsen, wenn der Winkel höchstens 5 Grad beträgt. + +- Augenposition statt Fußposition: Mittelpunkt und Blickrichtung teilen dann denselben Ursprung, die Normale steht wirklich senkrecht auf dem, was der Builder anpeilt. Wer den Ring „durchschaut“, steht in dessen Mitte; die Fußposition läge 1,62 Blöcke daneben. Rundung auf 0,5 gibt saubere Werte wie im Beispiel (`0.5, 72, 40.5`), die man von Hand nicht trifft. Nachteil: Die Höhe hängt von der Haltung (Schleichen, Fliegen) ab; die Rückmeldung nennt den gespeicherten Mittelpunkt, damit man ihn ablesen kann. +- Einrasten (5 Grad, Konstante): Wer „geradeaus“ schaut, trifft die Achse selten exakt; ohne Einrasten stünden krumme Normalen wie (0,003, 0,01, 0,99996) in der Datei, und ein Quader-Test der Lobby wäre schwerer nachzuvollziehen. Schrägen Ringen (Diagonale, Neigung) bleibt die genaue Richtung. Verworfen: Einrasten abschaltbar zu machen (mehr Oberfläche ohne Bedarf). +- Test (Unit): Achsen, 2 Grad neben +Z, 6 Grad neben +Z, Diagonale, gerundeter Mittelpunkt, Blick nach oben/unten, negativer Radius wird von D5 abgelehnt. +- SOLID: Reine Funktion, keine Abhängigkeiten. + +### D5: Validierung ist die der Lobby, nicht eine Kopie + +Vor dem Speichern läuft dieselbe Prüfung wie beim Lobby-Start. Das Ziel ist, dass keine zweite Regelkopie entsteht (DRY): `lobby-portals` liefert `net.onelitefeather.titan.core.portal.PortalValidator` mit `static List problems(List)` (`PortalProblem(String portalId, String reason)`) und `requireValid(String world, List)`; `Box`, `Disc` und `Portal` sind reine Daten ohne Konstruktorprüfung. `PortalEditor` baut die Liste, wie sie nach dem Speichern in der Datei stünde (Ersetzen an der Listenposition oder Anhängen), ruft `PortalValidator.problems(...)`, nimmt die Probleme mit `portalId` gleich der bearbeiteten Id und macht daraus `Rejected(Grund)`; `PortalMessages` bildet sie auf Chat-Meldungen ab (der `reason` als `Placeholder.unparsed`). Probleme anderer, schon vorher ungültiger Portale blockieren die Bearbeitung dieses Portals nicht. `requireValid` bleibt dem Lobby-Start. Fehlt dort eine Regel, die die Spec hier nennt, wandert sie in `PortalValidator` (Anpassung in `lobby-portals`, nicht hier kopiert). Nur die Regeln des Befehls selbst liegen im Setup-Modul: Id-Muster, gesperrte Ids (`list`, `show`, `create`), Radius als Zahl, leere Aufgabe vor dem Trimmen sowie die Vollständigkeit des Entwurfs (beide Ecken, Radius, Aufgabe), weil `Box` ohne Ecken gar nicht existiert. + +- Test (Unit): jede Regel aus der Spec als Ablehnungstest; ein Test, der `PortalValidator.requireValid` und den Editor an derselben ungültigen Liste vergleicht, hält beide im Gleichschritt. +- SOLID: Dependency Inversion auf die Regeln in `core`; Open/Closed. + +### D6: Speichern über den vorhandenen Weg, ohne Datenverlust + +Gespeichert wird `LobbyMap.lobbyMapBuilder(active).portals(list).build()` über `MapProvider.saveMap`. Das setzt voraus, dass `lobby-portals` `lobbyMapBuilder(map)` die Portale mitkopieren lässt, sonst löschte `/setup map setspawn` alle Portale. Aufgabe 1.1 prüft das; fehlt es, ergänzt `lobby-portals` es (kleine Korrektur dort, mit Test), nicht dieser Change. Die Liste ist unveränderlich (`List.copyOf`). + +Nebenwirkung, unverändert vom Bestand: `saveMap` lädt die Map neu (`loadMapData`), auch für Portale. Das ist derselbe Pfad wie bei `setspawn` und kostet ein Neuladen der Map-Datei. + +- Built-in first: `MapProvider`/Aves-`GsonFileHandler` und das Gson-Format von `lobby-portals`, keine eigene Serialisierung. +- Test: Unit über `PortalStore`; Persistenztest (Integration, `@TempDir`): `MapProvider.create(tempDir, instance)` über einer `worlds/world/map.json` mit Spawn, Name, Autor und einem Portal, Portal über `MapProviderPortalStore` speichern, mit einem frischen `MapProvider` neu laden und Spawn, Name, Autoren, alte und neue Portale vergleichen; danach `MapCommand`-Pfad (`setspawn`) ausführen und prüfen, dass die Portale bleiben. +- SOLID: Dependency Inversion; der Setup-Server ändert `MapProvider` nicht. + +### D7: Umriss als Partikel, Punkte als reine Funktion + +`PortalOutline.points(PortalShape)` liefert die Punkte: für einen Quader die zwölf Kanten von `min` bis `max + 1` (blockinklusive Hüllkörper) mit 0,5 Blöcken Abstand; für einen Ring `max(16, ceil(2·π·r / 0,5))` Punkte auf dem Kreis, aufgespannt aus zwei Vektoren senkrecht zur Normalen. `show` startet je Spieler einen wiederholenden Scheduler-Task (`player.scheduler()`, alle 5 Ticks, 8 Sekunden = 32 Durchläufe), der die Punkte per `ParticlePacket` nur an diesen Spieler sendet; ein zweites `show` bricht den ersten Task ab. Ein Spieler-Trennen stoppt den Task, weil der Spieler-Scheduler mit dem Spieler endet. + +- Built-in first: Minestoms `ParticlePacket` und der Spieler-Scheduler; kein Partikel-Helfer im Repository (geprüft), keine neue Bibliothek. Verworfen: Glas- oder Block-Platzhalter (verändern die Welt, brauchen Aufräumen). +- Test: Unit für `PortalOutline` (Punktzahl, jeder Punkt auf der Kante bzw. im Abstand `radius` vom Mittelpunkt und senkrecht zur Normalen, Achsen-Normalen und Diagonale); der Senderteil ist ein Einzeiler und wird nur im Integrationstest (Cyano, `env.tick()`) geprüft: nach 32 Ticks kein Task mehr, ein zweites `show` ersetzt den ersten. +- SOLID: Single Responsibility; Geometrie getrennt vom Versand. + +### D8: Nachrichten englisch im Stil von `MapCommand`; Logging + +Rückmeldungen sind englische MiniMessage-Strings mit `` wie in `MapCommand` (grün für Erfolg, rot für Fehler, Platzhalter über `Placeholder.parsed`/`component`), gesammelt in einer Klasse `PortalMessages`. Das Repository hat weder `TranslationStore` noch Bundles, der Setup-Server ist ein internes Team-Werkzeug ohne Übersetzung; der Change führt keine i18n-Infrastruktur ein (eigener Change, wenn gewünscht). Die Rückmeldung nennt bei Ringen den gespeicherten Mittelpunkt und die Normale. Eingaben der Spieler (Aufgabe, Recht) gehen über `Placeholder.unparsed`, damit MiniMessage-Tags im Text nicht ausgewertet werden. + +Logging: SLF4J, eine Zeile INFO je gespeichertem oder entferntem Portal (`Saved portal {} in world {}`); keine Spielernamen oder Chattexte. Keine Metriken, keine Spans. + +- Test: Unit für `PortalMessages` (Platzhalter, Escapen von `<` in der Aufgabe); INFO-Zeile über einen aufgefangenen Appender. +- SOLID: Single Responsibility. + +### D9: Geführter Ablauf als dünne Schicht + +`PortalFlow.render(FlowState, List)` ist eine reine Funktion: aus Entwurfszustand (gewählte Form, gesetzte Ecken, Mittelpunkt, Radius, Aufgabe, Recht) und den Portalen der Welt liefert sie den nächsten Schritt als Komponente samt der Befehlstexte ihrer Knöpfe (`FlowStep(Component, List)`, `kind` = `RUN` oder `SUGGEST`). Der Befehlshandler sendet den Schritt nach jedem Unterbefehl, der einen geführten Entwurf ändert, und bei `create`; der Ablauf endet in der Zusammenfassung mit [save] und [cancel], `save` ist derselbe Befehl wie von Hand (D3). Knöpfe tragen nur bestehende Unterbefehle (`ClickEvent.runCommand`) oder schlagen im Eingabefeld vor (`ClickEvent.suggestCommand`, für Aufgabe, Recht, Radius); es gibt keinen zweiten Änderungspfad. Der Zustand ist der vorhandene Entwurf (D3, Flag `guided`), kein eigener Zustandsautomat: der nächste Schritt folgt aus dem, was im Entwurf fehlt. `cancel` verwirft, das Trennen verwirft (D3). Aufgaben-Knöpfe kommen aus den `task`-Werten der Portale der Welt (`distinct`, Reihenfolge der Liste, höchstens 8); die Navigator-Spalte wird nicht gelesen, `setup` hängt nicht von `features/*` ab. Radius-Knöpfe sind feste Vorschläge (2, 3, 5, 8) plus Vorschlag für freie Eingabe; eine Abstands-Heuristik ist nicht nötig (KISS). + +- Klick-Komponenten baue ich im Code mit der Adventure-API (`Component.text(...).clickEvent(...)`) statt mit ``-Tags, sobald Spielertext im Befehl vorkommt (Aufgaben): Anführungszeichen oder `<` in einer Aufgabe würden einen MiniMessage-Tag brechen oder Befehle einschleusen. Statische Texte dürfen MiniMessage mit `` bleiben (D8). Verworfen: eigener Zustandsautomat im Ablauf (zweite Wahrheit neben dem Entwurf); Chat-Eingabe abfangen (Dialog-Modus, fehleranfällig). +- Test (Unit, ohne Server): `PortalFlowTest` prüft je Zustand den Schritt (Knopfbeschriftungen, Befehlstexte, `RUN`/`SUGGEST`), Aufgaben aus Portalen ohne Doppelte, Aufgabe mit `<`/`'` bleibt wörtlich im Befehl. +- SOLID: Single Responsibility (Darstellung getrennt von Logik), Open/Closed (neuer Schritt = neuer Fall). + +### D10: Live-Vorschau: reine Punkte, ein Task je Spieler + +`DraftOutline.points(PortalDraft, Pos eye, Vec look, Point block)` ist eine reine Funktion und liefert die Vorschau-Punkte: Ecken als Blockmitten, Quader (bei einer Ecke von dort bis zu `block`, dem aktuellen Block des Spielers), Ring (Kreis wie `PortalOutline` in D7; ohne Radius Standardradius 3 um `eye` in der Ebene senkrecht zu `look`). Die Kantenerzeugung teilt sich der Code mit `PortalOutline` (DRY). Obergrenze: `MAX_POINTS = 256` je Vorschau (Konstante, im Test gepinnt); bei Überschreitung wird der Abstand vergrößert statt Punkte abzuschneiden, damit die Form vollständig bleibt. + +`DraftPreview` (Paket `portal`) hält je Spieler-UUID höchstens einen Scheduler-Task (`player.scheduler().buildTask(...).repeat(TaskSchedule.tick(5))`). Er wird gestartet, sobald ein Spieler einen offenen Entwurf hat, und läuft, bis der Entwurf endet: `start(player)` ersetzt einen laufenden Task, `stop(uuid)` bricht ihn ab; `stop` rufen der Befehl bei den Ergebnissen `Saved`, `Removed`, `Cancelled` und der `PlayerDisconnectEvent`-Listener (D3) auf. Jeder Durchlauf liest den Entwurf frisch, berechnet die Punkte mit der aktuellen Position und sendet `ParticlePacket`s nur an diesen Spieler; ist der Entwurf weg, beendet er sich selbst. Der Hinweis „Standardradius“ geht einmal beim Wechsel in diesen Zustand in den Chat, nicht bei jedem Durchlauf. Kein Leck: der Spieler-Scheduler endet mit dem Spieler, zusätzlich stoppt der Listener ausdrücklich, und die Map der Tasks wird bei `stop` bereinigt; keine statischen Felder, frische `DraftPreview`-Instanz je Test. `show` (D7) bleibt für gespeicherte Portale unverändert. + +- Warum 5 Ticks: 4 Aktualisierungen pro Sekunde wirken flüssig, Partikel leben etwa eine Sekunde, und mit `MAX_POINTS = 256` sind es höchstens rund 1000 Partikel pro Sekunde je Spieler; die Konstanten stehen an einer Stelle und sind änderbar. +- Test: Unit für `DraftOutline` (Ecken, Quader mit einer und zwei Ecken, Ring mit und ohne Radius, Achsen und Diagonale, Obergrenze bei riesigem Quader und Radius). Lebenszyklus mit Cyano-`Env` und `env.tick()`: Pakete nur an den Entwurfsspieler, Quader folgt nach Bewegen, Task endet nach `save`, `cancel` und Trennen, zweites `start` ersetzt den Task; keine Uhr, kein Warten. +- SOLID: Geometrie, Planung und Versand getrennt. + +### D11: Tab-Vervollständigung aus einer reinen Quelle + +`PortalCompletions` (rein) liefert die Vorschläge aus den gespeicherten Portalen, den Entwurfs-Ids des Spielers und festen Listen (Verben, Radius-Hinweise `1`, `2`, `3`, `5`, `8`, `none`, `box`, `ring`): `ids(portals, drafts)`, `verbs()`, `tasks(portals)` (distinct), `radii()`. `PortalCommand` hängt sie über `setSuggestionCallback` der Minestom-Argumente an; das ist Minestoms eingebaute Vervollständigung, kein eigenes Parsing. Die Quelle für Aufgaben sind wie in D9 die vorhandenen Portale. + +- Test (Unit): `PortalCompletionsTest` (Ids aus Welt und Entwurf ohne Doppelte, Verben, Aufgaben distinct, Radien); ein Integrationstest mit Cyano prüft, dass die Callbacks registriert sind, soweit Cyano das erlaubt. +- SOLID: Single Responsibility. + +## Risks / Trade-offs + +- [`lobby-portals` liefert andere Namen, Feldtypen oder ein anderes JSON-Format] → Aufgabe 1.1 gleicht ab und passt dieses Dokument an, bevor Code entsteht. +- [`lobby-portals` kopiert Portale nicht in `lobbyMapBuilder(map)`, `setspawn` löscht sie] → Persistenztest deckt das ab; Korrektur gehört nach `lobby-portals` (D6). +- [`PortalValidator` fehlt eine Regel, die die Spec nennt] → in `lobby-portals` ergänzen statt kopieren (D5). +- [Augenhöhe schwankt mit der Haltung] → Rückmeldung zeigt den Mittelpunkt; `disc` lässt sich wiederholen (ersetzt die Form). +- [Ein Builder setzt Ecken, verlässt den Server, Entwurf weg] → bewusst: nichts halb Gespeichertes; der Chat sagt bei jedem Schritt, was fehlt. +- [Kein Recht auf `/setup portal`] → wie alle `/setup`-Befehle; wer den Setup-Server betreten darf, darf bauen. Ein Recht wäre ein eigener Change für alle `/setup`-Befehle. +- [Viele Partikel bei großen Quadern] → 0,5 Blöcke Abstand, nur der ausführende Spieler, `show` 8 Sekunden; `PortalOutline` und `DraftOutline` begrenzen die Punktzahl je Portal bzw. Vorschau (`MAX_POINTS = 256`, im Test gepinnt). +- [Klickbare Knöpfe hängen an Adventure-`ClickEvent` bzw. MiniMessage-Klick-Tags und deren Verhalten in Minestom 26.1/Client] → Komponenten mit Spielertext per API statt Tags (D9); reine Tests prüfen Befehlstexte, nicht das Klickverhalten; ein manueller Abnahmelauf klickt jeden Knopf im Client (Aufgabe 4.2). Läuft `run_command` im Client anders (z. B. Bestätigung), bleibt der Befehl tippbar, weil jeder Knopf nur einen normalen Befehl trägt. +- [Vorschau-Task leckt oder läuft nach Speichern weiter] → höchstens ein Task je UUID, ausdrückliches `stop` bei Speichern, Abbrechen, Entfernen, Trennen; Lebenszyklustest mit `env.tick()` (D10). +- [Mehr neue Verben (`shape`, `centre`, `radius`, `save`, `cancel`) vergrößern die Befehlsoberfläche] → nötig, damit Knöpfe nur bestehende Befehle tragen; sie sind auch von Hand nutzbar und werden vervollständigt. diff --git a/openspec/changes/setup-portal-command/proposal.md b/openspec/changes/setup-portal-command/proposal.md new file mode 100644 index 00000000..c34d0282 --- /dev/null +++ b/openspec/changes/setup-portal-command/proposal.md @@ -0,0 +1,47 @@ +# Proposal + +## Why + +Der Change `lobby-portals` lässt die Lobby Portale aus der `map.json` der Welt lesen (Quader und Ringe, je mit Aufgabe und optionalem Recht). Das Build-Team müsste diese Einträge von Hand in JSON schreiben: Blockkoordinaten abtippen, für Ringe Mittelpunkt und Normale ausrechnen. Fehler fallen erst beim Lobby-Start auf, weil ein ungültiges Portal ihn abbricht. Der Setup-Server, in dem die Welt ohnehin gebaut und gespeichert wird, soll Portale deshalb aus dem Spiel heraus anlegen, prüfen und speichern. + +## What Changes + +- **Voraussetzung:** Dieser Change baut auf `lobby-portals` auf und darf erst umgesetzt werden, wenn dessen Code in `main` ist. Er verwendet dessen Typen unter den Arbeitsnamen `net.onelitefeather.titan.core.portal.PortalShape` (sealed, `Box(min, max)` und `Disc(center, radius, normal)`) und `Portal(String id, PortalShape shape, String task, @Nullable String permission)` sowie die Liste `portals` an `LobbyMap` samt Builder-Methode. Die Validierung liefert `lobby-portals` als `net.onelitefeather.titan.core.portal.PortalValidator` (`static List problems(List)`, `PortalProblem(String portalId, String reason)`, `requireValid(String world, List)`); die Records `Box`/`Disc`/`Portal` sind reine Daten ohne Konstruktorprüfung. Die Namen MÜSSEN mit dem übereinstimmen, was `lobby-portals` am Ende ausliefert; Aufgabe 1.1 prüft das und passt design.md und tasks.md sonst an. +- Neuer Unterbefehl `/setup portal ...` im Setup-Server (neben `/setup map ...`): + - `/setup portal pos1` und `pos2`: Blockposition des Spielers als Quader-Ecke; der Quader ist die blockinklusive Spanne. + - `/setup portal disc `: Ring um die Augenposition des Spielers, Normale ist die Blickrichtung, nahe an einer Achse wird sie auf die Achse eingerastet. + - `/setup portal task `, `/setup portal permission `, `/setup portal remove`. + - `/setup portal list`: alle gespeicherten Portale der Welt, darunter getrennt die offenen Entwürfe des Spielers. `/setup portal show`: Umrisse gespeicherter Portale als Partikel für wenige Sekunden, nur für den ausführenden Spieler. +- **Geführter Ablauf:** `/setup portal create ` startet eine Schritt-für-Schritt-Führung im Chat mit anklickbaren Knöpfen (Form Box oder Ring, Ecken bzw. Mittelpunkt und Radius, Aufgabe, optionales Recht, Zusammenfassung mit Speichern und Abbrechen). Jeder Knopf führt einen der Unterbefehle aus (`pos1`, `pos2`, `disc`, `task`, `permission` sowie die neuen kleinen Verben `shape`, `centre`, `radius`, `save`, `cancel`); der Ablauf ist eine dünne Schicht über den Befehlen, kein zweiter Codepfad. Der geführte Ablauf und die einzelnen Befehle bearbeiten denselben Entwurf. +- **Live-Vorschau:** Solange ein Spieler einen offenen Entwurf hat, zeigen Partikel nur ihm den Entwurf (gesetzte Ecken, Quader bis zum aktuellen Block des Spielers, Ring mit gewähltem Radius, vorher mit Blickrichtung und Standardradius, mit Hinweis), alle 5 Ticks aktualisiert, mit Obergrenze der Punktzahl; sie endet bei Speichern, Abbrechen, Entfernen und Trennen. +- **Tab-Vervollständigung:** Vorschläge für Portal-Ids (gespeichert und eigene Entwürfe), Verben, bereits verwendete Aufgaben und Radius-Hinweise. +- **Speichern nur per `save`:** Jeder Bearbeitungsbefehl (`pos1`, `pos2`, `shape`, `centre`, `radius`, `disc`, `task`, `permission`) ändert nur den Entwurf und antwortet mit dessen Stand (was noch fehlt, oder „vollständig" mit anklickbarem [save]). Ein Portal wird ausschließlich durch `/setup portal save` gespeichert, im geführten Ablauf wie bei Befehlen; `cancel` verwirft den Entwurf. `save` prüft Vollständigkeit und `PortalValidator.problems(...)` aus `lobby-portals`; die Prüfung ist `PortalValidator.problems(...)` aus `lobby-portals`, die Probleme werden auf Chat-Meldungen abgebildet; dazu Regeln nur des Befehls (Id-Muster, gesperrte Ids `list`/`show`/`create`, Radius als Zahl, leere Aufgabe). Bei Problemen bleibt der Entwurf offen und der Chat nennt sie; ein ungültiges Portal wird nie gespeichert. +- Gespeichert wird über den bestehenden Weg (`MapProvider.saveMap` mit `LobbyMap`-Builder) in die `map.json` der aktuellen Welt; Spawn, Name und Autoren bleiben erhalten. +- Kein Rückgängig-Stapel, kein Wand-Item (der Setup-Server nutzt keins; ein Wand-Item ist ein mögliches Folge-Change). + +## Capabilities + +### New Capabilities + +- `setup-portals`: Das Build-Team legt im Setup-Server Portale per Befehl an, ändert, listet, entfernt und zeigt sie an; nur gültige Portale werden in die Map-Datei der Welt geschrieben. + +### Modified Capabilities + +Keine. Es gibt keine Setup-Spec; die Lobby-Seite (Laden, Auslösen) liegt in `lobby-portals`. + +## Impact + +- **Code**: `setup` (neuer Befehl `PortalCommand` unter `SetupCommand`, reine Bearbeitungslogik, Partikel-Umriss, Anbindung an `MapProvider`). Neu sind außerdem reine Klassen für Führung (`PortalFlow`), Vorschau-Geometrie (`DraftOutline`) und Vervollständigung (`PortalCompletions`) sowie ein Vorschau-Dienst mit Scheduler-Task je Spieler. Kein Code in `common` oder `core`, sofern `lobby-portals` `PortalValidator` und Builder-Methode wie beschrieben liefert; `setup` hängt nicht von `features/*` ab (Aufgabennamen kommen aus den vorhandenen Portalen, nicht aus der Navigator-Spalte). +- **Abhängigkeiten**: keine neuen Bibliotheken; `setup/build.gradle.kts` bekommt für den Persistenztest `testImplementation(libs.cyano)` (wie `common`). +- **Tests**: Unit-Tests für Bearbeitung, Abbildung der `PortalValidator`-Probleme, Ringbildung, Umriss, Führungsschritte (Komponenten und Befehlstexte als reine Funktion), Vorschau-Geometrie samt Obergrenze und Vervollständigung ohne Server; ein Persistenztest mit `@TempDir`; Cyano-`Env` mit `env.tick()` für den Vorschau-Lebenszyklus. +- **Nutzertexte**: neue Chat-Rückmeldungen des Setup-Servers, englisch im Stil von `MapCommand` (das Repository hat keine Übersetzungsinfrastruktur, siehe design.md D8). +- **Doku**: Abschnitt „Setup server“ in `README.md` nennt die Befehle. +- **Betrieb**: keine Konfiguration. + +## Delivery + +PR-Titel: `feat(setup): create lobby portals from in-game commands` + +Umfang: größer als zuvor (Führung, Vorschau, Vervollständigung kommen zu den Befehlen hinzu), aber ein Typ (`feat`) und ein Modul (`setup`), daher ein PR. Übersteigt die Umsetzung grob 900 geänderte Zeilen Produktivcode, wird an der Wellengrenze geteilt: erst Befehle, Speichern, `show`; dann Führung, Vorschau, Vervollständigung (beide `feat(setup): …`). + +Nicht-Ziele, jeweils eigener Change: Rückgängig-Stapel, Wand-Item (mögliches Folge-Change), Ändern von Portalen anderer Welten als der aktiven, Vorschau in der Lobby, Bearbeiten der Form per Griffe. diff --git a/openspec/changes/setup-portal-command/specs/setup-portals/spec.md b/openspec/changes/setup-portal-command/specs/setup-portals/spec.md new file mode 100644 index 00000000..9880832c --- /dev/null +++ b/openspec/changes/setup-portal-command/specs/setup-portals/spec.md @@ -0,0 +1,255 @@ +# Spec Delta + +## Purpose + +Legt fest, wie das Build-Team im Setup-Server Lobby-Portale mit Befehlen und einem geführten Ablauf anlegt, ändert, auflistet, entfernt und anzeigt (mit Live-Vorschau und Tab-Vervollständigung) und wie nur gültige Portale in die Map-Datei der aktuellen Welt gelangen. + +## ADDED Requirements + +### Requirement: Portale werden mit /setup portal verwaltet +Der Setup-Server MUSS den Befehl `/setup portal` für Spieler anbieten. Ein Portal MUSS über eine Id angesprochen werden, die aus Kleinbuchstaben, Ziffern, `-` und `_` besteht; `list`, `show` und `create` MÜSSEN als Id abgelehnt werden. Jede Änderung wirkt auf die Map-Datei der Welt, die der Setup-Server geladen hat. Alle Bearbeitungsbefehle (`pos1`, `pos2`, `shape`, `centre`, `radius`, `disc`, `task`, `permission`) MÜSSEN nur den Entwurf des Spielers ändern und mit dem Entwurfsstand antworten: was noch fehlt, oder bei einem vollständigen Entwurf (Form und Aufgabe) den Hinweis „vollständig" mit einem anklickbaren Knopf [save]. Ein Portal MUSS ausschließlich durch `/setup portal save` gespeichert werden, für den geführten Ablauf und für Befehle gleichermaßen. + +#### Scenario: Unvollständiger Entwurf meldet, was fehlt +- **WHEN** ein Spieler `/setup portal survival pos1` ausführt und für „survival“ noch keine zweite Ecke und keine Aufgabe gesetzt ist +- **THEN** wird nichts gespeichert und der Chat nennt, was noch fehlt (zweite Ecke, Aufgabe) + +#### Scenario: Vollständiger Entwurf wird nicht ohne save gespeichert +- **WHEN** für „survival“ zwei Ecken gesetzt sind und der Spieler `/setup portal survival task Survival` ausführt +- **THEN** ist der Entwurf vollständig, der Chat zeigt „vollständig" mit [save], und die `map.json` bleibt unverändert + +#### Scenario: save speichert den vollständigen Entwurf +- **WHEN** der Entwurf „survival" vollständig und gültig ist und der Spieler `/setup portal survival save` ausführt +- **THEN** steht das Portal in der `map.json` der Welt und der Chat bestätigt das Speichern + +#### Scenario: Änderung an gespeichertem Portal bleibt Entwurf +- **WHEN** das gespeicherte Portal „survival“ vorliegt und der Spieler `/setup portal survival permission titan.portal.survival` ausführt +- **THEN** steht das neue Recht erst nach `save` in der `map.json` + +#### Scenario: Reservierte oder ungültige Id +- **WHEN** ein Spieler `/setup portal list pos1`, `/setup portal create pos1` oder `/setup portal Survival! pos1` ausführt +- **THEN** lehnt der Chat die Id ab und nichts wird gespeichert + +#### Scenario: Konsole +- **WHEN** die Konsole `/setup portal list` ausführt +- **THEN** wird der Befehl wie alle `/setup`-Befehle abgelehnt + +### Requirement: Nur save speichert, cancel verwirft +`/setup portal save` MUSS den Entwurf des Spielers auf Vollständigkeit, die Regeln des Befehls und `PortalValidator.problems(...)` für diese Id prüfen und dann in die Map-Datei schreiben. Ist der Entwurf unvollständig oder ungültig, MUSS der Chat die Gründe nennen, der Entwurf offen bleiben und die Map-Datei unverändert. `/setup portal cancel` MUSS den Entwurf verwerfen, ohne die Map-Datei zu ändern. + +#### Scenario: save bei unvollständigem Entwurf +- **WHEN** dem Entwurf die Aufgabe fehlt und der Spieler `save` ausführt +- **THEN** nennt der Chat die fehlende Aufgabe, der Entwurf bleibt offen und nichts wird gespeichert + +#### Scenario: save mit Problemen +- **WHEN** der Entwurf vollständig ist, `PortalValidator.problems(...)` für die Id aber ein Problem liefert +- **THEN** nennt der Chat den Grund, der Entwurf bleibt offen und die Map-Datei bleibt unverändert + +#### Scenario: cancel verwirft +- **WHEN** ein Spieler `cancel` für einen offenen Entwurf ausführt +- **THEN** ist der Entwurf weg und die Map-Datei unverändert + +### Requirement: Ein Quader entsteht aus zwei Ecken +`/setup portal pos1` und `pos2` MÜSSEN die Blockposition des Spielers (ganzzahlig, abgerundet) als erste bzw. zweite Ecke merken. Der Quader MUSS die achsenparallele, blockinklusive Spanne der beiden Ecken sein, unabhängig davon, welche Ecke größer ist. Die Ecken gehören dem ausführenden Spieler; Ecken eines anderen Spielers zählen nicht. Setzt ein Spieler `pos1` oder `pos2` für ein Portal, das ein Ring ist, MUSS die Form beim Vervollständigen zum Quader werden. + +#### Scenario: Zwei Ecken bilden einen Quader +- **WHEN** ein Spieler auf Block (14, 68, 11) `pos1` und auf Block (10, 64, 10) `pos2` für „survival“ ausführt, eine Aufgabe gesetzt ist und er `save` ausführt +- **THEN** speichert das Portal einen Quader von (10, 64, 10) bis (14, 68, 11), beide Blöcke eingeschlossen + +#### Scenario: Ecken zweier Spieler +- **WHEN** Spieler A `pos1` und Spieler B `pos2` für dieselbe Id ausführt +- **THEN** ist der Quader unvollständig und nichts wird gespeichert + +#### Scenario: Neue Ecke ersetzt die alte +- **WHEN** ein Spieler `pos1` für „survival“ zweimal an verschiedenen Blöcken ausführt +- **THEN** zählt die zuletzt gesetzte Ecke + +### Requirement: Ein Ring entsteht aus Standort, Blickrichtung und Radius +`/setup portal disc ` MUSS einen Ring anlegen, dessen Mittelpunkt die Augenposition des Spielers ist, auf ein halbes Feld gerundet (jede Koordinate auf das nächste Vielfache von 0,5), und dessen Normale die normierte Blickrichtung ist. Liegt die Blickrichtung höchstens 5 Grad neben einer der sechs Achsenrichtungen, MUSS die Normale auf diese Achse einrasten. Der Radius MUSS eine Zahl größer null sein; mit `disc` ersetzt der neue Ring eine vorhandene Form des Portals. `/setup portal centre` MUSS Mittelpunkt und Normale wie `disc` festlegen, ohne den Radius zu ändern, und `/setup portal radius ` MUSS nur den Radius des Ring-Entwurfs setzen (Mittelpunkt und Normale bleiben); ein Ring ohne Radius ist unvollständig. + +#### Scenario: Blick fast entlang einer Achse +- **WHEN** ein Spieler am Augenpunkt (0,52 | 72,0 | 40,47) mit einer Blickrichtung 2 Grad neben +Z `/setup portal elytra-ring disc 5.5` ausführt +- **THEN** hat der Ring den Mittelpunkt (0,5 | 72,0 | 40,5), den Radius 5,5 und die Normale (0, 0, 1) + +#### Scenario: Schräge Blickrichtung +- **WHEN** die Blickrichtung mehr als 5 Grad von jeder Achse abweicht +- **THEN** ist die Normale die normierte Blickrichtung ohne Einrasten + +#### Scenario: Ungültiger Radius +- **WHEN** ein Spieler `/setup portal elytra-ring disc 0`, `disc -2` oder `disc abc` ausführt +- **THEN** nennt der Chat den Fehler und das Portal ändert sich nicht + +### Requirement: Aufgabe und Recht werden je Portal gesetzt +`/setup portal task ` MUSS die Aufgabe (Ziel) des Portals setzen; eine leere Aufgabe MUSS abgelehnt werden. `/setup portal permission ` MUSS das Recht setzen, `permission none` MUSS es entfernen (das Portal ist dann für alle offen). + +#### Scenario: Aufgabe setzen +- **WHEN** ein Spieler `/setup portal survival task Survival` ausführt +- **THEN** ist „Survival“ die Aufgabe von „survival“ + +#### Scenario: Recht entfernen +- **WHEN** „survival“ ein Recht hat und der Spieler `/setup portal survival permission none` ausführt +- **THEN** hat das Portal kein Recht mehr + +#### Scenario: Recht auf unbekanntem Portal +- **WHEN** ein Spieler `permission` für eine Id ausführt, die es weder gespeichert noch als eigenen Entwurf gibt +- **THEN** wird für diese Id ein Entwurf angelegt, der ohne Form und Aufgabe nicht gespeichert wird + +### Requirement: Portale werden aufgelistet und entfernt +`/setup portal list` MUSS alle gespeicherten Portale der Welt mit Id, Form, Aufgabe und Recht anzeigen und darunter in einem eigenen Abschnitt „Entwürfe" die offenen Entwürfe des Spielers mit Id und Stand (was fehlt, oder „vollständig, nicht gespeichert"). `/setup portal remove` MUSS das gespeicherte Portal aus der Map-Datei löschen und den Entwurf des Spielers verwerfen. + +#### Scenario: Liste +- **WHEN** die Welt zwei Portale hat und ein Spieler `/setup portal list` ausführt +- **THEN** zeigt der Chat beide mit Id, Form, Aufgabe und Recht + +#### Scenario: Entwürfe getrennt +- **WHEN** die Welt ein Portal hat und der Spieler einen offenen Entwurf „arena" mit fehlender Aufgabe +- **THEN** zeigt `list` das Portal und getrennt davon den Entwurf „arena" mit „Aufgabe fehlt" + +#### Scenario: Leere Liste +- **WHEN** die Welt kein Portal und der Spieler keinen Entwurf hat +- **THEN** meldet der Chat, dass es keine gibt + +#### Scenario: Entfernen +- **WHEN** ein Spieler `/setup portal survival remove` ausführt +- **THEN** ist „survival“ nicht mehr in der `map.json` und aus der Liste verschwunden + +#### Scenario: Unbekannte Id +- **WHEN** ein Spieler `remove` für eine unbekannte Id ausführt +- **THEN** nennt der Chat, dass es die Id nicht gibt, und nichts ändert sich + +#### Scenario: Radius ändert nur den Radius +- **WHEN** ein Ring-Entwurf einen Mittelpunkt hat, der Spieler sich bewegt und `/setup portal elytra-ring radius 8` ausführt +- **THEN** ist der Radius 8, Mittelpunkt und Normale sind unverändert + +### Requirement: Umrisse werden für den Spieler angezeigt +`/setup portal show` MUSS die Umrisse aller gespeicherten Portale der Welt als Partikel nur dem ausführenden Spieler zeigen: bei einem Quader die zwölf Kanten der blockinklusiven Spanne, bei einem Ring den Kreis um den Mittelpunkt in der Ebene zur Normalen. `show` gilt für gespeicherte Portale; offene Entwürfe zeigt die Live-Vorschau. Die Anzeige MUSS nach wenigen Sekunden von selbst enden; ein erneutes `show` MUSS die laufende Anzeige desselben Spielers ersetzen. + +#### Scenario: Quader +- **WHEN** ein Spieler `show` ausführt und die Welt einen Quader hat +- **THEN** sieht nur dieser Spieler Partikel entlang der zwölf Kanten, die den ganzen Block der Ecken einschließen + +#### Scenario: Ring +- **WHEN** die Welt einen Ring hat +- **THEN** sieht der Spieler Partikel auf einem Kreis mit dem Radius des Rings in der Ebene senkrecht zur Normalen + +#### Scenario: Ende der Anzeige +- **WHEN** die Anzeigedauer abgelaufen ist +- **THEN** erscheinen keine Partikel mehr + +#### Scenario: Keine Portale +- **WHEN** die Welt kein Portal hat +- **THEN** meldet der Chat, dass es nichts anzuzeigen gibt + +### Requirement: Ein geführter Ablauf leitet mit Knöpfen durch das Anlegen +`/setup portal create ` MUSS für den Spieler einen geführten Ablauf im Chat starten, der auf dem Entwurf des Spielers für diese Id beruht (bei einer vorhandenen Id vorbefüllt). Jeder Schritt MUSS anklickbare Knöpfe zeigen, die genau einen der Unterbefehle `/setup portal shape box|ring`, `pos1`, `pos2`, `centre`, `radius `, `task `, `permission `, `save` oder `cancel` ausführen; der Ablauf darf keine Änderung vornehmen, die nicht über diese Befehle geht. Knöpfe mit freier Eingabe MÜSSEN den Befehl im Chat-Eingabefeld vorschlagen. Die Schritte sind: Form wählen (Box, Ring); Box: erste Ecke, zweite Ecke; Ring: Mittelpunkt (Spieler steht in der Mitte und schaut durch den Ring), Radius (Knöpfe mit Vorschlägen und freie Eingabe); Aufgabe (Knöpfe mit den Aufgaben, die vorhandene Portale der Welt verwenden, und freie Eingabe); Recht (Knopf „keins“ und freie Eingabe); Zusammenfassung mit `save` und `cancel`. Nach jedem ausgeführten Schritt MUSS der nächste Schritt erscheinen. Gespeichert wird wie bei Befehlen nur durch `save`; `cancel` und das Trennen des Spielers MÜSSEN den Entwurf verwerfen. + +#### Scenario: Ablauf startet +- **WHEN** ein Spieler `/setup portal create survival` ausführt +- **THEN** zeigt der Chat die Formwahl mit den Knöpfen [Box] und [Ring], die `shape box` bzw. `shape ring` für „survival“ ausführen + +#### Scenario: Box-Schritte +- **WHEN** der Spieler [Box] geklickt hat +- **THEN** fordert der Chat auf, an der ersten Ecke zu stehen, mit dem Knopf [set corner 1] für `pos1`; nach `pos1` erscheint [set corner 2] für `pos2` + +#### Scenario: Ring-Schritte +- **WHEN** der Spieler [Ring] geklickt hat +- **THEN** fordert der Chat auf, in der Mitte zu stehen und durch den Ring zu schauen, mit dem Knopf [set centre] für `centre`; danach erscheinen Radius-Knöpfe, die `radius ` ausführen, und ein Vorschlag für freie Eingabe + +#### Scenario: Aufgabe und Recht +- **WHEN** die Form vollständig ist und vorhandene Portale der Welt die Aufgaben „Survival“ und „Creative“ verwenden +- **THEN** bietet der Chat [Survival] und [Creative] (führen `task Survival` bzw. `task Creative` aus) und einen Knopf, der `task ` im Eingabefeld vorschlägt; danach [none] für `permission none` und einen Knopf für `permission ` + +#### Scenario: Zusammenfassung und Speichern +- **WHEN** Form, Aufgabe und (optional) Recht gesetzt sind +- **THEN** zeigt der Chat eine Zusammenfassung mit [save] und [cancel]; erst [save] schreibt das Portal in die `map.json`, davor nicht + +#### Scenario: Abbrechen +- **WHEN** der Spieler [cancel] klickt +- **THEN** ist der Entwurf verworfen, nichts wird gespeichert und die Vorschau endet + +#### Scenario: Trennen im Ablauf +- **WHEN** der Spieler mitten im Ablauf den Server verlässt +- **THEN** ist sein Entwurf verworfen + +#### Scenario: Benutzertext in Knöpfen +- **WHEN** eine vorhandene Aufgabe Zeichen wie `<` oder `'` enthält +- **THEN** führt ihr Knopf `task` mit genau diesem Text aus, ohne dass MiniMessage ihn auswertet + +### Requirement: Eine Live-Vorschau zeigt den offenen Entwurf +Solange ein Spieler einen offenen Entwurf hat, MUSS der Setup-Server ihm den Entwurf als Partikel zeigen, nur diesem Spieler, in einem festen Intervall von 5 Ticks: gesetzte Ecken; bei einem Quader mit einer Ecke den Quader von dieser Ecke bis zum aktuellen Block des Spielers (folgt dem Spieler), bei zwei Ecken den Quader; bei einem Ring den Kreis um den Mittelpunkt mit dem gewählten Radius in der Ebene senkrecht zur Normalen. Solange der Radius fehlt, MUSS die Vorschau den Ring mit Blickrichtung, Standardradius 3 und dem Hinweis im Chat zeigen, dass es der Standardradius ist. Die Punktzahl je Vorschau MUSS begrenzt sein. Die Vorschau MUSS enden, wenn der Entwurf gespeichert, abgebrochen oder entfernt wird oder der Spieler den Server verlässt; je Spieler läuft höchstens eine. + +#### Scenario: Nur der Entwurfsspieler sieht sie +- **WHEN** Spieler A einen offenen Entwurf hat und Spieler B in der Nähe steht +- **THEN** erhält nur A Partikelpakete + +#### Scenario: Quader folgt dem Spieler +- **WHEN** ein Quader-Entwurf nur `pos1` hat und der Spieler sich bewegt +- **THEN** zeigt die nächste Aktualisierung den Quader von `pos1` bis zum neuen Block des Spielers + +#### Scenario: Ring mit Standardradius +- **WHEN** ein Ring-Entwurf noch keinen Radius hat +- **THEN** zeigt die Vorschau einen Kreis mit Radius 3 in der Ebene senkrecht zur Blickrichtung, und der Chat weist auf den Standardradius hin + +#### Scenario: Ring mit gewähltem Radius +- **WHEN** der Mittelpunkt und der Radius 5,5 gesetzt sind +- **THEN** zeigt die Vorschau den Kreis mit Radius 5,5 um den Mittelpunkt, in der Ebene senkrecht zur gespeicherten Normalen, unabhängig von der Blickrichtung + +#### Scenario: Vorschau endet +- **WHEN** der Entwurf gespeichert oder abgebrochen wird oder der Spieler den Server verlässt +- **THEN** läuft kein Vorschau-Task mehr und es gehen keine Partikel mehr an den Spieler + +#### Scenario: Punktzahl begrenzt +- **WHEN** der Entwurf sehr groß ist (großer Quader oder Radius) +- **THEN** überschreitet die Zahl der Punkte je Aktualisierung die Obergrenze nicht + +### Requirement: Tab-Vervollständigung unterstützt die Eingabe +`/setup portal` MUSS Vorschläge liefern: an der Id-Position die Ids der gespeicherten Portale der aktuellen Welt und der offenen Entwürfe des Spielers (sowie die Verben `list`, `show`, `create`); nach der Id die Verben (`pos1`, `pos2`, `disc`, `centre`, `radius`, `shape`, `task`, `permission`, `remove`, `save`, `cancel`); nach `task` die Aufgaben, die vorhandene Portale verwenden; nach `disc` und `radius` Radius-Hinweise; nach `permission` den Wert `none`; nach `shape` `box` und `ring`. + +#### Scenario: Ids +- **WHEN** die Welt die Portale „survival“ und „creative“ hat, der Spieler einen Entwurf „arena“ hat und `/setup portal ` vervollständigt +- **THEN** enthalten die Vorschläge „survival“, „creative“ und „arena“ sowie `list`, `show`, `create` + +#### Scenario: Verben +- **WHEN** der Spieler `/setup portal survival ` vervollständigt +- **THEN** enthalten die Vorschläge `pos1`, `pos2`, `disc`, `task`, `permission`, `remove` + +#### Scenario: Aufgaben +- **WHEN** vorhandene Portale die Aufgaben „Survival“ und „Creative“ verwenden und der Spieler `/setup portal survival task ` vervollständigt +- **THEN** enthalten die Vorschläge beide, ohne Doppelte + +#### Scenario: Radius-Hinweise +- **WHEN** der Spieler `/setup portal survival disc ` oder `radius ` vervollständigt +- **THEN** enthalten die Vorschläge die festen Radius-Hinweise + +### Requirement: Ungültige Portale werden nie gespeichert +Vor jedem Speichern MUSS die Portalliste, wie sie danach in der Map-Datei stünde, `PortalValidator.problems(...)` aus `lobby-portals` bestehen (dieselben Regeln, die die Lobby beim Start prüft); der Editor bildet die Probleme des bearbeiteten Portals auf Chat-Meldungen ab und kopiert keine Regel. Die Regeln sind: Id nicht leer und in der Welt eindeutig, Aufgabe vorhanden, bei einem Ring Radius größer null und Normale ungleich null, bei einem Quader beide Ecken gesetzt. Verletzt eine Änderung eine Regel, MUSS der Chat den Grund nennen und die Map-Datei MUSS unverändert bleiben. Beim Speichern eines vorhandenen Portals gilt seine eigene Id nicht als Duplikat. + +#### Scenario: Fehlende Aufgabe +- **WHEN** ein Quader mit beiden Ecken vorliegt, aber keine Aufgabe, und der Spieler `save` ausführt +- **THEN** wird nichts gespeichert und der Chat nennt die fehlende Aufgabe + +#### Scenario: Leere Aufgabe +- **WHEN** ein Spieler `task` mit einem leeren Wert ausführt +- **THEN** lehnt der Chat das ab und das gespeicherte Portal behält seine Aufgabe + +#### Scenario: Validator meldet ein Problem +- **WHEN** `PortalValidator.problems(...)` für die Id des bearbeiteten Portals beim `save` ein Problem liefert (z. B. Normale null) +- **THEN** nennt der Chat dessen Grund, und die Map-Datei bleibt unverändert + +#### Scenario: Bestehende Id +- **WHEN** ein Spieler einen vollständigen Entwurf mit der Id eines bereits gespeicherten Portals per `save` speichert +- **THEN** ersetzt das Speichern das vorhandene Portal (kein Duplikat in der Liste), und der Chat sagt, dass es aktualisiert wurde + +### Requirement: Speichern erhält die übrigen Map-Daten +Speichern oder Entfernen eines Portals MUSS Spawn, Name, Autoren und alle anderen Portale der Map-Datei unverändert lassen. Umgekehrt MÜSSEN `/setup map setspawn`, `setname` und `setauthor` die gespeicherten Portale erhalten. Nach einem Neustart des Setup-Servers MÜSSEN die Portale aus der Map-Datei wieder geladen sein. + +#### Scenario: Spawn bleibt +- **WHEN** eine Welt einen Spawn hat und ein Spieler ein Portal speichert +- **THEN** hat die Map-Datei danach denselben Spawn und das Portal + +#### Scenario: Portale bleiben nach setspawn +- **WHEN** die Welt Portale hat und ein Spieler `/setup map setspawn` ausführt +- **THEN** stehen die Portale unverändert in der Map-Datei + +#### Scenario: Neustart +- **WHEN** ein Portal gespeichert wurde und der Setup-Server neu startet +- **THEN** listet `/setup portal list` es mit denselben Werten diff --git a/openspec/changes/setup-portal-command/tasks.md b/openspec/changes/setup-portal-command/tasks.md new file mode 100644 index 00000000..c5182b45 --- /dev/null +++ b/openspec/changes/setup-portal-command/tasks.md @@ -0,0 +1,51 @@ +# Tasks + +## Execution Plan + +Integrationszweig: `feat/setup-portals` von `origin/main`, **erst nachdem `lobby-portals` in `main` gemergt ist**. 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 | prerequisite-check | 1.1 | haiku | read-only | alles | +| 2 | editor-core | 2.1–2.4 | sonnet | `setup/src/main/java/**/portal/**`, `setup/src/test/java/**/portal/**` | `setup/src/main/java/**/commands/**`, `common/**`, `core/**`, `buildSrc/**` | +| 3 | command-persistence | 3.1–3.4 | sonnet | `setup/src/main/java/**/commands/**`, `setup/src/main/java/**/Titan.java`, `setup/src/test/**`, `setup/build.gradle.kts` | `common/**`, `core/**`, `features/**`, `apps/**` | +| 3 | outline | 3.5–3.6, 3.8 | sonnet | `setup/src/main/java/**/portal/PortalOutline*.java`, `setup/src/main/java/**/portal/PortalPreview*.java`, `setup/src/test/java/**/portal/**` | `setup/src/main/java/**/commands/**`, `common/**`, `core/**` | +| 4 | flow-completion | 3.7, 3.9 | sonnet | `setup/src/main/java/**/portal/PortalFlow*.java`, `setup/src/main/java/**/portal/PortalCompletions*.java`, `setup/src/main/java/**/commands/**`, `setup/src/test/**` | `common/**`, `core/**`, `features/**` | +| 5 | docs | 4.1 | sonnet | `README.md` | Code | +| 5 | verify | 4.2 | haiku | read-only | alles | +| 6 | pr | 5.1 | sonnet | Git/GitHub | Code | + +Wellen 2 und 3 (`outline`) berühren getrennte Dateien und laufen parallel; Welle 4 (`flow-completion`) baut auf den Verben und dem Entwurf aus Welle 2/3 auf, jeder Agent in eigenem Worktree ab `origin/main`. Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhandenes nutzen (Minestom-Command-Framework wie `MapCommand`, `ParticlePacket`, Spieler-Scheduler, Aves-`GsonFileHandler` über `MapProvider`, `PortalValidator.problems(...)` aus `lobby-portals` statt einer Regelkopie, Adventure-`ClickEvent` mit Komponenten aus dem Code statt ``-Tags bei Spielertext), Java 25 ohne Preview (Records, sealed `switch` für das Ergebnis, kein `instanceof`-Geflecht), Nachrichten englisch im `MapCommand`-Stil in einer Klasse `PortalMessages` (kein i18n-Bundle im Repository, siehe design.md D8), Spielereingaben nur über `Placeholder.unparsed`, SLF4J mit Parametern, keine Spielernamen im Log, keine Metriken/Spans, Test zuerst, schlanke Kommentare nur fürs Warum, Conventional Commits `feat(setup): …`. F.I.R.S.T.: keine Systemzeit, kein `Thread.sleep`, `@TempDir` für Dateien, frische Fixtures und Editor je Test, keine geteilten statischen Zustände, Erfolg nur über Assertions, INFO-Zeilen über einen aufgefangenen Appender. + +## 1. Voraussetzung prüfen (Welle 1) + +- [ ] 1.1 Prüfen, dass `lobby-portals` in `origin/main` ist und dass `Portal`, `PortalShape` (`Box(min, max)`, `Disc(center, radius, normal)`), `LobbyMap.portals`, die Builder-Methode und das JSON-Format mit den Arbeitsnamen aus proposal.md und design.md übereinstimmen; ebenso `net.onelitefeather.titan.core.portal.PortalValidator` (`static List problems(List)`, `PortalProblem(String portalId, String reason)`, `requireValid(String world, List)`; `Box`/`Disc`/`Portal` ohne Konstruktorprüfung) und dass `LobbyMap.lobbyMapBuilder(map)` die Portale mitkopiert. Abweichungen in design.md (Context, D5, D6) und tasks.md nachtragen; fehlt die Wiederverwendung oder das Mitkopieren, in `lobby-portals` nachziehen lassen, nicht hier kopieren. Nachweis: Bericht mit Dateiverweisen; design.md und tasks.md passen zum gelieferten Code. + +## 2. Bearbeitungslogik ohne Server (Welle 2) + +- [ ] 2.1 Test zuerst (Unit, `DiscPlacementTest`): Mittelpunkt auf 0,5 gerundet; Normale eingerastet bei 2 Grad neben ±X/±Y/±Z, nicht bei 6 Grad; Diagonale bleibt normiert; Blick nach oben und unten; rot. Dann `DiscPlacement` umsetzen; grün. +- [ ] 2.2 Test zuerst (Unit, `PortalEditorTest`, In-Memory-`PortalStore`, Spieler-UUIDs als Werte): alle Szenarien aus `specs/setup-portals` für Quader (zwei Ecken in beliebiger Reihenfolge, Ecken zweier Spieler, Ecke ersetzt Ecke, Ring wird Quader), Aufgabe, Recht (`none`), Entwurf ohne Form/Aufgabe (`Pending` mit dem, was fehlt), `remove` (gespeichert, Entwurf, unbekannt), reservierte (`list`, `show`, `create`) und ungültige Ids, leere Aufgabe, Radius 0 und negativ, doppelte Id in der Liste, `centre` und `radius` (Radius ändert nur den Radius, Ring ohne Radius ist `Pending`), `shape`, vollständiger Entwurf wird ohne `save` nicht gespeichert (Antwort `Complete`, Map unverändert), `save` speichert (Ersetzen bei bestehender Id ohne Duplikat), `save` mit Problemen oder unvollständig meldet und lässt den Entwurf offen, Änderung an gespeichertem Portal wirkt erst nach `save`, `cancel`, Probleme von `PortalValidator.problems(...)` anderer Ids blockieren nicht; rot. Dann `PortalDraft`, `PortalStore`, `PortalEditor` und das sealed Ergebnis umsetzen; die Regelprüfung ruft `PortalValidator.problems(...)` auf und bildet die Probleme der bearbeiteten Id auf `Rejected(Grund)` ab (D5); grün. +- [ ] 2.3 Test zuerst (Unit): ein Vergleichstest hält `PortalEditor` und `PortalValidator.requireValid` an derselben ungültigen Portalliste im Gleichschritt (beide lehnen ab, gleiche Regel). Nachweis: Test grün, keine Regel im Setup-Modul kopiert (Review). +- [ ] 2.4 Test zuerst (Unit, `PortalMessagesTest`): Erfolg/Fehler/Fehlt-Meldungen, Abbildung von `PortalProblem` auf Chat (Grund über `unparsed`), Platzhalter, `<`-Tags in der Aufgabe werden nicht ausgewertet (`unparsed`); dazu die INFO-Zeile je Speichern/Entfernen über einen aufgefangenen Appender, ohne Spielernamen; rot. Dann `PortalMessages` und das Logging im Editor umsetzen; grün. + +## 3. Befehl, Speichern, Umriss (Welle 3) + +- [ ] 3.1 `MapProviderPortalStore` (liest `getActiveLobby().portals()`, speichert per `saveMap(LobbyMap.lobbyMapBuilder(active).portals(list).build())`). Test zuerst (Integration, `@TempDir`, Cyano-`Env` für die Instanz; `testImplementation(libs.cyano)` in `setup/build.gradle.kts` ergänzen): `worlds/world/map.json` mit Spawn, Name, Autor und einem Portal; neues Portal speichern; frischer `MapProvider` lädt Spawn, Name, Autor, altes und neues Portal; `remove` lässt die übrigen Daten unberührt; rot, dann grün. +- [ ] 3.2 Test zuerst (Integration, gleiche Fixture): `/setup map setspawn`-Pfad (`LobbyMap.lobbyMapBuilder(active).spawn(...)`) behält die gespeicherten Portale. Ist der Test rot, ist das ein Fehler in `lobby-portals` (D6): dort beheben lassen und hier abhaken, sobald grün. +- [ ] 3.3 `PortalCommand` (Syntaxen aus D1) in `SetupCommand` einhängen, `PlayerDisconnectEvent`-Listener in `Titan` für das Verwerfen der Entwürfe; Test zuerst (Integration, Cyano-`Env`, `env.tick()`): jede Syntax erreicht den Editor mit den richtigen Werten (Blockposition abgerundet, Augenposition und Blickrichtung für `disc`), `disc abc` und fehlende Argumente werden abgelehnt, Konsole wird abgelehnt, Entwurf ist nach dem Trennen weg. Nachweis: Test grün, `/setup map …` unverändert. +- [ ] 3.4 `list` (Portale mit Id, Form, Aufgabe, Recht; darunter getrennt die offenen Entwürfe des Spielers mit Stand; leere Liste) im Befehl; Test zuerst (Unit über `PortalMessages`, Integration für den Befehl); grün. +- [ ] 3.5 Test zuerst (Unit, `PortalOutlineTest`): Quader liefert Punkte nur auf den zwölf Kanten von `min` bis `max + 1`, Abstand höchstens 0,5, Obergrenze je Portal; Ring liefert mindestens 16 Punkte, alle im Abstand `radius` vom Mittelpunkt und senkrecht zur Normalen (Achsen und Diagonale); rot. Dann `PortalOutline` umsetzen; grün. +- [ ] 3.6 `show`: wiederholender Spieler-Task (alle 5 Ticks, 32 Durchläufe, `ParticlePacket` nur an den ausführenden Spieler), ein zweites `show` ersetzt die laufende Anzeige, leere Welt meldet „nichts anzuzeigen“. Test zuerst (Integration, Cyano-`Env`, `env.tick()` statt Warten): Pakete gehen nur an den Spieler, nach 32 Ticks endet der Task, zweites `show` ersetzt den ersten; grün. + +- [ ] 3.7 (Welle 4) Test zuerst (Unit, `PortalFlowTest`): `PortalFlow.render` als reine Funktion liefert je Zustand den Schritt mit Knopfbeschriftungen, Befehlstexten und `RUN`/`SUGGEST`: Formwahl (`shape box|ring`), Box (`pos1`, dann `pos2`), Ring (`centre`, dann `radius 2|3|5|8` plus Vorschlag), Aufgabe (distinct Aufgaben der Portale, höchstens 8, plus `task `-Vorschlag; Aufgabe mit `<` und `'` bleibt wörtlich), Recht (`permission none`, Vorschlag), Zusammenfassung (`save`, `cancel`); rot. Dann `PortalFlow` umsetzen und `create`, `shape`, `centre`, `radius`, `save`, `cancel` in Editor und `PortalCommand` einhängen (Schritt wird nach jedem Verb eines geführten Entwurfs gesendet). Integration (Cyano-`Env`): jeder Knopfbefehl, als Spieler ausgeführt, ändert den Entwurf wie der getippte Befehl; `cancel` und Trennen verwerfen; grün. +- [ ] 3.8 (Welle 3) Test zuerst (Unit, `DraftOutlineTest`): Ecken als Blockmitten; Quader mit einer Ecke bis zum aktuellen Block, mit zwei Ecken; Ring mit Radius um den Mittelpunkt senkrecht zur Normalen (Achsen, Diagonale), Ring ohne Radius mit Blickrichtung und Standardradius 3 um die Augenposition; Obergrenze `MAX_POINTS = 256` bei riesigem Quader und Radius, Form bleibt vollständig; rot. Dann `DraftOutline` umsetzen (Kantenerzeugung mit `PortalOutline` geteilt); grün. Danach `DraftPreview` (ein Scheduler-Task je UUID, alle 5 Ticks, `ParticlePacket` nur an den Spieler, `start` ersetzt, `stop` bei `Saved`/`Removed`/`Cancelled`/Trennen, Hinweis „Standardradius“ einmal); Test zuerst (Integration, Cyano-`Env`, `env.tick()`): Pakete nur an den Entwurfsspieler, Quader folgt nach Bewegen, Task endet nach `save`, `cancel`, `remove` und Trennen, zweites `start` ersetzt den Task, keine Task-Einträge übrig (kein Leck); grün. +- [ ] 3.9 (Welle 4) Test zuerst (Unit, `PortalCompletionsTest`): Ids aus Welt und eigenen Entwürfen ohne Doppelte plus `list`/`show`/`create`, Verben, Aufgaben distinct, Radius-Hinweise, `none`, `box`/`ring`; rot. Dann `PortalCompletions` umsetzen und per `setSuggestionCallback` an die Argumente von `PortalCommand` hängen; grün. + +## 4. Doku und Abnahme (Welle 5) + +- [ ] 4.1 Abschnitt „Setup server“ in `README.md` um die Befehle, den geführten Ablauf (`create` mit Knöpfen), die Live-Vorschau, die Tab-Vervollständigung, den Ablauf per Befehl (Ecken oder Ring, Aufgabe, Recht, `show`, `save` als einziger Speicherweg, auch für Befehle), die Regel „Mittelpunkt = Augenposition auf 0,5 gerundet, Normale = Blickrichtung mit Einrasten bei 5 Grad“ und den Hinweis ergänzen, dass die Portale der Lobby beim Start ungültig abbrechen. Nachweis: README nennt jede Syntax aus der Spec. +- [ ] 4.2 Abnahme (Haiku, read-only): `./gradlew build` grün; jede Anforderung und jedes Szenario aus `specs/setup-portals` hat einen Test; F.I.R.S.T.-Prüfung der neuen Tests (keine Sleeps, keine Systemzeit, kein geteilter Zustand, `@TempDir`); keine Validierungsregel kopiert; Vorschau-Tasks leckfrei; kein Diff außerhalb von `setup/**` und `README.md`. Manuell: Setup-Server starten, Quader und Ring einmal geführt (jeden Knopf im Client klicken, Vorschau beobachten, Tab drücken) und einmal per Befehl anlegen, `show`, Neustart, `list` (einmal von Hand, Ergebnis im PR). + +## 5. Pull Request (Welle 6) + +- [ ] 5.1 Pull Request mit dem Titel `feat(setup): create lobby portals from in-game commands` gegen `main` öffnen (Titel und Beschreibung Englisch), Beschreibung nennt `lobby-portals` als Voraussetzung, die Befehle, den geführten Ablauf mit Vorschau und Vervollständigung, das Wand-Item als möglichen Folge-Change und den manuellen Abnahmelauf. Nachweis: PR-Link. From e57994668bdbcaa292ab25afa4096716cd0839fc Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:46:07 +0200 Subject: [PATCH 02/17] docs(openspec): align setup-portal-command with lobby-portals Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/setup-portal-command/design.md | 8 ++++---- openspec/changes/setup-portal-command/proposal.md | 2 +- openspec/changes/setup-portal-command/tasks.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/changes/setup-portal-command/design.md b/openspec/changes/setup-portal-command/design.md index d2c323ad..ec0029f7 100644 --- a/openspec/changes/setup-portal-command/design.md +++ b/openspec/changes/setup-portal-command/design.md @@ -2,13 +2,13 @@ ## Context -Motivation: siehe proposal.md. Anforderungen: `specs/setup-portals`. **Voraussetzung:** `lobby-portals` ist in `main` (die Typen `Portal`, `PortalShape` mit `Box`/`Disc` in `net.onelitefeather.titan.core.portal`, `PortalValidator`/`PortalProblem`, `LobbyMap.portals` samt Builder-Methode und Gson-Format sind Arbeitsnamen aus dem Entwurf dieses Changes; Aufgabe 1.1 gleicht sie mit dem gelieferten Code ab). +Motivation: siehe proposal.md. Anforderungen: `specs/setup-portals`. **Voraussetzung:** `lobby-portals` ist in `main` (die Typen sind mit Aufgabe 1.1 gegen den gelieferten Code geprüft: `Portal(String id, PortalShape shape, String task, @Nullable String permission)`, sealed `PortalShape` mit `Box(Vec min, Vec max)` und `Disc(Vec center, double radius, Vec normal)` (Minestom-`Vec`, also `double`-Koordinaten; `Box.min`/`max` sind blockinklusive Blockkoordinaten, `Disc.normal()` liefert den normierten Vektor), `PortalValidator` und `PortalProblem(@Nullable String portalId, int index, String reason)` mit `portalLabel()` liegen in `net.onelitefeather.titan.core.portal` (`core`); `LobbyMap.portals()` (unveränderlich, nie `null`), `LobbyMap.Builder.portals(List)` und `LobbyMap.lobbyMapBuilder(map)` (kopiert die Portale mit) liegen in `net.onelitefeather.titan.common.map` (`common`). Das Setup-Modul hängt von `:common` und `:core` ab (`setup/build.gradle.kts`), alle genannten Typen sind `public`. Ist-Zustand des Setup-Servers: - Eigene, DI-freie `Titan`-Klasse (`setup`): privater Konstruktor legt Instanz und `MapProvider` an, liest `SetupSpawnConfig`, registriert `BlockHandlerHelper` sowie `SetupCommand(mapProvider)` und die Listener von Hand. Kein Avaje, keine Column, keine Module. - `SetupCommand` (`/setup`, `Conditions::playerOnly`) hängt `MapCommand` als Unterbefehl ein. `MapCommand` (`setspawn`, `setname`, `setauthor`) speichert mit `mapProvider.saveMap(LobbyMap.lobbyMapBuilder(mapProvider.getActiveLobby()).x(...).build())`. Es gibt kein Recht auf den `/setup`-Befehlen; der Setup-Server ist ein internes Werkzeug des Build-Teams. -- `MapProvider.saveMap` schreibt die `map.json` der aktiven Welt (`GsonFileHandler` von Aves, Gson mit `PositionGsonAdapter`) und lädt sie neu (`loadMapData`). `LobbyMap.lobbyMapBuilder(map)` kopiert Spawn, Name und Autoren in den Builder; Felder, die dort fehlen, gehen beim Speichern verloren. +- `MapProvider.saveMap` schreibt die `map.json` der aktiven Welt (`GsonFileHandler` von Aves, Gson aus `MapGson.create()` mit `PositionGsonAdapter` und `PortalGsonAdapter`; beide Klassen sind paketprivat in `common`, das Setup-Modul braucht sie nicht, weil der `MapProvider` das Gson besitzt) und lädt sie neu (`loadMapData`). Beim Laden läuft `PortalValidator.requireValid` über alle Portale und bricht mit `IllegalStateException` ab; `saveMap` selbst prüft vor dem Schreiben nichts, ein ungültiges Portal würde also geschrieben und danach beim Neuladen den Setup-Server abbrechen lassen. Die Validierung im Editor (D5) ist deshalb Pflicht. `LobbyMap.lobbyMapBuilder(map)` kopiert Spawn, Name, Autoren und Portale. Format im `map.json`: Schlüssel `portals` (fehlt, wenn die Liste leer ist), je Eintrag `id`, `task`, optional `permission` (bei `null` weggelassen) und `shape` mit `type` `box` (`min`, `max`) oder `disc` (`center`, `radius`, `normal`), Punkte als `{x, y, z}`; die Normale wird normiert geschrieben. - Nachrichten sind englische MiniMessage-Strings mit ``, aufgelöst durch `TitanMiniMessageImpl` (`common`). Das Repository hat keine `TranslationStore`-Infrastruktur. - Der Setup-Server nutzt Aves nur für Map und Gson, keine Inventare, kein Wand-Item, keine Partikel. Tests: JUnit ohne Cyano (`TitanLauncherTest`, `SetupSpawnSettingsTest`). @@ -70,14 +70,14 @@ Verworfen: Die Reihenfolge „erst `task`, dann Form“ zu erzwingen (lästig) u ### D5: Validierung ist die der Lobby, nicht eine Kopie -Vor dem Speichern läuft dieselbe Prüfung wie beim Lobby-Start. Das Ziel ist, dass keine zweite Regelkopie entsteht (DRY): `lobby-portals` liefert `net.onelitefeather.titan.core.portal.PortalValidator` mit `static List problems(List)` (`PortalProblem(String portalId, String reason)`) und `requireValid(String world, List)`; `Box`, `Disc` und `Portal` sind reine Daten ohne Konstruktorprüfung. `PortalEditor` baut die Liste, wie sie nach dem Speichern in der Datei stünde (Ersetzen an der Listenposition oder Anhängen), ruft `PortalValidator.problems(...)`, nimmt die Probleme mit `portalId` gleich der bearbeiteten Id und macht daraus `Rejected(Grund)`; `PortalMessages` bildet sie auf Chat-Meldungen ab (der `reason` als `Placeholder.unparsed`). Probleme anderer, schon vorher ungültiger Portale blockieren die Bearbeitung dieses Portals nicht. `requireValid` bleibt dem Lobby-Start. Fehlt dort eine Regel, die die Spec hier nennt, wandert sie in `PortalValidator` (Anpassung in `lobby-portals`, nicht hier kopiert). Nur die Regeln des Befehls selbst liegen im Setup-Modul: Id-Muster, gesperrte Ids (`list`, `show`, `create`), Radius als Zahl, leere Aufgabe vor dem Trimmen sowie die Vollständigkeit des Entwurfs (beide Ecken, Radius, Aufgabe), weil `Box` ohne Ecken gar nicht existiert. +Vor dem Speichern läuft dieselbe Prüfung wie beim Lobby-Start. Das Ziel ist, dass keine zweite Regelkopie entsteht (DRY): `lobby-portals` liefert `net.onelitefeather.titan.core.portal.PortalValidator` mit `static List problems(List)` (`PortalProblem(@Nullable String portalId, int index, String reason)` mit `portalLabel()`) und `requireValid(String world, List)`; `Box`, `Disc` und `Portal` sind reine Daten ohne Konstruktorprüfung. `PortalEditor` baut die Liste, wie sie nach dem Speichern in der Datei stünde (Ersetzen an der Listenposition oder Anhängen), ruft `PortalValidator.problems(...)`, nimmt die Probleme mit `index` gleich der Listenposition des bearbeiteten Portals (nicht über `portalId`, das `null` oder doppelt sein kann) und macht daraus `Rejected(Grund)`; `PortalMessages` bildet sie auf Chat-Meldungen ab (`portalLabel()` und `reason`, beides als `Placeholder.unparsed`). Probleme anderer Portale blockieren die Bearbeitung dieses Portals nicht (in der Praxis sind sie selten, weil der Start mit ungültigen Portalen schon abbricht). `requireValid` bleibt dem Lobby-Start. Der Validator prüft Id (nicht leer, eindeutig), Aufgabe (nicht leer), Quader (`min` je Achse höchstens `max`), Ring (`radius > 0`, Normale mit Länge > 0); Recht und Id-Muster prüft er nicht. Fehlt dort eine Regel, die die Spec hier nennt, wandert sie in `PortalValidator` (Anpassung in `lobby-portals`, nicht hier kopiert). Nur die Regeln des Befehls selbst liegen im Setup-Modul: Id-Muster, gesperrte Ids (`list`, `show`, `create`), Radius als Zahl, leere Aufgabe vor dem Trimmen sowie die Vollständigkeit des Entwurfs (beide Ecken, Radius, Aufgabe), weil `Box` ohne Ecken gar nicht existiert. - Test (Unit): jede Regel aus der Spec als Ablehnungstest; ein Test, der `PortalValidator.requireValid` und den Editor an derselben ungültigen Liste vergleicht, hält beide im Gleichschritt. - SOLID: Dependency Inversion auf die Regeln in `core`; Open/Closed. ### D6: Speichern über den vorhandenen Weg, ohne Datenverlust -Gespeichert wird `LobbyMap.lobbyMapBuilder(active).portals(list).build()` über `MapProvider.saveMap`. Das setzt voraus, dass `lobby-portals` `lobbyMapBuilder(map)` die Portale mitkopieren lässt, sonst löschte `/setup map setspawn` alle Portale. Aufgabe 1.1 prüft das; fehlt es, ergänzt `lobby-portals` es (kleine Korrektur dort, mit Test), nicht dieser Change. Die Liste ist unveränderlich (`List.copyOf`). +Gespeichert wird `LobbyMap.lobbyMapBuilder(active).portals(list).build()` über `MapProvider.saveMap`. Das setzt voraus, dass `lobbyMapBuilder(map)` die Portale mitkopiert, sonst löschte `/setup map setspawn` alle Portale. Aufgabe 1.1 hat das geprüft: `LobbyMap.lobbyMapBuilder(map)` ruft `builder.portals(map.portals())` (Tests `LobbyMapPortalsTest`, `MapProviderPortalTest.saveWritesPortals` in `common`), Korrektur in `lobby-portals` ist nicht nötig. Beim Speichern einer leeren Liste fehlt der Schlüssel `portals` in der Datei; das ist gewollt und lädt als leere Liste. Die Liste ist unveränderlich (`List.copyOf`). Nebenwirkung, unverändert vom Bestand: `saveMap` lädt die Map neu (`loadMapData`), auch für Portale. Das ist derselbe Pfad wie bei `setspawn` und kostet ein Neuladen der Map-Datei. diff --git a/openspec/changes/setup-portal-command/proposal.md b/openspec/changes/setup-portal-command/proposal.md index c34d0282..fc04f912 100644 --- a/openspec/changes/setup-portal-command/proposal.md +++ b/openspec/changes/setup-portal-command/proposal.md @@ -6,7 +6,7 @@ Der Change `lobby-portals` lässt die Lobby Portale aus der `map.json` der Welt ## What Changes -- **Voraussetzung:** Dieser Change baut auf `lobby-portals` auf und darf erst umgesetzt werden, wenn dessen Code in `main` ist. Er verwendet dessen Typen unter den Arbeitsnamen `net.onelitefeather.titan.core.portal.PortalShape` (sealed, `Box(min, max)` und `Disc(center, radius, normal)`) und `Portal(String id, PortalShape shape, String task, @Nullable String permission)` sowie die Liste `portals` an `LobbyMap` samt Builder-Methode. Die Validierung liefert `lobby-portals` als `net.onelitefeather.titan.core.portal.PortalValidator` (`static List problems(List)`, `PortalProblem(String portalId, String reason)`, `requireValid(String world, List)`); die Records `Box`/`Disc`/`Portal` sind reine Daten ohne Konstruktorprüfung. Die Namen MÜSSEN mit dem übereinstimmen, was `lobby-portals` am Ende ausliefert; Aufgabe 1.1 prüft das und passt design.md und tasks.md sonst an. +- **Voraussetzung:** Dieser Change baut auf `lobby-portals` auf und darf erst umgesetzt werden, wenn dessen Code in `main` ist. Er verwendet dessen Typen unter diesen Namen (in Aufgabe 1.1 gegen den gelieferten Code geprüft) `net.onelitefeather.titan.core.portal.PortalShape` (sealed, `Box(min, max)` und `Disc(center, radius, normal)`) und `Portal(String id, PortalShape shape, String task, @Nullable String permission)` sowie die Liste `portals` an `LobbyMap` samt Builder-Methode. Die Validierung liefert `lobby-portals` als `net.onelitefeather.titan.core.portal.PortalValidator` (`static List problems(List)`, `PortalProblem(@Nullable String portalId, int index, String reason)` mit `portalLabel()`, `requireValid(String world, List)`); die Records `Box`/`Disc`/`Portal` sind reine Daten ohne Konstruktorprüfung. Die Namen MÜSSEN mit dem übereinstimmen, was `lobby-portals` am Ende ausliefert; Aufgabe 1.1 prüft das und passt design.md und tasks.md sonst an. - Neuer Unterbefehl `/setup portal ...` im Setup-Server (neben `/setup map ...`): - `/setup portal pos1` und `pos2`: Blockposition des Spielers als Quader-Ecke; der Quader ist die blockinklusive Spanne. - `/setup portal disc `: Ring um die Augenposition des Spielers, Normale ist die Blickrichtung, nahe an einer Achse wird sie auf die Achse eingerastet. diff --git a/openspec/changes/setup-portal-command/tasks.md b/openspec/changes/setup-portal-command/tasks.md index c5182b45..772c69f3 100644 --- a/openspec/changes/setup-portal-command/tasks.md +++ b/openspec/changes/setup-portal-command/tasks.md @@ -19,14 +19,14 @@ Wellen 2 und 3 (`outline`) berühren getrennte Dateien und laufen parallel; Well ## 1. Voraussetzung prüfen (Welle 1) -- [ ] 1.1 Prüfen, dass `lobby-portals` in `origin/main` ist und dass `Portal`, `PortalShape` (`Box(min, max)`, `Disc(center, radius, normal)`), `LobbyMap.portals`, die Builder-Methode und das JSON-Format mit den Arbeitsnamen aus proposal.md und design.md übereinstimmen; ebenso `net.onelitefeather.titan.core.portal.PortalValidator` (`static List problems(List)`, `PortalProblem(String portalId, String reason)`, `requireValid(String world, List)`; `Box`/`Disc`/`Portal` ohne Konstruktorprüfung) und dass `LobbyMap.lobbyMapBuilder(map)` die Portale mitkopiert. Abweichungen in design.md (Context, D5, D6) und tasks.md nachtragen; fehlt die Wiederverwendung oder das Mitkopieren, in `lobby-portals` nachziehen lassen, nicht hier kopieren. Nachweis: Bericht mit Dateiverweisen; design.md und tasks.md passen zum gelieferten Code. +- [x] 1.1 Prüfen, dass `lobby-portals` in `origin/main` ist und dass `Portal`, `PortalShape` (`Box(min, max)`, `Disc(center, radius, normal)`), `LobbyMap.portals`, die Builder-Methode und das JSON-Format mit den Arbeitsnamen aus proposal.md und design.md übereinstimmen; ebenso `net.onelitefeather.titan.core.portal.PortalValidator` (`static List problems(List)`, `PortalProblem(@Nullable String portalId, int index, String reason)` mit `portalLabel()`, `requireValid(String world, List)`; `Box`/`Disc`/`Portal` ohne Konstruktorprüfung) und dass `LobbyMap.lobbyMapBuilder(map)` die Portale mitkopiert. Abweichungen in design.md (Context, D5, D6) und tasks.md nachtragen; fehlt die Wiederverwendung oder das Mitkopieren, in `lobby-portals` nachziehen lassen, nicht hier kopieren. Nachweis: Bericht mit Dateiverweisen; design.md und tasks.md passen zum gelieferten Code. ## 2. Bearbeitungslogik ohne Server (Welle 2) - [ ] 2.1 Test zuerst (Unit, `DiscPlacementTest`): Mittelpunkt auf 0,5 gerundet; Normale eingerastet bei 2 Grad neben ±X/±Y/±Z, nicht bei 6 Grad; Diagonale bleibt normiert; Blick nach oben und unten; rot. Dann `DiscPlacement` umsetzen; grün. - [ ] 2.2 Test zuerst (Unit, `PortalEditorTest`, In-Memory-`PortalStore`, Spieler-UUIDs als Werte): alle Szenarien aus `specs/setup-portals` für Quader (zwei Ecken in beliebiger Reihenfolge, Ecken zweier Spieler, Ecke ersetzt Ecke, Ring wird Quader), Aufgabe, Recht (`none`), Entwurf ohne Form/Aufgabe (`Pending` mit dem, was fehlt), `remove` (gespeichert, Entwurf, unbekannt), reservierte (`list`, `show`, `create`) und ungültige Ids, leere Aufgabe, Radius 0 und negativ, doppelte Id in der Liste, `centre` und `radius` (Radius ändert nur den Radius, Ring ohne Radius ist `Pending`), `shape`, vollständiger Entwurf wird ohne `save` nicht gespeichert (Antwort `Complete`, Map unverändert), `save` speichert (Ersetzen bei bestehender Id ohne Duplikat), `save` mit Problemen oder unvollständig meldet und lässt den Entwurf offen, Änderung an gespeichertem Portal wirkt erst nach `save`, `cancel`, Probleme von `PortalValidator.problems(...)` anderer Ids blockieren nicht; rot. Dann `PortalDraft`, `PortalStore`, `PortalEditor` und das sealed Ergebnis umsetzen; die Regelprüfung ruft `PortalValidator.problems(...)` auf und bildet die Probleme der bearbeiteten Id auf `Rejected(Grund)` ab (D5); grün. - [ ] 2.3 Test zuerst (Unit): ein Vergleichstest hält `PortalEditor` und `PortalValidator.requireValid` an derselben ungültigen Portalliste im Gleichschritt (beide lehnen ab, gleiche Regel). Nachweis: Test grün, keine Regel im Setup-Modul kopiert (Review). -- [ ] 2.4 Test zuerst (Unit, `PortalMessagesTest`): Erfolg/Fehler/Fehlt-Meldungen, Abbildung von `PortalProblem` auf Chat (Grund über `unparsed`), Platzhalter, `<`-Tags in der Aufgabe werden nicht ausgewertet (`unparsed`); dazu die INFO-Zeile je Speichern/Entfernen über einen aufgefangenen Appender, ohne Spielernamen; rot. Dann `PortalMessages` und das Logging im Editor umsetzen; grün. +- [ ] 2.4 Test zuerst (Unit, `PortalMessagesTest`): Erfolg/Fehler/Fehlt-Meldungen, Abbildung von `PortalProblem` auf Chat (`portalLabel()` und Grund über `unparsed`), Platzhalter, `<`-Tags in der Aufgabe werden nicht ausgewertet (`unparsed`); dazu die INFO-Zeile je Speichern/Entfernen über einen aufgefangenen Appender, ohne Spielernamen; rot. Dann `PortalMessages` und das Logging im Editor umsetzen; grün. ## 3. Befehl, Speichern, Umriss (Welle 3) From 627940a0f6903b3f2afaaa32c8be7a45685ec15e Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:50:47 +0200 Subject: [PATCH 03/17] feat(setup): place portal discs from eye position and look direction Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/setup/portal/DiscPlacement.java | 70 ++++++++++ .../titan/setup/portal/DiscPlacementTest.java | 124 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/DiscPlacement.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/DiscPlacementTest.java diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/DiscPlacement.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/DiscPlacement.java new file mode 100644 index 00000000..766d8da2 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/DiscPlacement.java @@ -0,0 +1,70 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Disc; + +/** + * Turns where a builder stands and looks into the parts of a {@link Disc}. Pure, so the rounding + * and + * snapping rules are testable without a server. + */ +public final class DiscPlacement { + + /** Looking "straight ahead" rarely hits an axis exactly; within this angle the normal snaps. */ + static final double SNAP_DEGREES = 5.0; + + private static final double SNAP_COSINE = Math.cos(Math.toRadians(SNAP_DEGREES)); + private static final Vec[] AXES = {new Vec(1, 0, 0), new Vec(-1, 0, 0), new Vec(0, 1, 0), new Vec(0, -1, 0), new Vec(0, 0, 1), new Vec(0, 0, -1) + }; + + private DiscPlacement() { + } + + public static Disc of(Point eye, Vec look, double radius) { + return new Disc(centre(eye), radius, normal(look)); + } + + /** The eye position with each coordinate rounded to the nearest multiple of 0.5. */ + public static Vec centre(Point eye) { + return new Vec(roundToHalf(eye.x()), roundToHalf(eye.y()), roundToHalf(eye.z())); + } + + /** + * The unit look direction, snapped onto the nearest of the six axes when at most + * {@value #SNAP_DEGREES} degrees away from it. + * + * @throws IllegalArgumentException if {@code look} has length zero + */ + public static Vec normal(Vec look) { + if (!(look.length() > 0)) { + throw new IllegalArgumentException("look direction must not have length 0"); + } + Vec unit = look.normalize(); + for (Vec axis : AXES) { + if (unit.dot(axis) >= SNAP_COSINE) { + return axis; + } + } + return unit; + } + + private static double roundToHalf(double value) { + return Math.round(value * 2) / 2.0; + } +} diff --git a/setup/src/test/java/net/onelitefeather/titan/setup/portal/DiscPlacementTest.java b/setup/src/test/java/net/onelitefeather/titan/setup/portal/DiscPlacementTest.java new file mode 100644 index 00000000..35022120 --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/titan/setup/portal/DiscPlacementTest.java @@ -0,0 +1,124 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Disc; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DiscPlacementTest { + + private static final Pos ORIGIN = new Pos(0, 0, 0); + + /** A direction that deviates from {@code axis} by {@code degrees} towards {@code towards}. */ + private static Vec tilted(Vec axis, Vec towards, double degrees) { + double radians = Math.toRadians(degrees); + return axis.mul(Math.cos(radians)).add(towards.mul(Math.sin(radians))); + } + + @Test + @DisplayName("Rounds every centre coordinate to the nearest half block") + void roundsCentreToHalfBlocks() { + Disc disc = DiscPlacement.of(new Pos(0.52, 72.0, 40.47), new Vec(0, 0, 1), 5.5); + + assertEquals(new Vec(0.5, 72.0, 40.5), disc.center(), "centre is rounded to 0.5"); + assertEquals(5.5, disc.radius(), "radius is passed through"); + } + + @Test + @DisplayName("Rounds negative coordinates to the nearest half block") + void roundsNegativeCentre() { + Disc disc = DiscPlacement.of(new Pos(-0.26, -10.74, -3.0), new Vec(0, 0, 1), 1); + + assertEquals(new Vec(-0.5, -10.5, -3.0), disc.center(), "negative values round to the nearest 0.5"); + } + + @Test + @DisplayName("Snaps a look direction 2 degrees off +Z onto +Z") + void snapsNearPositiveZ() { + Disc disc = DiscPlacement.of(ORIGIN, tilted(new Vec(0, 0, 1), new Vec(1, 0, 0), 2), 3); + + assertEquals(new Vec(0, 0, 1), disc.normal(), "normal snaps to +Z"); + } + + @Test + @DisplayName("Snaps to each of the six axes when 2 degrees off") + void snapsToEverySixAxis() { + Vec[] axes = {new Vec(1, 0, 0), new Vec(-1, 0, 0), new Vec(0, 1, 0), new Vec(0, -1, 0), new Vec(0, 0, 1), new Vec(0, 0, -1)}; + for (Vec axis : axes) { + Vec towards = axis.y() == 0 ? new Vec(0, 1, 0) : new Vec(1, 0, 0); + + Disc disc = DiscPlacement.of(ORIGIN, tilted(axis, towards, 2), 3); + + assertEquals(axis, disc.normal(), "2 degrees off " + axis + " snaps to it"); + } + } + + @Test + @DisplayName("Snaps a direction exactly on the axis") + void snapsExactAxis() { + assertEquals(new Vec(0, 1, 0), DiscPlacement.of(ORIGIN, new Vec(0, 1, 0), 3).normal(), "looking straight up gives +Y"); + assertEquals(new Vec(0, -1, 0), DiscPlacement.of(ORIGIN, new Vec(0, -1, 0), 3).normal(), "looking straight down gives -Y"); + } + + @Test + @DisplayName("Keeps the exact direction when it is 6 degrees off an axis") + void doesNotSnapAtSixDegrees() { + Vec look = tilted(new Vec(0, 0, 1), new Vec(1, 0, 0), 6); + + Disc disc = DiscPlacement.of(ORIGIN, look, 3); + + assertEquals(look.x(), disc.normal().x(), 1e-9, "x stays"); + assertEquals(look.z(), disc.normal().z(), 1e-9, "z stays"); + assertEquals(1.0, disc.normal().length(), 1e-9, "normal is a unit vector"); + } + + @Test + @DisplayName("Snaps just inside 5 degrees and not just outside") + void snapsAtTheFiveDegreeBoundary() { + assertEquals(new Vec(0, 0, 1), DiscPlacement.of(ORIGIN, tilted(new Vec(0, 0, 1), new Vec(1, 0, 0), 4.99), 3).normal(), "4.99 degrees snaps"); + assertTrue(DiscPlacement.of(ORIGIN, tilted(new Vec(0, 0, 1), new Vec(1, 0, 0), 5.01), 3).normal().x() > 0, "5.01 degrees does not snap"); + } + + @Test + @DisplayName("Normalises a diagonal look direction without snapping") + void normalisesDiagonal() { + Disc disc = DiscPlacement.of(ORIGIN, new Vec(3, 0, 3), 3); + + double component = Math.sqrt(0.5); + assertEquals(component, disc.normal().x(), 1e-9, "x of the diagonal"); + assertEquals(0.0, disc.normal().y(), 1e-9, "y of the diagonal"); + assertEquals(component, disc.normal().z(), 1e-9, "z of the diagonal"); + } + + @Test + @DisplayName("Snaps an unnormalised look vector near an axis") + void snapsUnnormalisedLook() { + assertEquals(new Vec(1, 0, 0), DiscPlacement.of(ORIGIN, new Vec(10, 0.1, 0), 3).normal(), "length does not matter for snapping"); + } + + @Test + @DisplayName("Rejects a look vector of length zero") + void rejectsZeroLook() { + assertThrows(IllegalArgumentException.class, () -> DiscPlacement.of(ORIGIN, Vec.ZERO, 3), "a zero direction has no plane"); + } +} From 5b913c74072eb100b51a735363d578492cf85b52 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:50:48 +0200 Subject: [PATCH 04/17] feat(setup): add the portal editor with per-player drafts Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/setup/portal/Missing.java | 21 + .../titan/setup/portal/PortalDraft.java | 194 +++++++ .../titan/setup/portal/PortalEditResult.java | 67 +++ .../titan/setup/portal/PortalEditor.java | 258 +++++++++ .../titan/setup/portal/PortalStore.java | 37 ++ .../setup/portal/InMemoryPortalStore.java | 52 ++ .../titan/setup/portal/PortalEditorTest.java | 529 ++++++++++++++++++ 7 files changed, 1158 insertions(+) create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/Missing.java create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalDraft.java create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditResult.java create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalStore.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/InMemoryPortalStore.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorTest.java diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/Missing.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/Missing.java new file mode 100644 index 00000000..d9860491 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/Missing.java @@ -0,0 +1,21 @@ +/** + * 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.setup.portal; + +/** A part of a {@link PortalDraft} that is still needed before it can be saved. */ +public enum Missing { + FORM, CORNER_1, CORNER_2, CENTRE, RADIUS, TASK +} diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalDraft.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalDraft.java new file mode 100644 index 00000000..aefc9192 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalDraft.java @@ -0,0 +1,194 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * One player's unsaved work on one portal. Readable everywhere in the module (the preview and the + * guided flow show it); only the {@link PortalEditor} changes it. + */ +public final class PortalDraft { + + /** The shape kind a draft is heading for. */ + public enum Form { + BOX, RING + } + + private final String id; + private @Nullable Form form; + private @Nullable Vec corner1; + private @Nullable Vec corner2; + private @Nullable Vec centre; + private @Nullable Vec normal; + private @Nullable Double radius; + private @Nullable String task; + private @Nullable String permission; + + PortalDraft(String id) { + this.id = id; + } + + /** A draft holding the values of a saved portal, so edits start from what is there. */ + static PortalDraft of(Portal portal) { + PortalDraft draft = new PortalDraft(portal.id()); + draft.task = portal.task(); + draft.permission = portal.permission(); + switch (portal.shape()) { + case Box box -> { + draft.form = Form.BOX; + draft.corner1 = box.min(); + draft.corner2 = box.max(); + } + case Disc disc -> { + draft.form = Form.RING; + draft.centre = disc.center(); + draft.radius = disc.radius(); + draft.normal = disc.normal(); + } + } + return draft; + } + + public String id() { + return id; + } + + public @Nullable Form form() { + return form; + } + + public @Nullable Vec corner1() { + return corner1; + } + + public @Nullable Vec corner2() { + return corner2; + } + + public @Nullable Vec centre() { + return centre; + } + + public @Nullable Vec normal() { + return normal; + } + + public @Nullable Double radius() { + return radius; + } + + public @Nullable String task() { + return task; + } + + public @Nullable String permission() { + return permission; + } + + /** What is still needed before {@code save} can succeed, in the order a builder works. */ + public List missing() { + List missing = new ArrayList<>(); + if (form == null) { + missing.add(Missing.FORM); + } else if (form == Form.BOX) { + if (corner1 == null) { + missing.add(Missing.CORNER_1); + } + if (corner2 == null) { + missing.add(Missing.CORNER_2); + } + } else { + if (centre == null) { + missing.add(Missing.CENTRE); + } + if (radius == null) { + missing.add(Missing.RADIUS); + } + } + if (task == null) { + missing.add(Missing.TASK); + } + return missing; + } + + public boolean complete() { + return missing().isEmpty(); + } + + /** The portal this draft describes, or empty while parts are missing. */ + Optional toPortal() { + if (!complete()) { + return Optional.empty(); + } + if (form == Form.BOX) { + Vec min = new Vec(Math.min(corner1.x(), corner2.x()), Math.min(corner1.y(), corner2.y()), Math.min(corner1.z(), corner2.z())); + Vec max = new Vec(Math.max(corner1.x(), corner2.x()), Math.max(corner1.y(), corner2.y()), Math.max(corner1.z(), corner2.z())); + return Optional.of(new Portal(id, new Box(min, max), task, permission)); + } + return Optional.of(new Portal(id, new Disc(centre, radius, normal), task, permission)); + } + + /** Switching form drops what belongs to the other one; the same form keeps everything. */ + void form(Form form) { + if (this.form == form) { + return; + } + this.form = form; + this.corner1 = null; + this.corner2 = null; + this.centre = null; + this.normal = null; + this.radius = null; + } + + void corner1(Vec corner) { + form(Form.BOX); + this.corner1 = corner; + } + + void corner2(Vec corner) { + form(Form.BOX); + this.corner2 = corner; + } + + void centre(Vec centre, Vec normal) { + form(Form.RING); + this.centre = centre; + this.normal = normal; + } + + void radius(double radius) { + form(Form.RING); + this.radius = radius; + } + + void task(String task) { + this.task = task; + } + + void permission(@Nullable String permission) { + this.permission = permission; + } +} diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditResult.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditResult.java new file mode 100644 index 00000000..e66a7957 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditResult.java @@ -0,0 +1,67 @@ +/** + * 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.setup.portal; + +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.portal.PortalProblem; + +import java.util.List; + +/** What an edit did; the command turns it into chat text with a {@code switch}. */ +public sealed interface PortalEditResult { + + /** A new portal was written to the map. */ + record Saved(Portal portal) implements PortalEditResult { + } + + /** A portal with the same id was replaced in the map. */ + record Updated(Portal portal) implements PortalEditResult { + } + + /** The draft is still missing parts. */ + record Pending(String id, List missing) implements PortalEditResult { + public Pending { + missing = List.copyOf(missing); + } + } + + /** The draft can be saved, but has not been. */ + record Complete(String id) implements PortalEditResult { + } + + /** The saved portal and the draft are gone. */ + record Removed(String id) implements PortalEditResult { + } + + /** {@code PortalValidator} found problems for the edited portal; the map is unchanged. */ + record Rejected(String id, List problems) implements PortalEditResult { + public Rejected { + problems = List.copyOf(problems); + } + } + + /** The input broke a rule of the command itself (id pattern, blank task, radius). */ + record Invalid(String reason) implements PortalEditResult { + } + + /** The draft was discarded. */ + record Cancelled(String id) implements PortalEditResult { + } + + /** Neither a saved portal nor a draft of the player has this id. */ + record Unknown(String id) implements PortalEditResult { + } +} diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java new file mode 100644 index 00000000..2e423cf8 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java @@ -0,0 +1,258 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.portal.PortalProblem; +import net.onelitefeather.titan.core.portal.PortalValidator; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Cancelled; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Complete; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Invalid; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Pending; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Rejected; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Removed; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Saved; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Unknown; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Updated; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * The rules of editing portals, free of Minestom commands and files: players are UUIDs, positions + * and look directions are values. Every edit only changes the player's draft; {@link #save} is the + * one way into the {@link PortalStore}. + */ +public final class PortalEditor { + + private static final Pattern ID_PATTERN = Pattern.compile("[a-z0-9_-]+"); + /** Words the command uses in the id position. */ + private static final Set RESERVED_IDS = Set.of("list", "show", "create"); + private static final String NO_PERMISSION = "none"; + + private final PortalStore store; + private final Map> drafts = new HashMap<>(); + + public PortalEditor(PortalStore store) { + this.store = store; + } + + public PortalEditResult corner1(UUID player, String id, Point position) { + return edit(player, id, draft -> draft.corner1(blockOf(position))); + } + + public PortalEditResult corner2(UUID player, String id, Point position) { + return edit(player, id, draft -> draft.corner2(blockOf(position))); + } + + public PortalEditResult shape(UUID player, String id, PortalDraft.Form form) { + return edit(player, id, draft -> draft.form(form)); + } + + /** + * Sets centre and normal from where the player stands and looks, without touching the radius. + */ + public PortalEditResult centre(UUID player, String id, Point eye, Vec look) { + return edit(player, id, draft -> draft.centre(DiscPlacement.centre(eye), DiscPlacement.normal(look))); + } + + /** Sets only the radius of a ring draft. */ + public PortalEditResult radius(UUID player, String id, double radius) { + Optional invalid = invalidRadius(radius); + if (invalid.isPresent()) { + return invalid.get(); + } + return edit(player, id, draft -> draft.radius(radius), draft -> draft.form() == PortalDraft.Form.BOX ? Optional.of(new Invalid("a box has no radius, use 'shape ring' or 'centre' first")) : Optional.empty()); + } + + /** Shorthand for {@link #centre} and {@link #radius} in one step. */ + public PortalEditResult disc(UUID player, String id, Point eye, Vec look, double radius) { + Optional invalid = invalidRadius(radius); + if (invalid.isPresent()) { + return invalid.get(); + } + return edit(player, id, draft -> { + draft.centre(DiscPlacement.centre(eye), DiscPlacement.normal(look)); + draft.radius(radius); + }); + } + + public PortalEditResult task(UUID player, String id, String task) { + if (task == null || task.isBlank()) { + return new Invalid("the task must not be empty"); + } + return edit(player, id, draft -> draft.task(task.trim())); + } + + /** {@code none} removes the permission. */ + public PortalEditResult permission(UUID player, String id, String permission) { + if (permission == null || permission.isBlank()) { + return new Invalid("the permission must not be empty, use 'none' to remove it"); + } + String value = permission.trim(); + return edit(player, id, draft -> draft.permission(NO_PERMISSION.equals(value) ? null : value)); + } + + /** Writes the complete, valid draft to the store; otherwise reports why and keeps the draft. */ + public PortalEditResult save(UUID player, String id) { + Optional invalid = invalidId(id); + if (invalid.isPresent()) { + return invalid.get(); + } + PortalDraft draft = draftsOf(player).get(id); + if (draft == null) { + return new Unknown(id); + } + Optional portal = draft.toPortal(); + if (portal.isEmpty()) { + return new Pending(id, draft.missing()); + } + List saved = store.portals(); + int position = indexOf(saved, id); + List next = new ArrayList<>(saved); + if (position < 0) { + position = next.size(); + next.add(portal.get()); + } else { + next.set(position, portal.get()); + } + int edited = position; + // The validator is the lobby's own check; only the edited entry's problems concern this save. + List problems = PortalValidator.problems(next).stream().filter(problem -> problem.index() == edited).toList(); + if (!problems.isEmpty()) { + return new Rejected(id, problems); + } + store.save(next); + dropDraft(player, id); + return edited < saved.size() ? new Updated(portal.get()) : new Saved(portal.get()); + } + + /** Discards the player's draft; the saved portal stays. */ + public PortalEditResult cancel(UUID player, String id) { + Optional invalid = invalidId(id); + if (invalid.isPresent()) { + return invalid.get(); + } + return dropDraft(player, id) ? new Cancelled(id) : new Unknown(id); + } + + /** Deletes the saved portal and the player's draft of it. */ + public PortalEditResult remove(UUID player, String id) { + Optional invalid = invalidId(id); + if (invalid.isPresent()) { + return invalid.get(); + } + List saved = store.portals(); + boolean wasSaved = indexOf(saved, id) >= 0; + if (wasSaved) { + store.save(saved.stream().filter(portal -> !id.equals(portal.id())).toList()); + } + boolean hadDraft = dropDraft(player, id); + return wasSaved || hadDraft ? new Removed(id) : new Unknown(id); + } + + /** The player's open drafts, oldest first. */ + public List drafts(UUID player) { + Map own = drafts.get(player); + return own == null ? List.of() : List.copyOf(own.values()); + } + + /** Forgets every draft of the player, for when they leave. */ + public void discardAll(UUID player) { + drafts.remove(player); + } + + private PortalEditResult edit(UUID player, String id, Consumer change) { + return edit(player, id, change, draft -> Optional.empty()); + } + + /** Applies {@code change} unless {@code guard} objects to the draft's current state. */ + private PortalEditResult edit(UUID player, String id, Consumer change, Function> guard) { + Optional invalid = invalidId(id); + if (invalid.isPresent()) { + return invalid.get(); + } + PortalDraft draft = draftsOf(player).computeIfAbsent(id, this::startDraft); + Optional refused = guard.apply(draft); + if (refused.isPresent()) { + return refused.get(); + } + change.accept(draft); + List missing = draft.missing(); + return missing.isEmpty() ? new Complete(id) : new Pending(id, missing); + } + + private PortalDraft startDraft(String id) { + return store.portals().stream().filter(portal -> id.equals(portal.id())).findFirst().map(PortalDraft::of).orElseGet(() -> new PortalDraft(id)); + } + + private Map draftsOf(UUID player) { + return drafts.computeIfAbsent(player, ignored -> new LinkedHashMap<>()); + } + + private boolean dropDraft(UUID player, String id) { + Map own = drafts.get(player); + if (own == null) { + return false; + } + boolean removed = own.remove(id) != null; + if (own.isEmpty()) { + drafts.remove(player); + } + return removed; + } + + private static int indexOf(List portals, String id) { + for (int index = 0; index < portals.size(); index++) { + if (id.equals(portals.get(index).id())) { + return index; + } + } + return -1; + } + + private static Vec blockOf(Point position) { + return new Vec(Math.floor(position.x()), Math.floor(position.y()), Math.floor(position.z())); + } + + private static Optional invalidId(String id) { + if (id == null || !ID_PATTERN.matcher(id).matches()) { + return Optional.of(new Invalid("the id may only contain a-z, 0-9, '-' and '_'")); + } + if (RESERVED_IDS.contains(id)) { + return Optional.of(new Invalid("'" + id + "' is reserved and cannot be used as an id")); + } + return Optional.empty(); + } + + private static Optional invalidRadius(double radius) { + if (!(radius > 0) || Double.isInfinite(radius)) { + return Optional.of(new Invalid("the radius must be a number greater than 0")); + } + return Optional.empty(); + } +} diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalStore.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalStore.java new file mode 100644 index 00000000..386f50bb --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalStore.java @@ -0,0 +1,37 @@ +/** + * 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.setup.portal; + +import net.onelitefeather.titan.core.portal.Portal; + +import java.util.List; + +/** + * Where the portals of the loaded world live; the editor neither knows files nor the map provider. + */ +public interface PortalStore { + + /** The saved portals of the world, in file order. */ + List portals(); + + /** Replaces the world's portals with {@code portals} and keeps everything else in the map. */ + void save(List portals); + + /** Name of the world for log lines. */ + default String world() { + return "unknown"; + } +} diff --git a/setup/src/test/java/net/onelitefeather/titan/setup/portal/InMemoryPortalStore.java b/setup/src/test/java/net/onelitefeather/titan/setup/portal/InMemoryPortalStore.java new file mode 100644 index 00000000..c0b194b9 --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/titan/setup/portal/InMemoryPortalStore.java @@ -0,0 +1,52 @@ +/** + * 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.setup.portal; + +import net.onelitefeather.titan.core.portal.Portal; + +import java.util.ArrayList; +import java.util.List; + +/** A {@link PortalStore} that keeps the list in memory and counts how often it was written. */ +final class InMemoryPortalStore implements PortalStore { + + private List portals; + private int saves; + + InMemoryPortalStore(Portal... portals) { + this.portals = List.of(portals); + } + + @Override + public List portals() { + return portals; + } + + @Override + public void save(List portals) { + this.portals = new ArrayList<>(portals); + this.saves++; + } + + @Override + public String world() { + return "test-world"; + } + + int saves() { + return saves; + } +} diff --git a/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorTest.java b/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorTest.java new file mode 100644 index 00000000..99df538e --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorTest.java @@ -0,0 +1,529 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Cancelled; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Complete; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Invalid; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Pending; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Rejected; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Removed; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Saved; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Unknown; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Updated; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PortalEditorTest { + + private static final UUID ALICE = new UUID(0, 1); + private static final UUID BOB = new UUID(0, 2); + private static final Vec LOOK_Z = new Vec(0, 0, 1); + + private InMemoryPortalStore store; + private PortalEditor editor; + + @BeforeEach + void freshFixture() { + store = new InMemoryPortalStore(); + editor = new PortalEditor(store); + } + + private static Box box(int x1, int y1, int z1, int x2, int y2, int z2) { + return new Box(new Vec(x1, y1, z1), new Vec(x2, y2, z2)); + } + + private void completeBox(UUID player, String id) { + editor.corner1(player, id, new Pos(10, 64, 10)); + editor.corner2(player, id, new Pos(14, 68, 11)); + editor.task(player, id, "Survival"); + } + + // --- drafts and explicit save --------------------------------------------------------- + + @Test + @DisplayName("A first corner alone reports the second corner and the task as missing") + void firstCornerReportsWhatIsMissing() { + PortalEditResult result = editor.corner1(ALICE, "survival", new Pos(1, 2, 3)); + + assertEquals(new Pending("survival", List.of(Missing.CORNER_2, Missing.TASK)), result, "pending lists the missing parts"); + assertTrue(store.portals().isEmpty(), "nothing is saved"); + } + + @Test + @DisplayName("A complete draft is reported as complete but not saved") + void completeDraftIsNotSavedWithoutSave() { + editor.corner1(ALICE, "survival", new Pos(10, 64, 10)); + editor.corner2(ALICE, "survival", new Pos(14, 68, 11)); + + PortalEditResult result = editor.task(ALICE, "survival", "Survival"); + + assertEquals(new Complete("survival"), result, "complete draft gets the save hint"); + assertEquals(0, store.saves(), "the store was never written"); + assertTrue(store.portals().isEmpty(), "the map stays unchanged"); + } + + @Test + @DisplayName("Save persists the complete draft") + void saveWritesThePortal() { + completeBox(ALICE, "survival"); + + PortalEditResult result = editor.save(ALICE, "survival"); + + Portal expected = new Portal("survival", box(10, 64, 10, 14, 68, 11), "Survival", null); + assertEquals(new Saved(expected), result, "a new id is reported as saved"); + assertEquals(List.of(expected), store.portals(), "the portal is in the store"); + assertTrue(editor.drafts(ALICE).isEmpty(), "the draft is gone after saving"); + } + + @Test + @DisplayName("Save replaces a portal with the same id in place without a duplicate") + void saveReplacesSameId() { + Portal first = new Portal("a", box(0, 0, 0, 1, 1, 1), "First", null); + Portal other = new Portal("b", box(5, 5, 5, 6, 6, 6), "Other", null); + store = new InMemoryPortalStore(first, other); + editor = new PortalEditor(store); + editor.task(ALICE, "a", "Changed"); + + PortalEditResult result = editor.save(ALICE, "a"); + + Portal replaced = new Portal("a", box(0, 0, 0, 1, 1, 1), "Changed", null); + assertEquals(new Updated(replaced), result, "an existing id is reported as updated"); + assertEquals(List.of(replaced, other), store.portals(), "replaced at the same position, no duplicate"); + } + + @Test + @DisplayName("Save without a task reports what is missing and keeps the draft") + void saveIncompleteKeepsDraft() { + editor.corner1(ALICE, "survival", new Pos(0, 0, 0)); + editor.corner2(ALICE, "survival", new Pos(1, 1, 1)); + + PortalEditResult result = editor.save(ALICE, "survival"); + + assertEquals(new Pending("survival", List.of(Missing.TASK)), result, "the missing task is named"); + assertEquals(1, editor.drafts(ALICE).size(), "the draft stays open"); + assertEquals(0, store.saves(), "nothing is written"); + } + + @Test + @DisplayName("A zero-length stored normal is rejected with the validator's reason") + void zeroNormalIsRejected() { + Portal broken = new Portal("ring", new Disc(new Vec(0, 70, 0), 3, Vec.ZERO), "Survival", null); + store = new InMemoryPortalStore(broken); + editor = new PortalEditor(store); + editor.permission(ALICE, "ring", "some.permission"); + + PortalEditResult result = editor.save(ALICE, "ring"); + + Rejected rejected = assertInstanceOf(Rejected.class, result, "the validator's problem blocks the save"); + assertEquals("ring", rejected.id(), "the rejected id is named"); + assertEquals(List.of("normal must not have length 0"), rejected.problems().stream().map(p -> p.reason()).toList(), "reason comes from the validator"); + assertEquals(List.of(broken), store.portals(), "the map is unchanged"); + assertEquals(1, editor.drafts(ALICE).size(), "the draft stays open"); + } + + @Test + @DisplayName("Problems of other portals do not block saving this one") + void otherPortalsProblemsDoNotBlock() { + Portal broken = new Portal("bad", box(5, 5, 5, 1, 1, 1), "Task", null); + store = new InMemoryPortalStore(broken); + editor = new PortalEditor(store); + completeBox(ALICE, "good"); + + PortalEditResult result = editor.save(ALICE, "good"); + + assertInstanceOf(Saved.class, result, "only the edited portal's problems count"); + assertEquals(2, store.portals().size(), "the new portal is appended after the broken one"); + } + + @Test + @DisplayName("Editing a saved portal stays a draft until save") + void editOfSavedPortalStaysDraft() { + Portal saved = new Portal("survival", box(0, 0, 0, 1, 1, 1), "Survival", null); + store = new InMemoryPortalStore(saved); + editor = new PortalEditor(store); + + PortalEditResult result = editor.permission(ALICE, "survival", "titan.portal.survival"); + + assertEquals(new Complete("survival"), result, "the prefilled draft is complete"); + assertEquals(List.of(saved), store.portals(), "the map still has the old permission"); + editor.save(ALICE, "survival"); + assertEquals("titan.portal.survival", store.portals().getFirst().permission(), "save writes the new permission"); + } + + @Test + @DisplayName("Cancel discards the draft and leaves the map alone") + void cancelDiscardsDraft() { + editor.corner1(ALICE, "survival", new Pos(0, 0, 0)); + + PortalEditResult result = editor.cancel(ALICE, "survival"); + + assertEquals(new Cancelled("survival"), result, "cancel is acknowledged"); + assertTrue(editor.drafts(ALICE).isEmpty(), "the draft is gone"); + assertEquals(0, store.saves(), "the map was not written"); + } + + @Test + @DisplayName("Cancel without a draft is unknown") + void cancelWithoutDraftIsUnknown() { + assertEquals(new Unknown("survival"), editor.cancel(ALICE, "survival"), "nothing to cancel"); + } + + @Test + @DisplayName("Cancel of a saved portal's draft keeps the saved portal") + void cancelKeepsSavedPortal() { + Portal saved = new Portal("survival", box(0, 0, 0, 1, 1, 1), "Survival", null); + store = new InMemoryPortalStore(saved); + editor = new PortalEditor(store); + editor.task(ALICE, "survival", "Changed"); + + editor.cancel(ALICE, "survival"); + + assertEquals(List.of(saved), store.portals(), "the saved portal is unchanged"); + } + + @Test + @DisplayName("Two players have separate drafts for the same id") + void draftsArePerPlayer() { + editor.corner1(ALICE, "survival", new Pos(0, 0, 0)); + editor.corner2(BOB, "survival", new Pos(9, 9, 9)); + + assertEquals(List.of(Missing.CORNER_2, Missing.TASK), editor.drafts(ALICE).getFirst().missing(), "Alice still lacks corner 2"); + assertEquals(List.of(Missing.CORNER_1, Missing.TASK), editor.drafts(BOB).getFirst().missing(), "Bob still lacks corner 1"); + assertInstanceOf(Pending.class, editor.save(ALICE, "survival"), "corners of two players never form a box"); + assertTrue(store.portals().isEmpty(), "nothing is saved"); + } + + @Test + @DisplayName("Discarding all drafts of a player leaves other players' drafts") + void discardAllIsPerPlayer() { + editor.corner1(ALICE, "a", new Pos(0, 0, 0)); + editor.corner1(BOB, "b", new Pos(0, 0, 0)); + + editor.discardAll(ALICE); + + assertTrue(editor.drafts(ALICE).isEmpty(), "Alice's drafts are gone"); + assertEquals(1, editor.drafts(BOB).size(), "Bob keeps his draft"); + } + + // --- ids ------------------------------------------------------------------------------ + + @Test + @DisplayName("Reserved ids are rejected") + void reservedIdsAreInvalid() { + for (String id : List.of("list", "show", "create")) { + assertInstanceOf(Invalid.class, editor.corner1(ALICE, id, new Pos(0, 0, 0)), "'" + id + "' is reserved"); + } + assertTrue(editor.drafts(ALICE).isEmpty(), "no draft is created for a rejected id"); + } + + @Test + @DisplayName("Ids outside [a-z0-9_-]+ are rejected") + void badIdsAreInvalid() { + for (String id : List.of("Survival!", "Survival", "with space", "", "a.b", "\u00e4")) { + assertInstanceOf(Invalid.class, editor.corner1(ALICE, id, new Pos(0, 0, 0)), "'" + id + "' does not match the pattern"); + assertInstanceOf(Invalid.class, editor.save(ALICE, id), "save checks the id as well"); + assertInstanceOf(Invalid.class, editor.remove(ALICE, id), "remove checks the id as well"); + } + } + + @Test + @DisplayName("Ids from lowercase letters, digits, dash and underscore are accepted") + void goodIdsAreAccepted() { + for (String id : List.of("survival", "elytra-ring", "a_b", "x1", "-", "_")) { + assertInstanceOf(Pending.class, editor.corner1(ALICE, id, new Pos(0, 0, 0)), "'" + id + "' is a valid id"); + } + } + + // --- box ------------------------------------------------------------------------------ + + @Test + @DisplayName("Two corners in any order form the inclusive span") + void cornersFormInclusiveSpanFromAnyOrder() { + editor.corner1(ALICE, "survival", new Pos(14, 68, 11)); + editor.corner2(ALICE, "survival", new Pos(10, 64, 10)); + editor.task(ALICE, "survival", "Survival"); + + editor.save(ALICE, "survival"); + + assertEquals(box(10, 64, 10, 14, 68, 11), store.portals().getFirst().shape(), "min and max are picked per axis"); + } + + @Test + @DisplayName("Mixed corner ordering per axis is sorted per axis") + void cornersAreSortedPerAxis() { + editor.corner1(ALICE, "p", new Pos(10, 68, 10)); + editor.corner2(ALICE, "p", new Pos(14, 64, 9)); + editor.task(ALICE, "p", "T"); + + editor.save(ALICE, "p"); + + assertEquals(box(10, 64, 9, 14, 68, 10), store.portals().getFirst().shape(), "each axis is ordered independently"); + } + + @Test + @DisplayName("Corners use the block position, rounded down") + void cornersFloorThePosition() { + editor.corner1(ALICE, "p", new Pos(-0.5, 64.9, 3.99)); + editor.corner2(ALICE, "p", new Pos(-0.5, 64.9, 3.99)); + editor.task(ALICE, "p", "T"); + + editor.save(ALICE, "p"); + + assertEquals(box(-1, 64, 3, -1, 64, 3), store.portals().getFirst().shape(), "a single block box from the floored position"); + } + + @Test + @DisplayName("A new corner replaces the earlier one") + void newCornerReplacesOld() { + editor.corner1(ALICE, "p", new Pos(0, 0, 0)); + editor.corner1(ALICE, "p", new Pos(5, 5, 5)); + editor.corner2(ALICE, "p", new Pos(6, 6, 6)); + editor.task(ALICE, "p", "T"); + + editor.save(ALICE, "p"); + + assertEquals(box(5, 5, 5, 6, 6, 6), store.portals().getFirst().shape(), "the last corner 1 counts"); + } + + @Test + @DisplayName("A corner turns a ring draft into a box draft but keeps the saved ring until complete") + void ringBecomesBox() { + Portal ring = new Portal("p", new Disc(new Vec(0, 70, 0), 3, new Vec(0, 0, 1)), "T", null); + store = new InMemoryPortalStore(ring); + editor = new PortalEditor(store); + + PortalEditResult result = editor.corner1(ALICE, "p", new Pos(1, 2, 3)); + + assertEquals(new Pending("p", List.of(Missing.CORNER_2)), result, "the box still needs its second corner"); + assertEquals(List.of(ring), store.portals(), "the saved ring stays until the box is saved"); + editor.corner2(ALICE, "p", new Pos(4, 5, 6)); + editor.save(ALICE, "p"); + assertEquals(box(1, 2, 3, 4, 5, 6), store.portals().getFirst().shape(), "the portal is now a box"); + } + + // --- ring ----------------------------------------------------------------------------- + + @Test + @DisplayName("disc uses the rounded eye position and the snapped normal") + void discUsesEyeAndLook() { + editor.disc(ALICE, "elytra-ring", new Pos(0.52, 72.0, 40.47), LOOK_Z, 5.5); + editor.task(ALICE, "elytra-ring", "Elytra"); + + editor.save(ALICE, "elytra-ring"); + + assertEquals(new Disc(new Vec(0.5, 72.0, 40.5), 5.5, new Vec(0, 0, 1)), store.portals().getFirst().shape(), "centre, radius and normal follow the placement rules"); + } + + @Test + @DisplayName("disc replaces a box and drops its corners") + void discReplacesBox() { + editor.corner1(ALICE, "p", new Pos(0, 0, 0)); + + PortalEditResult result = editor.disc(ALICE, "p", new Pos(0, 70, 0), LOOK_Z, 2); + + assertEquals(new Pending("p", List.of(Missing.TASK)), result, "only the task is missing now"); + assertNull(editor.drafts(ALICE).getFirst().corner1(), "the corner is dropped"); + } + + @Test + @DisplayName("centre without a radius leaves the radius missing") + void centreWithoutRadiusIsPending() { + PortalEditResult result = editor.centre(ALICE, "ring", new Pos(0, 70, 0), LOOK_Z); + + assertEquals(new Pending("ring", List.of(Missing.RADIUS, Missing.TASK)), result, "radius and task are missing"); + } + + @Test + @DisplayName("radius changes only the radius, not centre or normal") + void radiusChangesOnlyRadius() { + editor.centre(ALICE, "ring", new Pos(0.5, 72, 40.5), LOOK_Z); + editor.radius(ALICE, "ring", 3); + + editor.radius(ALICE, "ring", 8); + + PortalDraft draft = editor.drafts(ALICE).getFirst(); + assertEquals(8.0, draft.radius(), "the new radius"); + assertEquals(new Vec(0.5, 72, 40.5), draft.centre(), "centre is untouched"); + assertEquals(new Vec(0, 0, 1), draft.normal(), "normal is untouched"); + } + + @Test + @DisplayName("radius on a ring without a centre asks for the centre") + void radiusFirstAsksForCentre() { + PortalEditResult result = editor.radius(ALICE, "ring", 4); + + assertEquals(new Pending("ring", List.of(Missing.CENTRE, Missing.TASK)), result, "the centre is still missing"); + } + + @Test + @DisplayName("radius on a box draft is refused") + void radiusOnBoxIsInvalid() { + editor.corner1(ALICE, "p", new Pos(0, 0, 0)); + + assertInstanceOf(Invalid.class, editor.radius(ALICE, "p", 3), "a box has no radius"); + } + + @Test + @DisplayName("Radius zero, negative or not finite is refused and changes nothing") + void invalidRadiusIsRefused() { + editor.centre(ALICE, "ring", new Pos(0, 70, 0), LOOK_Z); + editor.radius(ALICE, "ring", 3); + + for (double radius : new double[]{0, -2, Double.NaN, Double.POSITIVE_INFINITY}) { + assertInstanceOf(Invalid.class, editor.radius(ALICE, "ring", radius), "radius " + radius + " via radius"); + assertInstanceOf(Invalid.class, editor.disc(ALICE, "ring", new Pos(9, 9, 9), LOOK_Z, radius), "radius " + radius + " via disc"); + } + assertEquals(3.0, editor.drafts(ALICE).getFirst().radius(), "the draft keeps the old radius"); + assertEquals(new Vec(0, 70, 0), editor.drafts(ALICE).getFirst().centre(), "a refused disc does not move the centre"); + } + + @Test + @DisplayName("shape switches the form and drops what belongs to the other form") + void shapeSwitchesForm() { + editor.corner1(ALICE, "p", new Pos(0, 0, 0)); + + PortalEditResult result = editor.shape(ALICE, "p", PortalDraft.Form.RING); + + assertEquals(new Pending("p", List.of(Missing.CENTRE, Missing.RADIUS, Missing.TASK)), result, "a fresh ring draft"); + assertNull(editor.drafts(ALICE).getFirst().corner1(), "the corner is dropped"); + } + + @Test + @DisplayName("shape of the current form keeps the draft") + void sameShapeKeepsDraft() { + editor.corner1(ALICE, "p", new Pos(0, 0, 0)); + + editor.shape(ALICE, "p", PortalDraft.Form.BOX); + + assertEquals(new Vec(0, 0, 0), editor.drafts(ALICE).getFirst().corner1(), "the corner survives choosing box again"); + } + + @Test + @DisplayName("A draft without form and task lists both as missing") + void permissionOnUnknownIdCreatesIncompleteDraft() { + PortalEditResult result = editor.permission(ALICE, "new-one", "titan.x"); + + assertEquals(new Pending("new-one", List.of(Missing.FORM, Missing.TASK)), result, "form and task are missing"); + assertEquals(1, editor.drafts(ALICE).size(), "a draft was created"); + assertInstanceOf(Pending.class, editor.save(ALICE, "new-one"), "it cannot be saved"); + } + + // --- task and permission -------------------------------------------------------------- + + @Test + @DisplayName("Task is trimmed and stored") + void taskIsTrimmed() { + completeBox(ALICE, "p"); + editor.task(ALICE, "p", " Creative Lobby "); + + editor.save(ALICE, "p"); + + assertEquals("Creative Lobby", store.portals().getFirst().task(), "surrounding blanks are removed"); + } + + @Test + @DisplayName("A blank task is refused and the old task stays") + void blankTaskIsRefused() { + completeBox(ALICE, "p"); + + for (String task : List.of("", " ", "\t")) { + assertInstanceOf(Invalid.class, editor.task(ALICE, "p", task), "blank task '" + task + "' is refused"); + } + assertEquals("Survival", editor.drafts(ALICE).getFirst().task(), "the previous task is kept"); + } + + @Test + @DisplayName("Permission none removes the permission") + void permissionNoneRemoves() { + Portal saved = new Portal("survival", box(0, 0, 0, 1, 1, 1), "Survival", "titan.portal.survival"); + store = new InMemoryPortalStore(saved); + editor = new PortalEditor(store); + + editor.permission(ALICE, "survival", "none"); + editor.save(ALICE, "survival"); + + assertNull(store.portals().getFirst().permission(), "the portal is open for everyone"); + } + + @Test + @DisplayName("A blank permission is refused") + void blankPermissionIsRefused() { + assertInstanceOf(Invalid.class, editor.permission(ALICE, "p", " "), "blank permission is refused"); + assertTrue(editor.drafts(ALICE).isEmpty(), "a refused input creates no draft"); + } + + // --- remove --------------------------------------------------------------------------- + + @Test + @DisplayName("Remove deletes the saved portal and the player's draft") + void removeDeletesSavedPortal() { + Portal keep = new Portal("keep", box(0, 0, 0, 1, 1, 1), "K", null); + Portal drop = new Portal("drop", box(2, 2, 2, 3, 3, 3), "D", null); + store = new InMemoryPortalStore(keep, drop); + editor = new PortalEditor(store); + editor.task(ALICE, "drop", "Changed"); + + PortalEditResult result = editor.remove(ALICE, "drop"); + + assertEquals(new Removed("drop"), result, "removal is acknowledged"); + assertEquals(List.of(keep), store.portals(), "only the other portal remains"); + assertTrue(editor.drafts(ALICE).isEmpty(), "the draft is discarded too"); + } + + @Test + @DisplayName("Remove of a draft-only id discards the draft without writing the map") + void removeDraftOnly() { + editor.corner1(ALICE, "p", new Pos(0, 0, 0)); + + PortalEditResult result = editor.remove(ALICE, "p"); + + assertEquals(new Removed("p"), result, "the draft counts as removed"); + assertEquals(0, store.saves(), "the map was not written"); + } + + @Test + @DisplayName("Remove of an unknown id changes nothing") + void removeUnknown() { + assertEquals(new Unknown("nope"), editor.remove(ALICE, "nope"), "unknown id is reported"); + assertEquals(0, store.saves(), "the map was not written"); + } + + @Test + @DisplayName("Remove keeps another player's draft") + void removeKeepsOtherPlayersDraft() { + editor.corner1(BOB, "p", new Pos(0, 0, 0)); + + editor.remove(ALICE, "p"); + + assertFalse(editor.drafts(BOB).isEmpty(), "Bob's draft is not Alice's to remove"); + } +} From 2cb866d30e5fb44db9f6907a21c28521711fa4da Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:51:08 +0200 Subject: [PATCH 05/17] test(setup): keep the portal editor in step with the lobby validator Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../PortalEditorValidatorParityTest.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorValidatorParityTest.java diff --git a/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorValidatorParityTest.java b/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorValidatorParityTest.java new file mode 100644 index 00000000..699878bb --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorValidatorParityTest.java @@ -0,0 +1,102 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.portal.PortalValidator; +import net.onelitefeather.titan.setup.portal.PortalEditResult.Rejected; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps the editor and the lobby's start-up check in step: the editor has no rule copy, so what it + * accepts must pass {@link PortalValidator#requireValid} and what it rejects must be the same + * problem the validator reports. + */ +class PortalEditorValidatorParityTest { + + private static final UUID PLAYER = new UUID(0, 1); + private static final Vec LOOK = new Vec(0.3, -0.2, 0.9); + + @Test + @DisplayName("Every portal the editor accepts passes requireValid") + void acceptedPortalsPassRequireValid() { + InMemoryPortalStore store = new InMemoryPortalStore(); + PortalEditor editor = new PortalEditor(store); + editor.corner1(PLAYER, "box-a", new Pos(14, 68, 11)); + editor.corner2(PLAYER, "box-a", new Pos(10, 64, 10)); + editor.task(PLAYER, "box-a", "Survival"); + editor.disc(PLAYER, "ring-a", new Pos(0.52, 72.0, 40.47), LOOK, 5.5); + editor.task(PLAYER, "ring-a", "Elytra"); + editor.permission(PLAYER, "ring-a", "titan.portal.elytra"); + editor.centre(PLAYER, "ring-b", new Pos(-3.3, 65, 7.7), new Vec(0, 1, 0)); + editor.radius(PLAYER, "ring-b", 0.5); + editor.task(PLAYER, "ring-b", "Creative"); + + for (String id : List.of("box-a", "ring-a", "ring-b")) { + assertTrue(editor.save(PLAYER, id) instanceof PortalEditResult.Saved, id + " is accepted"); + } + + assertEquals(3, store.portals().size(), "all three portals were written"); + assertDoesNotThrow(() -> PortalValidator.requireValid("test-world", store.portals()), "the lobby accepts what the editor saved"); + } + + @Test + @DisplayName("A blank stored task is rejected by the editor and by requireValid with the same reason") + void blankTaskIsRejectedByBoth() { + List invalid = List.of(new Portal("a", new Box(new Vec(0, 0, 0), new Vec(1, 1, 1)), " ", null)); + InMemoryPortalStore store = new InMemoryPortalStore(invalid.toArray(Portal[]::new)); + PortalEditor editor = new PortalEditor(store); + editor.permission(PLAYER, "a", "titan.x"); + + PortalEditResult result = editor.save(PLAYER, "a"); + + Rejected rejected = assertInstanceOf(Rejected.class, result, "the editor rejects the save"); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> PortalValidator.requireValid("test-world", invalid), "the lobby rejects the list"); + String reason = rejected.problems().getFirst().reason(); + assertEquals("task must not be blank", reason, "the editor reports the validator's reason"); + assertTrue(failure.getMessage().contains(reason), "requireValid names the same reason: " + failure.getMessage()); + assertEquals(invalid, store.portals(), "the map is unchanged"); + } + + @Test + @DisplayName("A stored zero normal is rejected by the editor and by requireValid with the same reason") + void zeroNormalIsRejectedByBoth() { + List invalid = List.of(new Portal("ring", new Disc(new Vec(0, 70, 0), 3, Vec.ZERO), "Survival", null)); + InMemoryPortalStore store = new InMemoryPortalStore(invalid.toArray(Portal[]::new)); + PortalEditor editor = new PortalEditor(store); + editor.permission(PLAYER, "ring", "titan.x"); + + PortalEditResult result = editor.save(PLAYER, "ring"); + + Rejected rejected = assertInstanceOf(Rejected.class, result, "the editor rejects the save"); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> PortalValidator.requireValid("test-world", invalid), "the lobby rejects the list"); + assertTrue(failure.getMessage().contains(rejected.problems().getFirst().reason()), "requireValid names the editor's reason: " + failure.getMessage()); + } +} From 0e9cfe711daaf8e88a11f7f7bcb9fc89a1ce5b89 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:51:18 +0200 Subject: [PATCH 06/17] feat(setup): add pure outline points for saved and draft portals Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/setup/portal/DraftOutline.java | 76 +++++++++ .../titan/setup/portal/PortalOutline.java | 118 +++++++++++++ .../titan/setup/portal/DraftOutlineTest.java | 112 +++++++++++++ .../titan/setup/portal/PortalOutlineTest.java | 156 ++++++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/DraftOutline.java create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalOutline.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/DraftOutlineTest.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalOutlineTest.java diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/DraftOutline.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/DraftOutline.java new file mode 100644 index 00000000..7f64c8da --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/DraftOutline.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.setup.portal; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Disc; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The live preview points for a portal draft: corners, the box they span and the ring. Pure + * geometry over plain values, so it does not depend on the draft type. + */ +public final class DraftOutline { + + /** Upper bound over the whole preview, markers and shape together. */ + public static final int MAX_POINTS = PortalOutline.MAX_POINTS; + + /** Ring radius shown while none has been chosen yet. */ + public static final double DEFAULT_RADIUS = 3; + + /** Radius suggestions offered by the guided flow. */ + public static final List RADIUS_SUGGESTIONS = List.of(2.0, 3.0, 5.0, 8.0); + + private DraftOutline() { + } + + /** + * Preview of a box draft. Set corners are marked at their block centres; with one corner the + * box runs from it to {@code block}, the player's current block, with two it spans both. + */ + public static @NotNull List box(@NotNull List corners, @NotNull Point block) { + if (corners.isEmpty()) { + return List.of(); + } + List points = new ArrayList<>(); + for (Point corner : corners) { + points.add(new Vec(corner.blockX() + 0.5, corner.blockY() + 0.5, corner.blockZ() + 0.5)); + } + Point first = corners.getFirst(); + Point second = corners.size() > 1 ? corners.get(1) : block; + Vec lo = new Vec(Math.min(first.blockX(), second.blockX()), Math.min(first.blockY(), second.blockY()), Math.min(first.blockZ(), second.blockZ())); + Vec hi = new Vec(Math.max(first.blockX(), second.blockX()) + 1, Math.max(first.blockY(), second.blockY()) + 1, Math.max(first.blockZ(), second.blockZ()) + 1); + points.addAll(PortalOutline.boxEdges(lo, hi, MAX_POINTS - points.size())); + return List.copyOf(points); + } + + /** + * Preview of a ring draft. Without a disc (radius not chosen yet) it is a circle of + * {@link #DEFAULT_RADIUS} around the eye, facing along {@code look}. + */ + public static @NotNull List ring(@Nullable Disc disc, @NotNull Pos eye, @NotNull Vec look) { + if (disc == null) { + return PortalOutline.circle(eye.asVec(), DEFAULT_RADIUS, look, MAX_POINTS); + } + return PortalOutline.circle(disc.center(), disc.radius(), disc.normal(), MAX_POINTS); + } +} diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalOutline.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalOutline.java new file mode 100644 index 00000000..56bd5850 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalOutline.java @@ -0,0 +1,118 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.PortalShape; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * The particle points that trace a saved portal shape for {@code show}. Pure geometry, so it is + * testable without a server. + */ +public final class PortalOutline { + + /** Upper bound per outline; more particles per tick would only lag the client. */ + public static final int MAX_POINTS = 256; + + /** Wanted distance between neighbouring points, in blocks. */ + private static final double STEP = 0.5; + + /** A ring never gets fewer points than this, however small it is. */ + private static final int MIN_RING_POINTS = 16; + + private PortalOutline() { + } + + /** The points along the shape's edges (box) or rim (disc); at most {@link #MAX_POINTS}. */ + public static @NotNull List points(@NotNull PortalShape shape) { + return switch (shape) { + case Box box -> boxEdges(box.min(), box.max().add(1, 1, 1), MAX_POINTS); + case Disc disc -> circle(disc.center(), disc.radius(), disc.normal(), MAX_POINTS); + }; + } + + /** + * Points on the twelve edges of the box {@code [lo, hi]} in world coordinates, the eight + * corners included. When the budget is too small the spacing grows instead of points being + * dropped, so the outline stays a complete box. + */ + static @NotNull List boxEdges(@NotNull Vec lo, @NotNull Vec hi, int budget) { + double[] origin = {lo.x(), lo.y(), lo.z()}; + double[] length = {hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z()}; + double step = STEP; + while (total(length, step) > budget) { + step *= 1.05; + } + List points = new ArrayList<>(total(length, step)); + for (int corner = 0; corner < 8; corner++) { + points.add(new Vec(origin[0] + (corner & 1) * length[0], origin[1] + (corner >> 1 & 1) * length[1], origin[2] + (corner >> 2 & 1) * length[2])); + } + // The corners are in already; each edge adds only the points strictly between them. + for (int axis = 0; axis < 3; axis++) { + int segments = segments(length[axis], step); + for (int side = 0; side < 4; side++) { + for (int i = 1; i < segments; i++) { + double[] at = origin.clone(); + at[axis] += length[axis] * i / segments; + at[(axis + 1) % 3] += (side & 1) * length[(axis + 1) % 3]; + at[(axis + 2) % 3] += (side >> 1) * length[(axis + 2) % 3]; + points.add(new Vec(at[0], at[1], at[2])); + } + } + } + return List.copyOf(points); + } + + /** + * Points on the circle around {@code centre} in the plane perpendicular to {@code normal}. A + * non-positive radius or a zero normal has no circle and yields no points. + */ + static @NotNull List circle(@NotNull Vec centre, double radius, @NotNull Vec normal, int budget) { + if (!(radius > 0) || !(normal.length() > 0)) { + return List.of(); + } + Vec n = normal.normalize(); + // Cross with an axis the normal is not close to, else the cross product degenerates. + Vec helper = Math.abs(n.y()) < 0.9 ? new Vec(0, 1, 0) : new Vec(1, 0, 0); + Vec u = n.cross(helper).normalize(); + Vec v = n.cross(u); + int count = Math.min(budget, Math.max(MIN_RING_POINTS, (int) Math.ceil(2 * Math.PI * radius / STEP))); + List points = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + double angle = 2 * Math.PI * i / count; + points.add(centre.add(u.mul(radius * Math.cos(angle))).add(v.mul(radius * Math.sin(angle)))); + } + return List.copyOf(points); + } + + private static int segments(double length, double step) { + return Math.max(1, (int) Math.ceil(length / step)); + } + + private static int total(double[] length, double step) { + int between = 0; + for (double l : length) { + between += segments(l, step) - 1; + } + return 8 + 4 * between; + } +} diff --git a/setup/src/test/java/net/onelitefeather/titan/setup/portal/DraftOutlineTest.java b/setup/src/test/java/net/onelitefeather/titan/setup/portal/DraftOutlineTest.java new file mode 100644 index 00000000..85cb9d3c --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/titan/setup/portal/DraftOutlineTest.java @@ -0,0 +1,112 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Disc; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DraftOutlineTest { + + private static final double EPSILON = 1e-9; + + @Test + void constantsArePinned() { + assertEquals(256, DraftOutline.MAX_POINTS, "the preview cap is a documented contract"); + assertEquals(3.0, DraftOutline.DEFAULT_RADIUS, "default ring radius"); + assertEquals(List.of(2.0, 3.0, 5.0, 8.0), DraftOutline.RADIUS_SUGGESTIONS, "radius suggestions"); + } + + @Test + void boxWithoutCornersHasNoPoints() { + assertTrue(DraftOutline.box(List.of(), new Vec(1, 2, 3)).isEmpty(), "nothing set, nothing to show"); + } + + @Test + void cornerIsMarkedAtItsBlockCentre() { + List points = DraftOutline.box(List.of(new Vec(4, 65, -2)), new Vec(4, 65, -2)); + + assertTrue(points.contains(new Vec(4.5, 65.5, -1.5)), "block centre marker"); + } + + @Test + void singleCornerBoxRunsFromCornerToCurrentBlock() { + List points = DraftOutline.box(List.of(new Vec(0, 0, 0)), new Vec(3, 1, 2)); + + assertTrue(points.contains(new Vec(4, 2, 3)), "far corner is block + 1"); + assertTrue(points.contains(Vec.ZERO), "near corner is the set corner"); + assertTrue(points.contains(new Vec(0.5, 0.5, 0.5)), "marker of the set corner"); + } + + @Test + void twoCornerBoxIgnoresOrderAndCurrentBlock() { + List a = DraftOutline.box(List.of(new Vec(0, 0, 0), new Vec(3, 1, 2)), new Vec(9, 9, 9)); + List b = DraftOutline.box(List.of(new Vec(3, 1, 2), new Vec(0, 0, 0)), new Vec(9, 9, 9)); + + assertEquals(Set.copyOf(a), Set.copyOf(b), "same two corners, same box"); + assertTrue(a.contains(new Vec(4, 2, 3)), "box spans both corners"); + assertFalse(a.contains(new Vec(10, 10, 10)), "the current block is ignored once both corners are set"); + } + + @Test + void hugeBoxRespectsCapAcrossMarkersAndKeepsCorners() { + List points = DraftOutline.box(List.of(new Vec(0, 0, 0), new Vec(999, 999, 999)), Vec.ZERO); + + assertTrue(points.size() <= DraftOutline.MAX_POINTS, "cap exceeded: " + points.size()); + assertTrue(points.contains(new Vec(1000, 1000, 1000)), "far corner kept"); + assertTrue(points.contains(new Vec(0.5, 0.5, 0.5)), "marker kept"); + } + + @Test + void ringWithDiscUsesItsCentreRadiusAndNormal() { + List points = DraftOutline.ring(new Disc(new Vec(1, 2, 3), 5, new Vec(0, 0, 1)), new Pos(100, 100, 100), new Vec(1, 0, 0)); + + assertRing(points, new Vec(1, 2, 3), 5, new Vec(0, 0, 1)); + } + + @Test + void ringWithoutRadiusUsesDefaultRadiusAroundEyeFacingLook() { + for (Vec look : List.of(new Vec(0, 0, 1), new Vec(0, 1, 0), new Vec(1, 1, 1), new Vec(0.2, -0.9, 0.1))) { + List points = DraftOutline.ring(null, new Pos(5, 70, 5), look); + + assertRing(points, new Vec(5, 70, 5), 3, look); + } + } + + @Test + void hugeRingRespectsCap() { + List points = DraftOutline.ring(new Disc(Vec.ZERO, 5000, new Vec(0, 1, 0)), Pos.ZERO, new Vec(0, 0, 1)); + + assertEquals(DraftOutline.MAX_POINTS, points.size(), "cap applies to ring previews"); + } + + private static void assertRing(List points, Vec centre, double radius, Vec normal) { + Vec unit = normal.normalize(); + assertTrue(points.size() >= 16, "too few ring points"); + for (Vec p : points) { + assertEquals(radius, p.distance(centre), EPSILON, "distance from centre"); + assertEquals(0.0, p.sub(centre).dot(unit), EPSILON, "not perpendicular to the normal"); + } + } +} diff --git a/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalOutlineTest.java b/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalOutlineTest.java new file mode 100644 index 00000000..e8858ba3 --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalOutlineTest.java @@ -0,0 +1,156 @@ +/** + * 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.setup.portal; + +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PortalOutlineTest { + + private static final double EPSILON = 1e-9; + + @Test + void capIsPinned() { + assertEquals(256, PortalOutline.MAX_POINTS, "the per-portal cap is a documented contract"); + } + + @Test + void unitBlockBoxHasCornersAndEdgeMidpoints() { + List points = PortalOutline.points(new Box(new Vec(0, 0, 0), new Vec(0, 0, 0))); + + // 8 corners + 12 edges with one midpoint each. + assertEquals(20, points.size(), "unit block outline"); + assertEquals(20, new HashSet<>(points).size(), "no point is emitted twice"); + } + + @Test + void boxPointsLieOnlyOnEdgesOfMinToMaxPlusOne() { + List points = PortalOutline.points(new Box(new Vec(2, 3, 4), new Vec(5, 4, 8))); + + for (Vec p : points) { + assertTrue(onEdge(p, new Vec(2, 3, 4), new Vec(6, 5, 9)), "point " + p + " is not on a box edge"); + } + } + + @Test + void boxContainsAllEightCorners() { + List points = PortalOutline.points(new Box(new Vec(2, 3, 4), new Vec(5, 4, 8))); + + for (double x : new double[]{2, 6}) { + for (double y : new double[]{3, 5}) { + for (double z : new double[]{4, 9}) { + assertTrue(points.contains(new Vec(x, y, z)), "missing corner " + x + "," + y + "," + z); + } + } + } + } + + @Test + void boxEdgeSpacingIsAtMostHalfABlock() { + List points = PortalOutline.points(new Box(new Vec(0, 0, 0), new Vec(2, 0, 0))); + + List xs = points.stream().filter(p -> p.y() == 0 && p.z() == 0).map(Vec::x).sorted().toList(); + for (int i = 1; i < xs.size(); i++) { + assertTrue(xs.get(i) - xs.get(i - 1) <= 0.5 + EPSILON, "gap too large at " + xs.get(i)); + } + assertEquals(0.0, xs.getFirst(), EPSILON, "edge starts at min"); + assertEquals(3.0, xs.getLast(), EPSILON, "edge ends at max + 1"); + } + + @Test + void hugeBoxRespectsCapAndKeepsCorners() { + List points = PortalOutline.points(new Box(new Vec(0, 0, 0), new Vec(999, 999, 999))); + + assertTrue(points.size() <= PortalOutline.MAX_POINTS, "cap exceeded: " + points.size()); + assertTrue(points.contains(new Vec(1000, 1000, 1000)), "the box stays complete, far corner kept"); + assertTrue(points.contains(Vec.ZERO), "the box stays complete, near corner kept"); + } + + @Test + void ringHasAtLeastSixteenPointsEvenWhenTiny() { + List points = PortalOutline.points(new Disc(Vec.ZERO, 0.1, new Vec(0, 1, 0))); + + assertEquals(16, points.size(), "minimum ring resolution"); + } + + @Test + void ringPointCountFollowsCircumference() { + List points = PortalOutline.points(new Disc(Vec.ZERO, 4, new Vec(0, 1, 0))); + + assertEquals((int) Math.ceil(2 * Math.PI * 4 / 0.5), points.size(), "one point per half block of rim"); + } + + @Test + void hugeRingRespectsCap() { + List points = PortalOutline.points(new Disc(Vec.ZERO, 500, new Vec(1, 1, 1))); + + assertEquals(PortalOutline.MAX_POINTS, points.size(), "cap applies to rings"); + } + + @Test + void ringPointsAreAtRadiusAndPerpendicularForAxisNormals() { + for (Vec normal : List.of(new Vec(1, 0, 0), new Vec(0, 1, 0), new Vec(0, 0, 1), new Vec(0, -1, 0))) { + assertRing(new Vec(10, 64, -3), 5, normal); + } + } + + @Test + void ringPointsAreAtRadiusAndPerpendicularForTiltedAndDiagonalNormals() { + assertRing(new Vec(0.5, 70, 0.5), 3, new Vec(1, 1, 1)); + assertRing(Vec.ZERO, 2.5, new Vec(0.01, 1, 0.02)); + assertRing(Vec.ZERO, 2.5, new Vec(0, 3, 0)); + } + + @Test + void ringWithUnusableNormalOrRadiusHasNoPoints() { + assertTrue(PortalOutline.points(new Disc(Vec.ZERO, 3, Vec.ZERO)).isEmpty(), "zero normal"); + assertTrue(PortalOutline.points(new Disc(Vec.ZERO, 0, new Vec(0, 1, 0))).isEmpty(), "zero radius"); + } + + private static void assertRing(Vec centre, double radius, Vec normal) { + List points = PortalOutline.points(new Disc(centre, radius, normal)); + Vec unit = normal.normalize(); + assertTrue(points.size() >= 16, "too few points for normal " + normal); + for (Vec p : points) { + assertEquals(radius, p.distance(centre), EPSILON, "distance from centre for normal " + normal); + assertEquals(0.0, p.sub(centre).dot(unit), EPSILON, "not in the plane for normal " + normal); + } + } + + private static boolean onEdge(Vec p, Vec lo, Vec hi) { + double[] v = {p.x(), p.y(), p.z()}; + double[] l = {lo.x(), lo.y(), lo.z()}; + double[] h = {hi.x(), hi.y(), hi.z()}; + int atBound = 0; + for (int axis = 0; axis < 3; axis++) { + if (v[axis] < l[axis] - EPSILON || v[axis] > h[axis] + EPSILON) { + return false; + } + if (Math.abs(v[axis] - l[axis]) < EPSILON || Math.abs(v[axis] - h[axis]) < EPSILON) { + atBound++; + } + } + return atBound >= 2; + } +} From 2ab250122a7687d4356e74e6e21286ce7456278b Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 19:52:25 +0200 Subject: [PATCH 07/17] feat(setup): add portal chat messages and editor logging Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/setup/portal/PortalEditor.java | 6 + .../titan/setup/portal/PortalMessages.java | 154 +++++++++++++ .../setup/portal/PortalEditorLoggingTest.java | 101 +++++++++ .../setup/portal/PortalMessagesTest.java | 209 ++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalMessages.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalEditorLoggingTest.java create mode 100644 setup/src/test/java/net/onelitefeather/titan/setup/portal/PortalMessagesTest.java diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java index 2e423cf8..5ec7458b 100644 --- a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalEditor.java @@ -30,6 +30,9 @@ import net.onelitefeather.titan.setup.portal.PortalEditResult.Unknown; import net.onelitefeather.titan.setup.portal.PortalEditResult.Updated; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -49,6 +52,7 @@ */ public final class PortalEditor { + private static final Logger LOGGER = LoggerFactory.getLogger(PortalEditor.class); private static final Pattern ID_PATTERN = Pattern.compile("[a-z0-9_-]+"); /** Words the command uses in the id position. */ private static final Set RESERVED_IDS = Set.of("list", "show", "create"); @@ -147,6 +151,7 @@ public PortalEditResult save(UUID player, String id) { return new Rejected(id, problems); } store.save(next); + LOGGER.info("Saved portal {} in world {}", id, store.world()); dropDraft(player, id); return edited < saved.size() ? new Updated(portal.get()) : new Saved(portal.get()); } @@ -170,6 +175,7 @@ public PortalEditResult remove(UUID player, String id) { boolean wasSaved = indexOf(saved, id) >= 0; if (wasSaved) { store.save(saved.stream().filter(portal -> !id.equals(portal.id())).toList()); + LOGGER.info("Removed portal {} from world {}", id, store.world()); } boolean hadDraft = dropDraft(player, id); return wasSaved || hadDraft ? new Removed(id) : new Unknown(id); diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalMessages.java b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalMessages.java new file mode 100644 index 00000000..d0123dae --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/titan/setup/portal/PortalMessages.java @@ -0,0 +1,154 @@ +/** + * 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.setup.portal; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.portal.PortalProblem; +import net.onelitefeather.titan.core.portal.PortalShape; +import net.minestom.server.coordinate.Vec; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Chat text of the portal editor, in the style of {@code MapCommand}: English MiniMessage with + * {@code }, green for success, red for errors. Text a player typed (task, permission) or + * that came from a map file goes in as {@code unparsed}, so tags in it are never evaluated. + */ +public final class PortalMessages { + + private static final MiniMessage MINI = MiniMessage.miniMessage(); + + private PortalMessages() { + } + + public static Component render(PortalEditResult result) { + return switch (result) { + case PortalEditResult.Saved saved -> saved("Saved", saved.portal()); + case PortalEditResult.Updated updated -> saved("Updated", updated.portal()); + case PortalEditResult.Pending pending -> + MINI.deserialize(" Portal is not complete yet, missing: .", Placeholder.unparsed("id", pending.id()), Placeholder.unparsed("missing", describeMissing(pending.missing()))); + case PortalEditResult.Complete complete -> complete(complete.id()); + case PortalEditResult.Removed removed -> + MINI.deserialize(" Removed portal .", Placeholder.unparsed("id", removed.id())); + case PortalEditResult.Rejected rejected -> rejected(rejected); + case PortalEditResult.Invalid invalid -> + MINI.deserialize(" ", Placeholder.unparsed("reason", capitalised(invalid.reason()))); + case PortalEditResult.Cancelled cancelled -> + MINI.deserialize(" Discarded the draft of portal .", Placeholder.unparsed("id", cancelled.id())); + case PortalEditResult.Unknown unknown -> + MINI.deserialize(" There is no portal or draft .", Placeholder.unparsed("id", unknown.id())); + }; + } + + /** + * Saved portals first, then, separated, the player's open drafts with what each still needs. + */ + public static Component list(List portals, List drafts) { + if (portals.isEmpty() && drafts.isEmpty()) { + return MINI.deserialize(" There are no portals and no drafts."); + } + List lines = new ArrayList<>(); + if (!portals.isEmpty()) { + lines.add(MINI.deserialize(" Portals ():", Placeholder.unparsed("count", String.valueOf(portals.size())))); + for (Portal portal : portals) { + lines.add(MINI.deserialize(" - : , task , permission ", portalTags(portal))); + } + } + if (!drafts.isEmpty()) { + lines.add(MINI.deserialize(" Drafts ():", Placeholder.unparsed("count", String.valueOf(drafts.size())))); + for (PortalDraft draft : drafts) { + String state = draft.complete() ? "complete, not saved" : "missing " + describeMissing(draft.missing()); + lines.add(MINI.deserialize(" - : ", Placeholder.unparsed("id", draft.id()), Placeholder.unparsed("state", state))); + } + } + return Component.join(JoinConfiguration.newlines(), lines); + } + + /** {@code portal 'id': reason}, the validator's wording for one problem. */ + public static Component problem(PortalProblem problem) { + return MINI.deserialize(" -