diff --git a/projects/Thievery/README.md b/projects/Thievery/README.md index d6215e2..396126d 100644 --- a/projects/Thievery/README.md +++ b/projects/Thievery/README.md @@ -7,6 +7,7 @@ Technical documentation is maintained here. Run commands from the source checkou TFMC runs Minecraft **1.21.10**. See the [shared platform and build baseline](../../PLATFORM.md) for runtime, build and validation conventions. - [Locks and theft systems](docs/SYSTEM.md) +- [Lock owners, guard mode and the lock migration](docs/LOCK-OWNERS.md) - [Automated tests and coverage](docs/testing.md) - [Manual test matrix](docs/TEST_MATRIX.md) diff --git a/projects/Thievery/docs/LOCK-OWNERS.md b/projects/Thievery/docs/LOCK-OWNERS.md new file mode 100644 index 0000000..8ab5bb6 --- /dev/null +++ b/projects/Thievery/docs/LOCK-OWNERS.md @@ -0,0 +1,206 @@ +# Lock owners + +Every Thievery lock belongs to a character. Chests, barrels and other containers, +locked doors, armour stands, item frames and lockable furniture record the owning +character's ID, and only that character, played in a current session, counts as the +owner. Another character on the same account is a stranger: it cannot open a private +lock, rotate the lock state, clear a private lock's clues or merge a chest onto it. Staff with +`thievery.admin` keep their override. + +## Who owns a lock + +Placing a container, display or lockable furniture piece gives it to the character +you are playing, with a PUBLIC lock. Opening an unowned container claims it for that +character, and a sneak toggle claims an unowned display. Locking a door with a +master key records the character as the door's owner; the key stays the credential +for opening, unlocking and breaking the door. + +Without a current character session, placing still works but leaves the block or +display unowned (any stale record left at that spot by a container that was blown up +is removed), while claims, rotations and door locking +are refused with "Play a character to claim or change locks." Staff without a +current character are the exception for placement and door locking: the lock is +stamped as a **server** lock, starts PRIVATE and opens only through the staff +override. Server hoppers may move items between server containers, so staff +automation keeps working. Server locks cannot be picked. + +## Access + +PUBLIC and unowned locks are open to everyone. A PRIVATE lock opens for the owning +character only. A GUILD lock opens for characters in the guild the owner's locks +follow, compared by guild incarnation, so a disbanded and recreated guild with the +same name does not inherit access. A FACTION lock opens for characters whose guild +is in the same faction as that guild. + +The guild a lock follows comes from SimpleFactions' `lockGuild`: the owner's current +guild while they live, and the guild they belonged to when they died afterwards. A +dead owner's GUILD and FACTION locks therefore keep working for that guild. A +revived character lost their guild at death, so their guild has no access until they +rejoin. When SimpleFactions is not ready, or is older than 4.1.0 and has no +`lockGuild`, GUILD and FACTION locks deny everyone except staff. + +Both halves of a double chest are read together. Opening fills an unowned half from +its owned partner, and a wholly unowned double chest is claimed for the opener. An +unreadable half is never copied onto its partner; the chest is listed for staff. When +the halves have two different owners, nothing is synced: opening still needs access +to both halves, nobody can rotate the chest (staff included), thieves cannot pick it, +and staff see it in the quarantine list until they set one owner with +`/thievery locks setowner`. + +## Automation + +A block hopper moves items only to and from containers with exactly its own owner, +checking both halves of a double chest. An unowned half goes with its owned +partner. Hoppers on another character's chest stop, including an alt's hopper under +its main's chest. + +A hopper minecart has no owner. Its moves are cancelled whenever the source or +destination is an owned container that is not PUBLIC, including either half of a +double chest. Owners cannot drain their own private storage with a hopper minecart; +use a block hopper, or make the chest PUBLIC. A block hopper that pushes into an +entity container, such as a chest minecart, is unaffected. + +## Picking and clues + +Thieves pick character locks as before. With `lockpicking.require-owner-online: true`, +a lock needs a member of its lock guild online, or, without a guild, the owning +character in play, or an open lockpick window. The messages never name the owner or +the guild: "Cannot lockpick - this lock's owners are away." A dead owner with no +lock guild is never online, so their lock can only be picked while a window is open. +Server locks, quarantined locks and unreadable records cannot be picked. + +Clue bookkeeping keys a lock's victim as `guild:`, otherwise +`player:`, and a pickpocket's victim by the character they are playing. +Clue keys written before character locks age out. + +Clue clearing on a PUBLIC or unowned container is open to anyone. A PRIVATE container +needs the owning character, and a GUILD or FACTION container anyone that lock lets in. +On a door, the owning character or a member of its lock guild may clear clues. + +## Staff commands + +Both commands need `thievery.admin` and are written to the server log and to +`plugins/Thievery/logs/locks-audit.log`. + +`/thievery locks quarantine` shows whether locks are running or frozen (and why), +then the quarantined records with a count for each stored owner, unreadable records, +files missing after a migration replay, and double chests with two owners. The full +list is in `plugins/Thievery/migrations/quarantine.json`. + +`/thievery locks setowner ` sets the owner of the container +(both halves of a double chest), locked door, lockable armour stand or item frame, or +lockable furniture piece you look at within 5 blocks. It keeps the lock state, except +that an unreadable record becomes PRIVATE and a lock given to `server` always becomes +PRIVATE, as server placements are; the reply says so. A door keeps its key, which +still opens it after it is given to `server`. Staff without a character cannot +enter the world when character bodies are on, so this is how they make a server lock. +The thief cooldowns of an unreadable container are kept when they can still be read. A UUID must be a known character +ID, dead characters included. A name must match exactly one living character; names +need TLibs 2.5.0, and without it use the UUID. A door whose record has lost its key or +its strength cannot be repaired this way, so the lock is never weakened: break the +door in Creative to remove it. Fixed records leave the quarantine list straight away. + +## Storage + +Lock files keep the owner as a bare UUID and add `ownerKind`, which is `CHARACTER` +or `SERVER` (stored as the nil UUID): + +| Store | Path | Owner fields | +|---|---|---| +| Containers | `plugins/Thievery/data/_/__.json` | `owner`, `ownerKind` | +| Doors | `plugins/Thievery/door-data/_/__.json` | `ownerUUID`, `ownerKind` | +| Entities | `plugins/Thievery/entities/.json` | `owner`, `ownerKind` | +| Furniture | InteractibleFurniture variables | `thievery.owner`, `thievery.ownerKind` | + +Every write goes to a temporary file in the same directory and is then moved into +place atomically. A record is written only when it changed; owner, kind and state +changes are forced to disk, while thief cooldown changes in `accessMap` are not. +Thief cooldowns stay keyed by account. + +A record that cannot be read loads locked, with an unresolved owner: an empty, +truncated or `null` file, a bad UUID, an owned record with a missing or unknown lock +state, an unknown `ownerKind`, or garbage furniture variables. Such a lock denies +everyone except staff, cannot be picked, is never overwritten by ordinary play, and +is listed for staff. An error inside a lock listener cancels the event, including a +linkage error from a changed SimpleFactions or TLibs API. + +A bare UUID without `ownerKind` is classified once whenever its record is loaded: a +known character ID is a character, an account with a reviewed heir is a legacy +owner, the nil UUID is the server, and anything else is unresolved. Legacy and +unresolved owners deny everyone except staff on non-PUBLIC locks and cannot be +picked. A classification made while character identity is unavailable is never +written back. + +Furniture is upgraded on first interaction instead of at startup: a bare character ID +gains its marker and a legacy account moves to its reviewed heir. Furniture values +written by older jars stay readable until stage 7. + +## Startup, guard mode and the migration + +Thievery registers its listeners in guard mode at the start of `onEnable`. In guard +mode the lock stores are frozen: the container, door and entity data managers save +nothing, and deletions (for example an item frame knocked off its wall) wait in a +queue. Players see "Locks are starting up. Try again shortly." when they try to +place or break a lockable, rotate or claim a lock, pick a lock, lock or unlock a +door, or clear clues. Owned, non-PUBLIC containers and displays stay shut to +players, and staff see a read-only copy of a container. PUBLIC and unowned +containers open normally without being claimed. Key holders can still open locked +doors. Block hoppers and hopper minecarts touching an owned container stop. +`resetcooldowns`, `setowner` and `testpick` on a container are refused. + +At the end of `onEnable` Thievery checks its gates in order. TLibs must offer the +reviewed-heir lookups (2.4.0 or later). Every store's `data-version` must be one this +jar understands. Character heirs must be ready, which is normal because RPCharacters +is a hard dependency and registers first. If any gate fails, locks stay in guard mode +until the server restarts, and the log says why with a SEVERE line. If everything +passes, the migration runs, the owner cache is cleared, queued deletions are applied +and locks switch to normal operation. + +The migration runs on the main thread before anyone can join. It scans every +container, door and entity file, classifies each distinct bare owner once and +rewrites the files it can settle: character IDs gain `ownerKind: CHARACTER`, the nil +UUID gains `SERVER`, and an account with a reviewed heir moves to that heir character. +Accounts without a heir, and values that cannot be classified yet, stay as they are +and go on the quarantine list, which every boot retries. A bare value written later, +for example by an older jar, goes through the same journalled pass on the next boot. +The log line `Lock migration: … in N ms` records the counts and duration. + +Each pass that rewrites files first archives every lock file it scanned to +`plugins/Thievery/migrations/backup/locks-NNNN.zip`, then writes +`migrations/journal-NNNN.json` with each file's original hash and the hash of the +exact bytes it will write. It rewrites the files by atomic move, forces every +directory it wrote into, marks the journal completed and writes `data-version` into +each store. If the server stops during the pass, or the pass throws (locks then stay +frozen until a restart), the next boot replays the unfinished journal: files already +at their new hash are done, files at their original hash are rewritten, a torn file +is taken from the archive and rewritten, and a missing file is listed for staff and +never restored. A journal is never archived twice, and a completed journal is never +replayed. + +## Rolling back + +Stop the server and roll back the whole stage 6a set together. Thievery's part is the +offline tool [`tools/thievery_rollback.py`](https://github.com/TF-Minecraft/Thievery/blob/main/tools/thievery_rollback.py) +in the source checkout. It needs only Python 3 and runs against the plugin folder: + +```sh +python3 tools/thievery_rollback.py /path/to/plugins/Thievery +python3 tools/thievery_rollback.py /path/to/plugins/Thievery --apply --report rollback.json +``` + +Without `--apply` it is a dry run that writes nothing. It processes every +`migrations/journal-NNNN.json` newest first and restores a file from that journal's +archive only if the file's current SHA-256 still equals the journal's `newHash` and +the archived copy matches `originalHash`. Files changed or deleted since the migration, +and archive copies that do not match, are listed and left alone. With `--apply` it then +deletes the journals, `data-version` in `data/`, `door-data/` and `entities/`, and +`migrations/quarantine.json`, so a later upgrade starts clean; the archives are kept. +If a journal or archive cannot be read it stops with exit code 2 and writes nothing. +`--report` saves the per-journal result as JSON. + +Then put back the previous jars. + +Under the old jars, lock files left unrestored, furniture upgraded since the +migration and server locks hold values the old code cannot match, so they deny +everyone except staff. That is expected and fails closed. Running an older Thievery +on migrated stores without this procedure is unsupported. diff --git a/projects/Thievery/docs/SYSTEM.md b/projects/Thievery/docs/SYSTEM.md index ccb0f4f..c575662 100644 --- a/projects/Thievery/docs/SYSTEM.md +++ b/projects/Thievery/docs/SYSTEM.md @@ -31,10 +31,11 @@ expire, with a message to the other player. An accepted robbery ends through its own quit handling. Pouch transfers still require both captured sessions to be current; switching characters never redirects the transfer to another pouch. -The build pins TLibs **2.4.0**, RPCharacters **2.16.0**, SimpleFactions **4.0.0** +The build pins TLibs **2.4.0**, RPCharacters **2.16.0**, SimpleFactions **4.1.0** and DenarEconomy **0.3.0**. RPCharacters remains required for clues, graves and -evil-RP rewards. Protected guild access and theft cooldown checks deny access -while factions or reviewed heirs are unavailable. +evil-RP rewards. Guild and faction locks need SimpleFactions 4.1.0 or later for +`lockGuild`; with an older SimpleFactions they deny everyone except staff. Theft +cooldown checks deny access while factions or reviewed heirs are unavailable. ## Character money and guild access @@ -52,15 +53,12 @@ before investigating a disputed payment. Startup does not issue another payment or resume a closed robbery. Pouch transfers move no physical coin items. Guild lookups use the active character. Guild cooldowns map character members -back to their account UUIDs and retain existing account-wide history. Offline -lock owner guild checks and `LockAccess` ownership checks use the account's -reviewed legacy heir; a different active character does not inherit those access -rights. Online guild checks require the relevant member character to be active. +back to their account UUIDs and retain existing account-wide history. Risk, thief +cooldowns and the container `accessMap` stay keyed by account, so switching +characters does not reset them. -Lock files, owner controls, risk and cooldown storage remain account-based until -stage 6. This includes owner toggles, shared-key and automation paths; this stage -does not claim full character isolation for physical locks. Existing guild target -keys retain their cooldown history. +Locks belong to characters: see [lock owners](LOCK-OWNERS.md) for ownership, +access, automation, staff commands, storage, guard mode and the lock migration. ## Responsibilities @@ -72,7 +70,9 @@ keys retain their cooldown history. ## Chest hopper automation -When a **block hopper** moves items (`InventoryMoveItemEvent`), allow pull/deposit only if the hopper and every involved block container (chest, barrel, hopper, etc.) share the same **non-null owner UUID**. Guild/PUBLIC player access does not apply to hoppers — only owner equality. Unowned containers block automation; hopper minecarts are not covered. Droppers and other initiators are unchanged. +When a **block hopper** moves items (`InventoryMoveItemEvent`), Thievery allows the pull or deposit only if every involved block container (chest, barrel, hopper and so on) has exactly the hopper's owner. Both halves of a double chest are checked, and an unowned half goes with its owned partner. Guild and PUBLIC access do not apply to block hoppers, and unowned hoppers move nothing. + +A **hopper minecart** has no owner. Its moves are cancelled when the source or destination is an owned container that is not PUBLIC, including either half of a double chest, so owners cannot drain their own private storage with one. Droppers and other initiators are unchanged. See [lock owners](LOCK-OWNERS.md#automation). Displays do not use the chest GUI. Doors and displays share one engine. @@ -153,9 +153,9 @@ Honor `min-lock-strength-ratio` against that display strength. ## Lock state -Reuse `LockState` (`PRIVATE` / `GUILD` / `FACTION` / `PUBLIC`) and the same access rules as `ContainerData.canAccess` (owner, same guild, same faction, public, `thievery.admin` bypass). `FACTION` is every guild in the owner's faction. Vassals are not included. Chest lockpick sessions scale budget, risk, critical chance, and break chance from `lockpicking.lock-types`. +Reuse `LockState` (`PRIVATE` / `GUILD` / `FACTION` / `PUBLIC`) and the same access rules as `ContainerData.canAccess` (owning character, the guild the owner's locks follow, that guild's faction, public, `thievery.admin` bypass). `FACTION` is every guild in that faction. Vassals are not included. Chest lockpick sessions scale budget, risk, critical chance, and break chance from `lockpicking.lock-types`. -Shift left-click is the toggle (same titles/sound as chests). The owner rotates state; staff with `thievery.admin` can also rotate a lock another player owns, and the placer stays the owner. Shift left-click **never** breaks a lockable display. +Shift left-click is the toggle (same titles/sound as chests). The owning character rotates state; staff with `thievery.admin` can also rotate a lock another character owns, and that character stays the owner. Shift left-click **never** breaks a lockable display. ### Lock change logging @@ -174,15 +174,15 @@ If the player cannot access a locked display, cancel: Owner / guild / public access still allows legitimate break and take. -Owner is set on place (`FurniturePlaceEvent`, `EntityPlaceEvent`). If missing, the first successful owner toggle may claim it. +Owner is set on place (`FurniturePlaceEvent`, `EntityPlaceEvent`) to the placer's current character, or the server for staff without one. If missing, the first toggle by a player with a current character claims it. ## Persistence (entity-capable) `ContainerDataManager` is block-file keyed. Do not reuse it for entities. -**Vanilla entities** (armor stand, item frame, glow item frame): `plugins/Thievery/entities/.json` (`owner`, `lockState`). Delete on entity remove. +**Vanilla entities** (armor stand, item frame, glow item frame): `plugins/Thievery/entities/.json` (`owner`, `ownerKind`, `lockState`). Delete on entity remove. -**IF furniture:** do not key only by `Furniture.getEntityId()`. Restore can respawn the ItemDisplay and change the UUID (`FurnitureRestoreHandler.ensureDisplay`). Store `thievery.owner` and `thievery.lockState` in `Furniture.getVariables()` and `persistFurniture`. The lock follows pickup, chunk reload, and respawn. +**IF furniture:** do not key only by `Furniture.getEntityId()`. Restore can respawn the ItemDisplay and change the UUID (`FurnitureRestoreHandler.ensureDisplay`). Store `thievery.owner`, `thievery.ownerKind` and `thievery.lockState` in `Furniture.getVariables()` and `persistFurniture`. The lock follows pickup, chunk reload, and respawn. Config: diff --git a/projects/Thievery/docs/TEST_MATRIX.md b/projects/Thievery/docs/TEST_MATRIX.md index 89a0ef8..e06043d 100644 --- a/projects/Thievery/docs/TEST_MATRIX.md +++ b/projects/Thievery/docs/TEST_MATRIX.md @@ -122,6 +122,14 @@ ItemsAdder must not grant inspection access. | L11 | Pick up and replace furniture: lock still on that piece | | L13 | Shift left-click on an owned chest, display and furniture: `/co inspect` or `/co lookup` shows ` set lock to .`; display and furniture entries sit on the supporting block | | L14 | Staff shift left-click on another player's lock changes state, keeps the owner, and logs with "(staff override)" | +| L15 | Your alt on the same account cannot open your PRIVATE chest, rotate it, clear its clues or merge a chest onto it | +| L16 | Placing a chest with no character selected leaves it unowned; staff with no character place a PRIVATE server chest only the staff override opens | +| L17 | A dead owner's GUILD chest still opens for the guild they died in; a revived owner's old guild has no access | +| L18 | A hopper minecart under an owned PRIVATE chest (either half of a double chest) takes nothing; under a PUBLIC or unowned chest it works | +| L19 | A block hopper under its owner's chest moves items; an alt's hopper under the same chest does not; server hoppers move between server chests | +| L20 | A double chest whose halves have two owners cannot be rotated or picked and appears in `/thievery locks quarantine` until `/thievery locks setowner` fixes it | +| L21 | Restart with RPCharacters failing to load: locks stay frozen ("Locks are starting up"), owned chests stay shut, key holders open doors, nothing in `plugins/Thievery/data` changes; the next good restart migrates | +| L22 | First boot on an old store: `Lock migration:` log line with counts and duration, `migrations/backup/locks-0001.zip`, a completed `journal-0001.json`, `data-version` in each store; a second boot changes nothing | ## Robbery diff --git a/projects/Thievery/docs/testing.md b/projects/Thievery/docs/testing.md index 576c6c3..01d7757 100644 --- a/projects/Thievery/docs/testing.md +++ b/projects/Thievery/docs/testing.md @@ -29,8 +29,24 @@ Pouch payment tests use real temporary SQLite files for balanced transfers, ledger reopen, a committed payment with a lost reply, insufficient funds and stale sessions. Handler tests repeat the same click, retry unavailable payments, and verify that unsavable intents consume no budget. Identity tests check -reviewed-heir ownership, another character on the same account, faction waiting, -and member-to-account cooldown mapping. +member-to-account cooldown mapping and faction waiting. + +Lock owner tests use temporary directories for real lock files. They cover the +migration (classification, exact-byte journal hashes, the archive, a second boot, +quarantine retry, stragglers, an exception mid-pass, every replay case and +`data-version`), the guard freeze in each data manager and handler, fail-closed +parsing, access for characters, alts, dead owners and server locks, double chests, +block hoppers and hopper minecarts, target keys and the staff lock commands. +`FactionFixture` gives each mocked player a character whose ID is their account +UUID; `plays(player, id)` switches it, and its `guilds` field stands in for +SimpleFactions' `lockGuild`. A JUnit extension starts every test with locks in +normal mode, no guild source and an empty quarantine list. + +The offline rollback tool has Python tests in `tools/test_thievery_rollback.py`, run +with `python3 -m unittest discover -s tools -p 'test_*.py'` and in CI after the Maven +build. They build a fixture laid out as the migration writes it, with two journals, +and cover the dry run, newest-first restores across both passes, changed, missing and +mismatched files, marker removal, and journals or archives that cannot be trusted. Hands-up inspection tests exercise consent, independent item copies and live refresh, full-view click and drag cancellation, creative inventory events,