Skip to content

Plan: make each character its own person across the server #89

Closed

Description

@ryanbarlow97

Each character should be its own person, with its own body, money, memberships, progress and property. Today most of that belongs to the Minecraft account. TF-Minecraft/SimpleFactions#156 moves faction membership to characters, and TF-Minecraft/DenarEconomy#37 does the same for money. They only work if the rest of the server follows, so this issue is the plan for the whole server. Those two issues are stages inside it.

Where characters exist today

An audit of every active repository and of Main's data found the following on 11 October 2026.

Main has 249 accounts and 285 characters (273 alive, 12 dead). 211 accounts have one character and 38 have more. Character IDs are random UUIDv4 strings, and a character created on the website keeps the website's ID.

Switching character swaps only five things: the MMOCore class, attribute allocation, LuckPerms professions.* nodes, focus and the skin. Everything held on the body is shared by the whole account: inventory, ender chest, location, health, hunger, XP, potion effects and the player's PDC. MMOCore level, EXP, skill points and profession levels are account-wide on purpose.

Only Magic, Gathering, Cooking nutrition and BirdMessenger recipients key their data by character. Everything else uses the account UUID or the username. For example, SimpleFactions members are usernames, DenarEconomy balances are per UUID, VehicleFramework owners are player_<username>, and Thievery checks lock access by account. Gathering, Magic and BirdMessenger each wrap RPCharacters in their own way, and they disagree on whether a character ID is a string or a UUID.

Many plugins also hold in-memory state keyed by account that survives a switch, so whatever a player starts on one character finishes on the next. This affects prompts, game seats, surgery, meditation, reloads and pending requests.

