diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..45c4568 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,42 @@ +## Summary + + + +## Issue closure + + + +## Verification + + + +## Checklist + + + +- [ ] Targets `master` +- [ ] Line endings preserved per file (`file ` before and after; this tree MIXES conventions -- pre-existing tracked files such as `README.md`, `pom.xml` and `src/main/resources/plugin.yml` are CRLF, while `FEATURES.md`, `UAT-CHECKLIST.md` and this template are LF -- match whichever convention the specific file you are touching already uses, never convert it) +- [ ] Every comment, javadoc, workflow comment, and this PR's own title and body are English-first with Chinese as a supplement, and nothing was added to `.github/cjk-allowlist.txt` +- [ ] `FEATURES.md` and `UAT-CHECKLIST.md` updated for every feature change in this PR, or N/A with the reason diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..c5aea69 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,149 @@ +# UltiTools External Plugin Example — Feature Inventory + +This document catalogues every operator- or player-visible function, command, content item and +configuration key in this repository, as read directly from source. It is an internal reference +for UAT execution and issue reconciliation — the public description of these features lives on +. Update this file in the same pull request as any feature change. + +This repository is not a plugin module in the usual sense — it is a worked, buildable example +demonstrating how a **plain Bukkit `JavaPlugin`** (one that does NOT extend `UltiToolsPlugin`) +integrates with the framework through the External Plugin API (`UltiToolsAPI.connect(this)`, +`UltiToolsAPI.getDataOperator(...)`, `UltiToolsAPI.disconnect(this)`) and through the framework's +own annotation-driven registration (`@Service`/`@Autowired` bean wiring, `@CmdMapping` command +dispatch, `@EventListener` Bukkit event registration). Its five `@CmdMapping` commands and one +`@EventListener` class exist specifically to exercise that surface end to end, including the one +place in the eighteen repositories this phase covers where the External Plugin API's own +`DataOperator` data path is exercised on a real server. `UltiToolsAPI.getEventBus()` — the +framework's separate cross-module pub/sub system — is a real method on the same API class, but +this example does not call it anywhere in its 5 source files (confirmed by reading all 5 in full +and by a repository-wide grep for `EventBus`, zero hits); `JoinListener`'s `@EventListener` +annotation instead reaches `ListenerManager#registerAllExternal`, which calls Bukkit's own +`PluginManager#registerEvents` directly — a distinct mechanism from the module EventBus, and not +demonstrated by this repository. + +## Conventions + +- **ID grammar:** `..`, dot-separated, every segment lowercase ASCII + drawn from `[a-z0-9-]`. `` is the repository name lowercased with no separators; per + this phase's fixed exception, `UltiTools-External-Example`'s repo-slug is `ultitools-example` + (not the mechanically-derived `ultitoolsexternalexample`). `` is the feature section's + slug. `` is the verb. An ID changes only when the feature's identity changes, never on + rewording. IDs are unique within a repository. This repository has **no** `@ConfigEntity` + class, so it has no config-shaped ID exception to apply — the `config` row shape described + elsewhere in this convention (used by every other repository in this fan-out) has zero + instances here, by design (see `## Configuration` below). +- **Kind**, exactly these eight values: `command`, `config`, `event`, `gui`, `scheduled`, + `placeholder`, `persistence`, `gate`. Each maps one-to-one onto a reconciliation-table line. + This repository has no `config` rows (zero `@ConfigEntity` classes — this example ships no + configuration of its own, by design: it demonstrates the External Plugin API from a plain + Bukkit plugin, not a configuration surface), no `gui` rows (no GUI page class), no `scheduled` + rows (no `@Scheduled` method — this example does not demonstrate `TaskManager`), no + `placeholder` rows (no PlaceholderAPI expansion), and no `gate` rows (no `@ConditionalOnConfig` + site — there is no config to condition on) — five of the eight Kinds stay in the vocabulary for + cross-repository consistency even though they appear zero times below. +- **Tier**, exactly three: `player`, `admin`, `internal`. Judged from what the feature is for. + `hello` and `info` are harmless demonstrations any player could run. `visit`, `visitors`, and + `delvisitor` are explicitly labeled "Data Storage Tests" in the source's own code comment — they + exist to exercise `DataOperator` CRUD by hand, not to serve a real gameplay purpose, so they are + `internal`. All five share the same class-level permission node (`ultiext.greet`, undeclared in + `plugin.yml`, so OP-only by Bukkit's own default) — an ordinary non-OP player cannot currently + reach the `internal`-tier commands, so no D-11 issue is filed for them. +- **Manual**, exactly three: `detailed`, `brief`, `none`. This repository's own features are + developer-facing example code, not something doc.ultikits.com describes for end users — every + row below is `none`, since there is nothing here for the public manual to expand. +- **Target**, exactly four: `player`, `console`, `both`, or `n/a`. This repository's one + `@CmdExecutor` class carries `@CmdTarget(BOTH)` at the class level with no method-level + override, so all five command rows below carry Target `both`. +- **Permission:** the literal node string, `none`, or `n/a`. This repository's one `@CmdExecutor` + class does not set `requireOp = true`, so no row below carries that suffix; all five commands + rely on the single literal node `ultiext.greet` alone. No permission node is declared in + `plugin.yml` (no `permissions:` section exists), so Bukkit's own undeclared-permission default + applies: OP-only. +- **Source:** `ClassName#member` — the class and member that actually implements the feature. +- **Row order:** by section, then by ID ascending within the section. +- **No manual prose:** no troubleshooting column, no explanatory paragraphs, no draft page text. + +### Reconciliation command family + +The canonical form for counting an annotation site across this repository's real sources: + +```bash +find -path '*/src/main/java/*' -name '*.java' -not -path '*/target/*' \ + -not -path '*/.worktrees/*' -print0 | xargs -0 grep -nE '^[[:space:]]*@AnnotationName\b' | wc -l +``` + +This repository is a single-root Maven project (`src/main/java` only, 5 source files total), +carries no git worktree directory, and has no javadoc or string-literal mention of any of its own +annotation names — the naive (unanchored) and line-start counts are identical for every kind +measured below, but the anchored `find`/`grep` form is used regardless, so the same command is +trustworthy unmodified against every repository in the fan-out. + +**Positive controls**, each confirmed by reading the cited line directly, not by trusting the +count alone: + +| Annotation | Sites | Positive control | +|---|---|---| +| `@CmdExecutor` | 1 | `GreetCommand.java:21`, class-level, `alias = {"ultiext", "uext"}` | +| `@CmdMapping` | 5 | `GreetCommand.java:31` (`hello`), `:37` (`info`), `:44` (`visit `), `:74` (`visitors`), `:93` (`delvisitor `) | +| `@EventListener` | 1 | `JoinListener.java:16`, class-level, on `PlayerJoinEvent`'s handler class | +| `@Scheduled` | 0 | no background task exists anywhere in this repository's 5 source files — confirmed by reading all 5 in full | +| `@ConfigEntity` | 0 | this repository ships no configuration of its own, by design — it demonstrates the External Plugin API from a plain Bukkit plugin, not a configuration surface. Zero written, not omitted: this line is the phase's own live test of that rule | +| `@ConditionalOnConfig` | 0 | there is no configuration key to condition registration on — direct consequence of the `@ConfigEntity` line above | +| `@ConfigEntry` | 0 | same reason as `@ConfigEntity` — zero keys, because zero config classes | +| `@Table` | 1 | `VisitorRecord.java:13`, `@Table("visitor_records")` | + +This document's command-row count (5) matches the `@CmdMapping` site count exactly (5 against 5). +The three `@ConfigEntity`/`@ConditionalOnConfig`/`@ConfigEntry` lines above all read 0 against 0 +with a stated reason — this repository is the phase's own live instance of the rule that a zero +count is written, never silently omitted from the reconciliation table. + +## Greeting + +`GreetCommand` — class-level `@CmdExecutor(permission = "ultiext.greet", alias = {"ultiext", +"uext"})`, `@CmdTarget(BOTH)`. Backed by `GreetService`, an `@Service` bean injected via +`@Autowired`, demonstrating that this framework's IoC container auto-scans and wires beans for a +plain `JavaPlugin` connected through `UltiToolsAPI.connect(this)`, exactly as it does for a real +`UltiToolsPlugin` module. + +| ID | Feature | Kind | How to reach | Permission | Target | Tier | Manual | Source | +|---|---|---|---|---|---|---|---|---| +| ultitools-example.greet.hello | Send a fixed greeting naming the sender (or `Console` for a non-player sender), proving `@Service`/`@Autowired` injection works for an externally-connected plugin | command | `/ultiext hello` (alias `/uext hello`) | ultiext.greet | both | player | none | GreetCommand#hello, GreetService#greet | +| ultitools-example.greet.info | Show a fixed plugin-info line naming this example and confirming `@Service` injection succeeded | command | `/ultiext info` (alias `/uext info`) | ultiext.greet | both | player | none | GreetCommand#info, GreetService#info | + +## Data Storage (External Plugin API) + +`GreetCommand`'s three data-manipulation sub-commands, explicitly labeled "Data Storage Tests" in +this class's own code comment — they exist to exercise `UltiToolsAPI.getDataOperator(this, +VisitorRecord.class)`'s CRUD surface by hand, not to serve real gameplay. `VisitorRecord` +(`@Table("visitor_records")`, extends `BaseDataEntity`, current-generation API, not the +deprecated `AbstractDataEntity`) is this repository's one persisted entity, tracking a player +name, a visit count, and a last-visit timestamp. + +| ID | Feature | Kind | How to reach | Permission | Target | Tier | Manual | Source | +|---|---|---|---|---|---|---|---|---| +| ultitools-example.data.delvisitor | Delete a visitor record by player name; reports success unconditionally regardless of whether a matching record actually existed — `DataOperator#del` is not checked for a prior existence, so deleting a name with no record produces the identical "[DATA] Deleted..." message as deleting one that did exist | command | `/ultiext delvisitor ` | ultiext.greet | both | internal | none | GreetCommand#delVisitor | +| ultitools-example.data.visit | Record a visit for the named player: creates a new `VisitorRecord` (visit count 1) if none exists for that name, or increments the existing record's visit count and updates its `last_visit` timestamp otherwise | command | `/ultiext visit ` | ultiext.greet | both | internal | none | GreetCommand#visit | +| ultitools-example.data.visitors | List every currently stored visitor record with its player name and visit count, or a "no records" message if the table is empty | command | `/ultiext visitors` | ultiext.greet | both | internal | none | GreetCommand#visitors | +| ultitools-example.data.persistence | Every `VisitorRecord` created or updated through `/ultiext visit` or an automatic on-join visit (see `## Player Join` below) survives a full server restart — `UltiToolsAPI.getDataOperator` resolves a `DataOperator` backed by the framework's own configured storage backend (JSON/SQLite/MySQL per the framework's `config.yml` `datasource.type`), scoped to this plugin's own data folder (or a `DataScope` if the connecting adapter supplies one), the same persistence guarantee any `UltiToolsPlugin` module's own `@Table` entities get — this is the only place in this phase's eighteen repositories where the External Plugin API's own data path, rather than a module's built-in one, is exercised end to end on a real server | persistence | run `/ultiext visit `, then restart the server, then run `/ultiext visitors` | n/a | n/a | internal | none | VisitorRecord#VisitorRecord, UltiToolsExtExample#onEnable, ExternalPluginAdapter#getDataFolder | + +## Player Join + +`JoinListener` — `@EventListener` on a plain Bukkit `Listener` implementation, demonstrating that +the framework auto-registers a `@EventListener`-annotated class's Bukkit `@EventHandler` methods +for a plain `JavaPlugin` connected via `UltiToolsAPI.connect(this)`. Bundles two independently +observable behaviours in one handler: a chat greeting, and an automatic visit-record +creation/increment identical in effect to running `/ultiext visit ` by hand. + +| ID | Feature | Kind | How to reach | Permission | Target | Tier | Manual | Source | +|---|---|---|---|---|---|---|---|---| +| ultitools-example.join.on-join | Send the joining player the same fixed greeting `/ultiext hello` produces, and automatically create or increment that player's `VisitorRecord` exactly as `/ultiext visit ` would, without the player running any command | event | join the server as any player | n/a | n/a | player | none | JoinListener#onJoin | + +## Configuration + +This repository has **zero** `@ConfigEntity` classes, **zero** `@ConfigEntry` keys, and **zero** +`@ConditionalOnConfig` sites — by design, not by omission. It demonstrates the External Plugin +API from a plain Bukkit `JavaPlugin`, and ships no configuration surface of its own; its +`plugin.yml` carries only the fixed fields Bukkit itself requires (`name`, `version`, `main`, +`api-version`, `description`, `authors`, `depend`), none of which is operator-configurable content +in the sense this document otherwise catalogues. This section intentionally carries no rows — see +the reconciliation table above for the explicit 0-against-0 lines this absence produces. diff --git a/UAT-CHECKLIST.md b/UAT-CHECKLIST.md new file mode 100644 index 0000000..27a668b --- /dev/null +++ b/UAT-CHECKLIST.md @@ -0,0 +1,87 @@ +# UltiTools External Plugin Example — UAT Checklist + +This document is the executable companion to `FEATURES.md`: one row per feature stating the +steps to exercise it and the observable truth that proves it works. It is an internal reference +for real-machine verification, not user-facing documentation. + +> Batches are dispatched at 60 rows or fewer, and a batch never spans two repositories. There are +> exactly two legitimate exits to `human-uat-pending`: a row needing the pixel layer while the +> real-client harness is not ready, and a row needing personal credentials. Every other row must +> reach `pass`, `fail`, or `blocked`. + +## Conventions + +- **Columns:** `ID`, `Preconditions`, `Steps`, `Expected`, `Layer`, `Covers`. +- **ID:** cites its `FEATURES.md` ID verbatim. A negative case suffixes the checklist ID only, + as `.neg-` — a negative case still tests the same feature, so the base ID is unchanged. +- **Layer**, copied verbatim from Laojun's own `ultitools-real-client-uat` skill so no + translation step exists at dispatch time: `protocol`, `java-client`, `os-input`, `pixel`, + `server`, `human`. +- **Human-authenticated-session rows (D-27b):** a row whose Steps can only be exercised through + the maintainer's own authenticated UltiCloud panel session carries the fixed Preconditions + phrase `maintainer-authenticated UltiCloud panel session (personal credentials)` and Layer + `human`, ending at `human-uat-pending` by design. This repository has no panel-capability rows + of its own (it is a plain external Bukkit plugin with no UltiCloud/panel integration at all — + that surface belongs to `UltiToolsPlugin` modules, not to a plugin connected only through the + External Plugin API), so no row below is currently affected; the convention is stated here for + template consistency with the framework's checklist. +- **Expected** must name an observable truth — an exact chat line, a log line, a database row, + an inventory slot — and never the words "it works". +- **Covers** back-references a Phase 9 GUI-excluded class name; left blank when no such class + applies. This repository is not one of the nine modules in Phase 9's GUI-exclusion register + (confirmed by reading + `.planning/phases/09-module-ecosystem-readiness-and-test-coverage/gui-exclusions/` — no file + named for this repository exists there), so every row below leaves `Covers` blank. This + repository also has no GUI page class of its own (`FEATURES.md`'s own `gui`-Kind count is 0), + so the question does not arise a second way either. +- A row whose Preconditions name a prior row must appear after that row in file order — asserted + mechanically: for every row, every checklist ID cited in its Preconditions cell must have a + strictly smaller line number in this file than the row citing it (sweep class 8, D-27a). +- **Config-per-file rule (D-06):** one checklist row per `@ConfigEntity`-annotated class or per + shipped yml file, never one row per key. This repository ships zero `@ConfigEntity` classes and + zero configuration yml files of its own (`plugin.yml` carries only Bukkit's own required + plugin-descriptor fields, not operator-configurable content) — zero config-per-file rows exist + below, and this is correct, not a missing section: see `FEATURES.md`'s `## Configuration` + section for the same rule stated on the catalogue side. +- This repository's `plugin.yml` declares no `permissions:` section, and none of its five + `@CmdMapping` sub-commands has an in-game i18n catalogue to check against (this example has no + `lang/*.json` file at all — every player-facing string is a hardcoded English literal in + `GreetCommand.java`/`GreetService.java`/`JoinListener.java`), so no `language: en` precondition + is needed on any row below, unlike a module that ships its own `lang/zh.json`/`lang/en.json` + pair with a non-English shipped default. +- **The same absent `permissions:` section also means every one of the five sub-commands is + OP-only by Bukkit's own undeclared-node default**, since all five share the single class-level + `@CmdExecutor(permission = "ultiext.greet")` node. Every row below whose Steps dispatch through + a non-console player therefore states that player must be OP or hold `ultiext.greet`, stated in + each row's own Preconditions cell rather than only here — an ordinary non-OP player dispatching + any of these commands is rejected before the command body ever runs, which a Precondition that + merely says "none" would let an executor discover only as a false failure. The one row that + dispatches from the server console (`ultitools-example.greet.hello.neg-console`) is unaffected: + Bukkit's console `CommandSender` always passes every permission check. + +## Greeting + +| ID | Preconditions | Steps | Expected | Layer | Covers | +|---|---|---|---|---|---| +| ultitools-example.greet.hello | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only | Run `/ultiext hello` as a player named `Tester` | Chat line reads exactly `Hello Tester! This message is from an external plugin using UltiTools-API.` | server | | +| ultitools-example.greet.hello.neg-console | none | Run `/ultiext hello` from the server console | Console line reads exactly `Hello Console! This message is from an external plugin using UltiTools-API.` — `GreetCommand#hello` substitutes the literal string `Console` for any non-`Player` sender, proving `@CmdTarget(BOTH)` genuinely admits console execution rather than merely declaring it | server | | +| ultitools-example.greet.info | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only | Run `/ultiext info` as a player | Chat/console line reads exactly `UltiTools External Example v1.0.0 - verifying @Service injection works!` | server | | + +## Data Storage (External Plugin API) + +| ID | Preconditions | Steps | Expected | Layer | Covers | +|---|---|---|---|---|---| +| ultitools-example.data.delvisitor | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; a visitor record named `probe` exists — run `/ultiext visit probe` first if none does | Run `/ultiext delvisitor probe` as a player, then run `/ultiext visitors` | Chat/console line reads `[DATA] Deleted visitor record for probe`; the subsequent `/ultiext visitors` no longer lists `probe` | server | | +| ultitools-example.data.delvisitor.neg-not-found | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; no visitor record named `ghost` exists (delete it first via `ultitools-example.data.delvisitor` if a prior dispatch created one, or use a name never visited in this session) | Run `/ultiext delvisitor ghost` as a player | Chat/console line STILL reads `[DATA] Deleted visitor record for ghost` — `GreetCommand#delVisitor` calls `DataOperator#del` unconditionally and reports success regardless of whether a matching row ever existed; this is the row's actual assertion, not a bug workaround | server | | +| ultitools-example.data.visit | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; no visitor record named `probe` currently exists (run `ultitools-example.data.delvisitor` first if one does) | Run `/ultiext visit probe` as a player | Chat/console line reads `[DATA] Created record for probe — first visit!` | server | | +| ultitools-example.data.visit.neg-repeat | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; a visitor record named `probe` exists with visit count 1 (run `ultitools-example.data.visit` immediately before this row, in the same dispatch) | Run `/ultiext visit probe` as a player a second time, then run `/ultiext visitors` | The first command's chat/console line reads `[DATA] Updated probe — visit #2`; the subsequent `/ultiext visitors` lists `probe` EXACTLY ONCE with `2 visits` — proving the SAME record's count incremented rather than a second, duplicate `probe` row being inserted alongside the first | server | | +| ultitools-example.data.visitors | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; at least one visitor record exists (run `ultitools-example.data.visit` first) | Run `/ultiext visitors` as a player | Chat/console starts with `[DATA] === Visitor Records () ===` where `` matches the actual stored count, followed by one ` — visits` line per record | server | | +| ultitools-example.data.visitors.neg-empty | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; zero visitor records exist (delete every record created earlier in this dispatch via `ultitools-example.data.delvisitor`) | Run `/ultiext visitors` as a player | Chat/console shows exactly `[DATA] No visitor records.` — no header line, no record lines | server | | +| ultitools-example.data.persistence | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; a visitor record named `restarttest` exists with visit count 1 (run `ultitools-example.data.visit` with `restarttest` first); the player who runs the post-restart query below is named something OTHER than `restarttest` (or the query is run from the server console) — `JoinListener#onJoin` auto-increments a visitor record matching the JOINING player's own name, so a player named `restarttest` rejoining after the restart would increment the very record this row checks BEFORE `/ultiext visitors` ever runs, producing a false persistence failure | Stop the server completely (a full clean shutdown, not `/ul reload`), start it again, then run `/ultiext visitors` as a player NOT named `restarttest` (or from the console) | `restarttest` is still listed with visit count 1, unchanged by the restart or by any other player's own join — `UltiToolsAPI.getDataOperator`'s backing store persisted the record to disk (JSON/SQLite/MySQL per the framework's own `datasource.type`), independent of this plugin's own in-memory state, which is rebuilt from scratch on every `onEnable` | server | | + +## Player Join + +| ID | Preconditions | Steps | Expected | Layer | Covers | +|---|---|---|---|---|---| +| ultitools-example.join.on-join | no visitor record exists yet for the joining player's name | The player joins the server for the first time in this session | The player receives the chat line `Hello ! This message is from an external plugin using UltiTools-API.` (identical to `/ultiext hello`'s own line, substituting the real player name), and a subsequent `/ultiext visitors` (run by any sender holding `ultiext.greet`) lists that player's name with visit count 1, without the player having run any command themselves | server | | +| ultitools-example.join.on-join.neg-rejoin | a visitor record already exists for the joining player's name, with visit count `N` (the player joined once already earlier in this same dispatch, per `ultitools-example.join.on-join`) | The same player quits and rejoins | The player again receives the join greeting; a subsequent `/ultiext visitors` shows that player's visit count as `N + 1`, NOT reset to 1 and NOT a second entry for the same name — `JoinListener#onJoin` looks up the existing record by name and increments it, mirroring `/ultiext visit`'s own repeat-visit behaviour | server | |