Decided

  • Separate bodies. Each character has its own inventory, ender chest, location, health, hunger, XP and effects.
  • Per-character progress. MMOCore level, EXP, skill points and profession levels belong to the character. At cutover, every existing character keeps the account's current level, EXP, skill points and profession levels. They hold no money, so copying them duplicates nothing that can be traded.
  • Estates. A character can name an heir in character. When it dies, its estate (bank money, vehicles, pets, animals, shops, chests) goes to that heir, or to its guild or realm if there is no heir. Carried items stay in its grave. Guild seats follow the succession rules in SimpleFactions#156.
  • Choosing a character. Players pick their character as they join, on a character select screen. Playing the same character is always allowed. Changing to a different one is limited by a cooldown: 4 days without a rank, 3 for Noble, 2 for Gilded, 1 for Ascended and Legacy (Shorten character switch cooldowns to 1-4 days #93). This replaces the "switching is never locked" rule in SimpleFactions#156. A switch now means leaving and rejoining as someone else, so leaving a fight that way is ordinary combat logging.
  • Account-wide things stay account-wide. These include anti-farm limits (Geiger drop limits, item and kit cooldowns, Thievery's same-target cooldown), bought cosmetics, ranks, the Discord link, warnings, bans and the CoreProtect audit trail.
  • Migration heir. Each account's active living character inherits what the account owns today; without one, its most-played living character does, using RPCharacters playtime. Staff review the result before cutover.

Design

One character identity for every plugin. TLibs gets a small CharacterIdentity interface that RPCharacters implements when it enables. It provides a CharacterRef (account UUID + character ID), the active character for an online player, offline lookup by ID or account, status, playtime and events. The events are switching, switched, died and session ended. TLibs loads before RPCharacters and most plugins already depend on it, so plugins can compile against the interface without a hard dependency on RPCharacters. TLibs also gets a CharacterScoped map that clears an account's entry when its character changes or its session ends.

Actions remember who started them. Anything that finishes later (surgery, reloads, crafting, theft, prompts, wagers, network callbacks) records the account, the character and a session number when it starts. When it finishes, it acts on that character or is cancelled. It never acts on whoever happens to be active by then. Debts belong to the character who earned them. That covers wages, shop proceeds and refunds, which stay payable after a switch or logout.

Bodies are swapped on the select screen. RPCharacters keeps one saved body per character. The player arrives at the select screen with no body in the world. Pressing Play loads that character's body, and leaving saves it back. Each saved body is tagged with its character ID so a crash mid-swap can be repaired on the next join.

The select screen. This is a small private room on Main. Each character stands as a Mannequin entity (Minecraft 1.21.9+) wearing its wardrobe skin, and dead characters stand as memorials. Clicking a character opens a Paper dialog card with name, age, race, class, guild, last location, pouch and bank. The card has a Play button, which is greyed out with the time when the cooldown ends. An empty spot opens the character creator. MusicalInstruments already uses Paper dialogs and a resource pack font for its screens. A simpler fallback is a dialog shown while the player is still joining, which needs a test on Dev for fonts and the Velocity proxy.

Death is recorded once and processed once. A permakill is saved before anything else happens. RPCharacters then fires the died event, and each plugin records that it has handled it. After a crash, the event is sent again and a plugin that has already handled it skips it. That way an estate or succession is never paid out twice.

Money is a ledger. DenarEconomy keeps balances per character, with exact amounts, an ID for every operation and a full transaction history. Guild treasuries are accounts in the same ledger. Item trades that pair money with items use escrow so a crash cannot complete one side only. The old UUID-only calls keep working while plugins move over, but they refuse to write when they cannot tell which character is meant.

The website gets a versioned copy. RPCharacters owns character identity and lifecycle. ProvinceSystem owns sign-in, web commands and wardrobe choices. Every roster update carries a revision, and an older snapshot can never override a later death or deletion. Roster pushes stop overwriting fields the website owns, such as wardrobe_active_slot.

Stages

  1. Fix the bugs that exist today (list below), each as its own small PR. They do not depend on the rest of this plan.
  2. Identity layer. Add CharacterIdentity, CharacterRef and CharacterScoped in TLibs, and implement them in RPCharacters. Move Gathering, Magic, BirdMessenger and Cooking onto the shared interface.
  3. Migration manifest. Build the heir table and the exceptions listed under Migration for staff to review.
  4. Select screen and separate bodies in RPCharacters, with per-character MMOCore progress. Prototype on Dev first.
  5. Factions and money together. Do SimpleFactions#156 and DenarEconomy#37 at the same time, along with the plugins that read membership or balances: Thievery, BarterShops, Marketblock, Games, TrialRooms, Dowsing and Cooking.
  6. Everything else. This covers the remaining ownership: vehicles, pets and animals, shops, construction credit, music projects, raid penalties and refund queues. It also adds estates and wills, and website revisions.
  7. Remove the account-keyed paths once nothing calls them.

Bugs that exist today

  • Permakill fires CharacterActivatedEvent with no previous character, so Magic and focus never save the dead character's state.
  • Every roster push to the website wipes wardrobe_active_slot.
  • Anyone can break any BarterShops sign.
  • Any faction member can register or break a voting booth; it should need the leader.
  • Switching mid-reload in GunsAndGadgets refunds the ammo to the new character.
  • Research lets several characters pool their focus into one project.
  • Surgery applies its cure or penalty to whichever character is active when the operation ends.
  • Infestations' death-on-login kills whichever character logs in next.
  • A temporary alias carries over to the next character.
  • RosterSyncService reads live character data from an async thread without locking.

Migration

The heir rule needs these exceptions settled before cutover, all found in Main's data:

  • 5 accounts whose most-played character is dead.
  • 8 faction member names that resolve to more than one UUID.
  • Dev has duplicate character IDs and a website roster that differs from the server, so Dev needs cleaning before it can test the migration.
  • Main's startup log shows older plugin versions than the jars on disk, so a migration test needs the versions Main actually runs.

Fungible things (money, items, vehicles, animals) go to the heir only, so nothing is duplicated. The other characters start with an empty body at their last location.

Acceptance

Two characters on one account in rival factions must keep their rights, property and consequences separate in all of these cases:

  • switching during menus, surgery, wagers, crafting and refunds
  • death with another character available
  • callbacks arriving late
  • a disconnect or restart between a payment and an item delivery
  • a death event delivered twice

Money and items must be conserved throughout.

Activity

  1. ryanbarlow97 commented on Oct 11, 2026

    @ryanbarlow97
    ContributorAuthor

    Stage 1 PRs, one per bug. All passed their builds and tests and none are merged yet.

    Found while fixing these, still open:

    • BarterShops never checks who owns the storage chest when a shop is set up, so anyone can link a buy shop to another player's chest and sell its stock.
    • Infestations lure commitment is keyed by account, so the deserter penalty carries over to another character.
    • When an account's last living character dies, no character event fires, so Magic and focus save the dead character only on quit or at the periodic save.
  2. ryanbarlow97 commented on Oct 11, 2026

    @ryanbarlow97
    ContributorAuthor

    Stage 1 is merged. Astra reviewed every PR, some over several rounds, before it went in. Nothing has been released or deployed yet.

    Code: #90, #91, #92 and #93, TF-Minecraft/ProvinceSystem#201, TF-Minecraft/BarterShops#32, TF-Minecraft/SimpleFactions#157, TF-Minecraft/GunsAndGadgets#36, TF-Minecraft/Research#15, TF-Minecraft/Surgery#29 and TF-Minecraft/Infestations#14. Docs: TF-Minecraft/Docs#150 to #155.

    Changes made during review:

    • Read character data for website roster pushes on the main thread #92 now parses saved characters and looks up races and traits on the main thread. The background job only reads the files and uploads the roster, so a reload cannot drop a trait from it.
    • BarterShops blocks physics and explosions from destroying a shop sign unless the owner's or staff's own break was expected to bring it down. Commands that replace blocks, such as /setblock with destroy, can still remove one.
    • Surgery also ends an operation when the patient's or surgeon's character is permakilled. It skips the commands set in the config once either character has changed.
    • Infestations holds deaths saved before this change, which record no character. They cause no deserter damage and do not keep a lure from failing.

    Still open:

    • BarterShops never checks who owns the storage chest when a shop is set up.
    • Switching character while online still carries an Infestations lure commitment over. Stage 4 removes switching while online.
    • When an account's last living character dies, no CharacterActivatedEvent fires. Surgery now listens for the permakill event instead, but Magic and focus still save only on quit.
    • The GunsAndGadgets reload refund lands after the switch. Once characters have separate bodies (stage 4), it must happen before the swap.
    • The new switch cooldowns (Shorten character switch cooldowns to 1-4 days #93) take effect on each server only after its permission-groups.yml is updated and the server restarts.
  3. ryanbarlow97 commented on Oct 11, 2026

    @ryanbarlow97
    ContributorAuthor

    Stage 2 design: one character identity for every plugin

    Today about a dozen plugins reach into RPCharacters in their own way. Gathering, Magic, Infestations, Research, Thievery, Games and SimpleFactions each wrap "the active character", and they disagree on whether a character ID is a string or a UUID and on what MISSING means. SimpleFactions reads RPCharacters' JSON files from disk in three places. Plugins work around timing with a 100-tick delay after join, a next-tick re-check after a permakill, a quit listener at LOWEST and PluginEnableEvent hooks. RPCharacters never says when a character stops being played.

    Stage 2 adds one API in TLibs, implemented by RPCharacters, and moves plugins onto it. Astra reviewed a first draft against the code; this version includes its changes.

    API (TLibs, net.tfminecraft.tlibs.character)

    All methods and events are main-thread only, and calls from other threads throw. Return values are immutable snapshots that are safe to hand to background work. The classes are additive, so plugins pinned to older TLibs keep working against the new release.

    • CharacterRef(UUID ownerId, UUID characterId) is a durable identity for a character. RPCharacters converts its string IDs once, at the edge.
    • CharacterSession(CharacterRef ref, long sessionId) is one stretch of play as a character. Every activation gets a fresh ID, including a reconnect or switching back to the same character. Anything that finishes later records the session when it starts and checks Characters.isCurrent(session) just before it applies its effect. This is the fix for the stage 1 bug class, and it catches Alice → Bob → Alice, which an identity check alone would miss.
    • CharacterStatus is ALIVE, DEAD or MISSING. CharacterInfo.alive() is a convenience for ALIVE.
    • CharacterInfo is a snapshot of a ref, its canonical plain name, its status, race ID, trait IDs (lower case, as stored), playtime seconds and the time it was last played.
    • CharacterIdentity is the service interface, registered in Bukkit's ServicesManager:
      • Optional<CharacterSession> currentSession(Player) and boolean isCurrent(CharacterSession).
      • Optional<CharacterInfo> info(UUID characterId), online or offline.
      • Optional<List<CharacterInfo>> characters(UUID ownerId). Empty means the account's records could not be read; an empty list means the account has no characters. Callers must not treat "unknown" as "none".
      • Optional<CharacterInfo> lastPlayed(UUID ownerId) returns the character the account played most recently, from a timestamp RPCharacters saves at session start. It works for dead characters too.
      • String displayName(Player) is what other players see now, including masks and temporary aliases. CharacterInfo.name is the real name.
    • Characters is the static facade. available() is false until RPCharacters registers the service, which happens only after its directory has loaded. The facade looks the service up each time, so it handles registration and unregistration in one place. Account fallback without RPCharacters is allowed only for display and gameplay checks, never for ownership or money.

    Events, all carrying a CharacterSession or CharacterRef:

    • CharacterSessionStartEvent(Player, CharacterSession) fires once the character is ready to play: after MMOCore setup on join, after a switch, and in stage 4 after the body has loaded.
    • CharacterSessionEndEvent(Player, CharacterSession, Reason) fires with reason SWITCH, QUIT, DEATH or SHUTDOWN. It runs while the outgoing character's data and body are still loaded, before RPCharacters saves or unloads anything. From this point the session is no longer current, so no new action can start against it, but listeners can still clean up against it.
    • CharacterDeathEvent(CharacterRef, UUID deathId, Instant at, killer account UUID or null, killer CharacterRef or null, String cause) fires only after RPCharacters has saved the death. If the save fails, RPCharacters logs it and the event does not fire. deathId is saved on the character, so separate deaths stay distinct after a revival. This is still an ordinary Bukkit event, so a crash between the save and the event loses delivery. Stage 6 (estates and succession) adds replay keyed by deathId, and consumers must treat it as idempotent. The cancellable CharacterPermakillEvent in RPCharacters stays as the "about to die" hook.

    CharacterSessionMap<V> holds per-session state. An entry is stored against the session that put it there, and get returns it only while that session is current. A late callback can neither read a stale value nor write into the next session. When a session ends, the map calls an optional onEnd(session, value) before removing the entry, so a plugin can flush, as Magic does today. Persisted things such as debts, refunds and character cooldowns must not go in this map.

    RPCharacters

    • One transition path: a single CharacterTransitions class handles join, switch, quit, permakill and shutdown, in this order:

      1. End the outgoing session.
      2. Save.
      3. Unload or swap.
      4. Start the incoming session once it is ready.

      The join path stops firing activation twice. RPCharacters' own focus listener moves onto the new events.

    • Permakill: the death is saved with a deathId and checked for success before CharacterDeathEvent fires. Killing an inactive character ends no session.

    • Directory: a complete in-memory directory of every character, separate from the playtime view, which hides characters and shows aliases. It is updated on creation, edits, deletion, revival, ingest and saves, and it reports invalid or duplicate IDs. It is complete before the service registers.

    • Existing events stay: TFMCCore and ActivityTF use RPCharacters-specific events (created, class change, chat and others) for stats.

    Rollout

    1. TLibs gets the API, unit tests for every lifecycle case and the 100% coverage gate, followed by a release.

    2. RPCharacters gets CharacterTransitions, the directory, the service and the events, followed by a release. Tests cover reconnecting, Alice → Bob → Alice, a cancelled death, a death with no replacement, a save failure and shutdown.

    3. Pilot consumers move first, one PR each:

      • Gathering, for lookups.
      • Magic, for persisted per-character state through onEnd.
      • GunsAndGadgets, for cleanup against the outgoing session.
    4. Remaining consumers follow once the pilot holds up:

      • Research, Infestations and Surgery.
      • Thievery's trait checks.
      • Games' display names.
      • BirdMessenger and Cooking events.
      • SimpleFactions, which deletes its three disk readers, its probes, the join delay, the death re-check and its identity PluginEnableEvent hooks.

      Gathering, Research, Infestations and GunsAndGadgets can drop RPCharacters entirely. Magic, Surgery, Cooking, BirdMessenger, Games, Thievery, SimpleFactions, TFMCCore and ActivityTF keep it for other APIs.

    5. Release: before anything is deployed, the full set of pinned releases is started together on a local Paper lab.

    Not part of stage 2: turning PlayerManager's list into a map, Thievery's EvilRpPlays reflection, the RPCharacters ↔ SimpleFactions compile cycle (SimpleFactions#156) and the TFMCWeb Discord gate reflection.

  4. ryanbarlow97 commented on Oct 12, 2026

    @ryanbarlow97
    ContributorAuthor

    Stages 3 and 4

    Stage 4 is merged and released. #98 adds character selection with separate bodies and per-character progression, released in RPCharacters v2.17.0. TF-Minecraft/TFMCCore#45 adds the required resource-pack gate the selector waits for, released in TFMCCore v3.5.0. Docs: TF-Minecraft/Docs#182. Both features default off. Dev runs the equivalent test builds. Main has not been switched over.

    Stage 3 is merged as #99 (docs TF-Minecraft/Docs#192). tools/body_heirs.py builds the heir manifest, its checksum and a staff review report from plugins/RPCharacters/data. Reviewed overrides survive regeneration.

    Heir rule changed (decided 12 October). The heir is the account's active living character, not its most-played one, because the active character is wearing the current body. Without an active living character it falls back to the most-played living character, then the most-played character.

    On Main's data from 12 October: 250 accounts, 287 characters, 244 heirs, nothing blocked. Eleven heirs are not the most-played character; four of them are because the most-played character is dead. One account needs a staff decision because every character on it is dead, so the body would go to a dead character. Six accounts have no characters, and their inventories go to quarantine.

    Before Main cutover:

    • Settle the all-dead account and regenerate the manifest from the stopped server.
    • Turn on TFMCCore's resource-pack.require-on-join and RPCharacters' require-resource-pack in the same restart. RPCharacters alone shows every joining player a Leave-only failure dialog. Remove the ConditionalEvents tcore pack join sender at the same time, as on Dev.
    • Set collisions.max-entity-collisions: 0 in the selection world's paper-world.yml.
    • Update Main's permission-groups.yml to the 4/3/2/1-day cooldowns.
    • Deploy the whole stage 2 set with it (TLibs 2.3.1 and the consumers), since Main has none of it yet.

    Known minor gap: a staff /tcore pack <player> on a player whose pack failed loads the pack but leaves the selector showing the failure until the player presses Retry.

  5. ryanbarlow97 commented on Oct 12, 2026

    @ryanbarlow97
    ContributorAuthor

    JustinTheOne (every character dead) keeps the tool's default. The body goes to Ilvaran Thorne, as a dead character, and staff sort out anything he needs afterwards. No accounts on Main need a staff decision now.

    Dev now runs the released RPCharacters 2.17.0 and TFMCCore 3.5.0 in place of the stage 4 test builds, which were built from the same source. Dev restarted cleanly at 16:18 UTC. Main is unchanged until the later stages are ready.

  6. ryanbarlow97 commented on Oct 12, 2026

    @ryanbarlow97
    ContributorAuthor

    Stage 5 design

    Reviewed by Astra over four rounds (all P0/P1 findings closed). Implementation is starting: TLibs 2.4.0 (legacyHeir, legacyHeirsReady) is released; DenarEconomy's ledger is next.

    Stage 5 of #89, covering TF-Minecraft/SimpleFactions#156 and TF-Minecraft/DenarEconomy#37. v2 answered Astra's review of v1 (astra-review-1.md, findings cited as R1 #n). v3 adds the section "Round 2 answers" at the end for astra-review-2.md (R2 #n); where it conflicts with earlier text, the round 2 section wins.

    Ground rules

    • A character is its own person: membership, offices, votes, wages, taxes, ledgers and money belong to it. One player's characters may be in rival guilds.
    • Stage 5 ships with the stage 4 cutover as one set and has no mixed account/character mode.
    • Migration gives everything an account has to its reviewed heir (legacyHeir, the stage 3/4 body heir). Nothing is duplicated, nothing is deleted: anything that cannot be mapped is quarantined with its rights and money intact for staff (finding 4).
    • Every money movement is exactly-once and every multi-party movement is atomic (findings 1-3).

    Identity (TLibs 2.4.0, RPCharacters 2.18.0)

    • CharacterIdentity.legacyHeir(UUID ownerId) (TLibs PR Fix G-menu portrait alignment and Unknown-name formatting #43, default empty). RPCharacters answers from CharacterBodies.migrationHeir.
    • Characters.ready() already exists as available(); plugins that migrate wait for available() and for RPCharacters' bodies to report enabled (new CharacterIdentity.legacyHeirsReady(), default false; RPCharacters returns true once bodies loaded the manifest). Migration never runs when this is false; the plugin stays in a read-only "waiting for identity" state for migrated stores and logs SEVERE once (finding 4).
    • RPCharacters fixes lastPlayed to report dead characters as before but adds nothing else; nothing in stage 5 routes money by lastPlayed (finding 8).
    • Death replay: RPCharacters keeps deathId on the character; consumers reconcile instead of relying on delivery. SimpleFactions runs the death cleanup from CharacterDeathEvent and also on startup and hourly for any member key whose character's status is DEAD (finding 6). Completion is recorded per death in SimpleFactions' own store only after every store it touches is saved.

    DenarEconomy 0.3.0: a transactional ledger

    Store. One SQLite database, plugins/DenarEconomy/ledger.db (driver bundled with Paper; test scope in the POM), WAL mode, synchronous=FULL, all writes on the main thread through one connection.

    Tables:

    • accounts(id TEXT PRIMARY KEY, kind TEXT, owner TEXT, character TEXT, label TEXT, cents INTEGER NOT NULL). Account IDs: char:<characterId>:pouch, char:<characterId>:bank, guild:<guildId>, legacy:<accountUuid>:pouch|bank (unclaimed legacy money), server:mint (source/sink for coins entering or leaving as items and admin credits, so every posting balances).
    • operations(op_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, status TEXT NOT NULL, created INTEGER, reason TEXT).
    • legs(op_id TEXT, account TEXT, cents INTEGER).

    Posting. Ledger.post(opId, reason, legs...) runs one SQLite transaction: if op_id exists, compare the fingerprint (hash of reason + sorted legs) and return the stored result (APPLIED or REFUSED) without changing anything; a different fingerprint returns CONFLICT and logs. Otherwise check every debited account stays >= 0 (no overdraft exceptions, finding "simplify"), apply all legs, insert the operation and legs, commit. Legs always sum to zero. Operation IDs are kept forever (finding 1). Balance reads come from the database; online players get no separate cached copy, so there is nothing deferred to lose.

    Legacy claim. At cutover the old PlayerData/<accountUuid>.json files are imported in one transaction into legacy:<accountUuid>:* accounts (op import:<accountUuid>), and the JSON folder is renamed PlayerData.imported. Claiming is a normal posting claim:<accountUuid> moving the legacy balances to the heir's accounts, so a crash leaves either the legacy account or the heir holding the money, never both or neither (finding 3). Claims run for every account whose heir is known once legacyHeirsReady(); the rest stay in legacy: accounts, listed by /deco legacy, and staff assign them with /deco legacy assign <account> <character>. There is no automatic first-character rule (finding 3).

    API (net.tfminecraft.denareconomy.api.Denars):

    • long balance(String accountId), helpers pouch(CharacterRef), bank(CharacterRef), guild(String guildId) returning account IDs.
    • Result transfer(String opId, String reason, String from, String to, long cents) and Result post(String opId, String reason, List<Leg>).
    • Result earn(String opId, CharacterRef, String account, long cents, boolean taxable): credit from server:mint; when taxable it fires CharacterEarnMoneyEvent(opId, ref, cents) after commit. Tax handlers must be idempotent by opId (finding 10).
    • Amounts are long cents.

    Old calls. OfflineModifier, MoneyManager.addMoney*, changeBal, PlayerManager.get(...).getPouch()/getBank() keep compiling for one release but act only on an online player's current character; for offline accounts they refuse and log the caller (finding 8). Account objects returned by PlayerManager.get become read-only views; change/setBal throw (finding 10, direct mutation by BarterShops and SimpleFactions). All in-repo callers move to Denars in this stage, so nothing relies on the wrappers at cutover.

    Coins. Pouch to item withdrawals, coin pickup into the pouch, and permadeath pouch drops post against server:mint with operation IDs derived from the event (coin:<uuid-of-item-entity> for pickups; a fresh random ID for player commands, since a command is one intent).

    Dead characters. Credits already owed (wages, dividends, refunds) still post to the dead character's accounts; debits from it are refused except by staff and by stage 6 estates (finding 6).

    Not in stage 5: escrow for item-for-money trades (#37 keeps it open).

    SimpleFactions 4.0.0: members are characters

    Member key. Stored member strings become lower-case character ID strings. Internally, a MemberKey value type wraps them in new code; persistence keeps strings (finding "simplify"). Helpers in identity/Members:

    • key(Player): the current session's character ID, or none (a player with no session is a stranger).
    • display(key): character name (offline via Characters.info). Replaces CharacterNames and every place that prints a member.
    • online(key): the online Player whose current character is that key, else empty. Replaces Bukkit.getPlayer(name) / getOfflinePlayer(name) on member strings, including Warband, raid elimination and loot (finding 7).
    • ref(key): CharacterRef for money.
    • All 220 name lookups and the object-level isLeader/isMember/addMember/setLeader take keys; menus capture the opener's CharacterSession and recheck Characters.isCurrent on every click.

    Combat state. Warbands, raid muster, battle votes, BattleManager and their persisted files stay keyed by account UUID: they are live combat sessions, a player is one character per session, and switching means a logout that already ends participation (finding 7). Participation start records the session's CharacterRef alongside the UUID; BattleStartedEvent/BattleEndedEvent gain getParticipants() (captured refs) while getParticipantIds() stays.

    Treasury. Bank becomes a thin view over the DenarEconomy account guild:<guildId>. Every deposit/withdraw is a Denars posting with an operation ID from its caller. GuildData.balance is imported once (op import-guild:<guildId>) and then ignored (finding 2).

    Settlement. The daily settlement for a guild is one Denars.post with ID sf:settle:<day>:<guildId> containing every leg: guild income and expenses, dividends, wages, vehicle upkeep and taxes for that guild. SimpleFactions computes the plan, posts it, then clears pending wages and tax buckets and saves. After a crash it recomputes the same day's plan; the ledger returns the stored result for the same ID, and SimpleFactions then clears and saves. If the recomputed fingerprint differs (state changed in between), the stored result stands and the mismatch is logged for staff. Failed legs never happen inside a posting (it is all or nothing); a guild that cannot cover its debits pays nothing that day and keeps its obligations pending, as today's insufficient-funds path does (finding 2). Personal vehicle upkeep for each payer character is its own posting sf:upkeep:<day>:<vehicleId>; fees use sf:fee:<vehicleId>:<event>:<nonce-stored-with-the-request>.

    Migration. A single migration manifest plugins/SimpleFactions/migrations/character-v1.json records per store (Data/<faction>.json, Wars/, Warbands/, Battles/, Cache/*) whether it has been migrated, with the backup path and file hash (finding 5). It runs only when legacyHeirsReady(); otherwise SimpleFactions loads but keeps every not-yet-migrated store untouched and blocks commands that would change membership, with a clear message. Mapping per name: name -> account UUID (usercache/OfflineModifier names) -> legacyHeir. Several names resolving to one account merge: membership deduplicates (keeping the earliest list position), monetary maps sum, votes keep the first (finding 5). Unmapped names are moved, with their roles and money, into migrationQuarantine on the faction file (role, original name, amounts), never dropped; staff assign them with /f admin assign <name> <character> which replays the original roles. Backups go to migrations/backup/<store> before any write.

    Death. Reconciled as above. A dead character leaves every guild, office, council seat, candidacy, vote, mercenary roster and wage override, and its warband if it is online at the time. Owed wages and dividends stay pending and pay to its (frozen) accounts at the next settlement before the recipient is removed from any payout list. Leader succession: the longest-serving remaining member by list order (join order). No members left: the guild disbands through the existing empty-guild path; its treasury stays in its guild: account for staff. Ruler death: elective governments start an election; others pass the seat to the longest-serving member of the ruling guild.

    Mercenaries. The company gate checks the enrolled character's own mercenary trait, not any living character on the account (finding 12).

    Vehicles. Cache/vehicles_registry.json and vehicle_fees.json store the paying character key; lastOwners becomes keys. Upkeep finds the payer from the registry row, not VehicleFramework's player_<username> tag (finding 11). Existing rows map account -> heir. VehicleFramework tags stay for stage 6.

    Export. Compiler writes an explicit public projection (leader, leader character, leader character of, member count and display names, provinces, banner, etc.), never the raw faction file, so no member keys, votes or ledgers leave the server (finding 12). The website keeps its current field names.

    API. Overloads by Player and by character ID for getByMember, getGuildByMember, getByLeader, getGuildByLeader; String versions take member keys. Guild.isLeader(Player).

    Consumers (all released with the set)

    Plugin Change
    RPCharacters Wallet on Denars with character refs. Paid change: write the pending-payment record (paying character, amount, op IDs paid:<characterId>:<stage>:<nonce> and refund:<same>) before the debit posting; refund replays with the stored ID (finding 9). Character select shows that character's balances and guild. Realm chat resolver and battle bridge use the Player overloads. legacyHeir + legacyHeirsReady.
    BarterShops Shop owner becomes a character (pulled forward from stage 6, since proceeds must reach a specific character): new shops store the creating character; existing shops map to the owner account's heir. Buy and sell are one Denars.transfer each with ID shop:<shopId>:<txNonce>, and the item moves only after the posting is APPLIED (finding 10). Embargo by owner character.
    Marketblock, TrialRooms Denars.earn(..., taxable=true) with event-derived IDs.
    Games Citizen tax through earn's event; guild table leader check by Guild.isLeader(Player).
    Thievery Robbery is one Denars.transfer between the two sessions' pouches. Guild lookups by Player overloads; GuildAccessCooldown maps member keys back to owner accounts for the account-wide cooldown. Lock ownership stays account-based until stage 6, noted as a known leak (finding 11).
    Dowsing Player overloads; leader messages via Members.display/online. Node upkeep via the guild account.
    Cooking, TFMCCore, ActivityTF Compatible APIs; pin bumps.
    ProvinceSystem Verify on a preview with migrated Dev data.

    Order and rollout

    1. TLibs 2.4.0 -> RPCharacters 2.18.0 (provider, wallet, paid-change intent).
    2. DenarEconomy 0.3.0.
    3. SimpleFactions 4.0.0 on a stage5 integration branch in parts: (a) Members, migration manifest and data mapping; (b) lookups and menus; (c) treasury, settlement, payouts, vehicles; (d) death and succession; (e) API, events, export. Merged together.
    4. Consumers.
    5. Rehearsal: copy Main's data into a local Paper lab, run the full set, check money totals before and after (sum of all accounts, guild treasuries and legacy accounts equals today's sum of pouches, banks and guild balances), member counts and quarantines. Then Dev. Rollback restores bodies, ledger and faction data together from one backup set (finding "sequencing").

    Round 2 answers (v3; overrides earlier text)

    R2 #1, #3 Settlement. Settlement keeps today's economics (proration of wages and dividends, loan funding from what remains, liquidation) but splits calculation from effects. Once a day SimpleFactions computes one realm-wide plan for every guild together: all ledger legs (so an interguild transfer appears once, with both ends), and the exact list of obligations it consumes (pending wage entries, tax buckets, loan instalments, contract buckets, by stable IDs) and the state changes to apply (loan balances, liquidation steps). Ledger.populateDailyTransfers and friends stop mutating; they return plan entries. The plan is written to settlements/<day>.json (atomic, fsync) with its hash before anything is posted. Then one posting sf:settle:<day> carries all legs. After APPLIED the plan's state changes are applied and saved, and the plan file is marked done. Recovery reads the plan file and never recomputes: not posted -> post; posted and not done -> apply the plan's changes; done -> nothing. A plan whose posting is REFUSED (it can only be refused if a debited account would go negative, which the planner already prevents) is logged and its obligations stay pending.

    R2 #2 Mint. System accounts (server:mint, and server:tax-void for tax with no jurisdiction) may go negative; every other account may not. Legs are aggregated per account with Math.addExact before checks. The rehearsal invariant compares the sum of all non-system accounts with the legacy money supply.

    R2 #4 Tax. Tax is computed inside the posting. DenarEconomy exposes a TaxPolicy service: given the earning character and gross cents, return (taxAccount, taxCents). SimpleFactions registers it: tax goes straight to the character's realm treasury account (guild:<incarnation> of the realm's base guild) at the realm's rate. Denars.earn(..., taxable) posts mint -> character net and mint -> treasury tax in one transaction. SimpleFactions' citizenTaxes buckets and the PlayerEarnMoneyEvent tax path are removed; the citizens ledger history is read from postings with reason tax. Denars.trade(opId, buyer, seller, cents, taxable) does the same for BarterShops' two directions. Derived legs share the parent operation; there are no derived operation IDs.

    R2 #5 Every mutation caller. Before implementation, an inventory (part of SimpleFactions PR (c)) lists every Bank.deposit/withdraw, Account.change, OfflineModifier.apply and MoneyManager mutation in all repos, and each compound operation (loan grant = borrower credit + issuer debit; fee + refund; Games table house bank; Dowsing purchase/refund/upkeep) becomes one posting with a durable intent ID stored before side effects. Mutating methods return the Result; callers act only on APPLIED.

    R2 #6 Guild incarnations. Every guild gets an immutable incarnation UUID (assigned at migration or creation). Ledger accounts and operation IDs use it, never the name. Disbanding leaves the old incarnation's account for staff; a new guild with the same name gets a new incarnation.

    R2 #7 Paid change. Recovery refunds only when Denars.status(debitOpId) is APPLIED; a pending record whose debit is absent or REFUSED is discarded without a refund. The refund uses its own stable ID.

    R2 #8 Migration gating. While legacyHeirsReady() is false or any store is unmigrated, SimpleFactions enables in a waiting state: no timers, listeners, saves, autosaves, shutdown saves or API answers that touch factions (the API returns empty and logs once). When identity becomes ready (on PluginEnableEvent or a 5 s poll) it migrates and then starts everything. Per store: copy the original to migrations/backup/ with its hash, record in-progress in the manifest, write the new file atomically, record done. On restart an in-progress store is restored from its backup and redone. Quarantine records keep a stable record ID, the store and object path, and the complete original JSON fragment; unresolved leaders block succession and disbanding for that guild until staff resolve them. /f admin quarantine list|assign <recordId> <character> resolves.

    R2 #9 Death. On a death (event or reconciliation), SimpleFactions first writes deaths/<deathId>.json listing everything the character holds and what each step will do (guilds and seats to leave, successor chosen, wages and dividends owed with amounts). Then each step runs and is ticked off in that file; the file is marked complete last. Startup replays incomplete death files regardless of current membership. Reconciliation scans member keys for DEAD characters with no death file.

    R2 #10 Combat and locks. Warband rejoin state stores the captured CharacterRef; on CharacterSessionStartEvent it is restored only for that character and dropped otherwise (join no longer restores it). Thievery's offline owner-guild check and LockAccess owner match use the lock owner account's legacy heir until stage 6 moves lock ownership to characters; the cross-character access is listed on #89 as a known stage 6 leak.

    R2 #11 BarterShops. Shops get a stable shopId (the existing file UUID, kept on the object). Owner becomes a CharacterRef; mayBreak and every owner check compare the breaker's current character (staff bypass unchanged). Same readiness gating and quarantine as SimpleFactions (shops whose owner has no heir are frozen, not deleted). Migration is per shop file with backup.

    R2 #12 SQLite. Reuse TLibs' SqliteDatabase if its transaction helper fits; reads and writes on the main thread only; synchronous=FULL. The rehearsal measures posting latency under the Main-like load and the 99-bot Dev harness; if a posting exceeds 2 ms at p99, switch to synchronous=NORMAL (durable against process crashes, not power loss). On any SQLite error DenarEconomy refuses further postings and logs SEVERE; it never falls back to memory. Close on disable. Tests use real temporary databases, including reopen and rollback cases. Compatibility wrappers that would throw are cut: every caller moves to Denars in this stage.

    Round 3 answers (v3.1; overrides earlier text)

    R3 #1 Insolvency. Guild treasury accounts (guild:<incarnation>) may go negative; character accounts and tax holding accounts may not. A negative treasury is today's bankruptcy (Guild.isBankrupt reads the account balance). Imported negative GuildData.balance values import as negative. The settlement plan applies expenses, then liquidation, exactly as today; any remaining deficit stays as the negative balance, and interguild obligations that today proceed regardless keep doing so. One guild's deficit therefore never refuses the global posting.

    R3 #2 Tax. Tax buckets stay, as ledger accounts: Denars.earn(taxable) posts the tax leg into guildtax:<incarnation> (a non-system account) instead of straight to the treasury. Settlement reads each holding account's balance as the citizen-tax base (so gross taxable income and tribute calculations are unchanged) and moves it to the treasury inside the settlement posting. Existing citizenTaxes buckets import once into those holding accounts from server:mint (op import-tax:<incarnation>), since that money was already deducted from players and is owed to the guild.

    R3 #3 Waiting state for dependents. SimpleFactions exposes SimpleFactionsApi.ready() and fires SimpleFactionsReadyEvent once migrated and started. Before that, lookups return a distinct unavailable result (Optional-based overloads) and the old nullable methods return null but are documented as unsafe for persistence decisions. Dowsing defers node loading, claiming and saving until ready (listens for the event); its resolution never clears an owner while SimpleFactions is not ready. Games and Thievery only query at runtime and treat not-ready as "no access".

    R3 #4 Durability. The ledger keeps synchronous=FULL permanently; the latency fallback is removed. If the rehearsal shows unacceptable tick cost, that is a separate decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions