Repository navigation
Plan: make each character its own person across the server #89
Description
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
- Fix the bugs that exist today (list below), each as its own small PR. They do not depend on the rest of this plan.
- Identity layer. Add
CharacterIdentity,CharacterRefandCharacterScopedin TLibs, and implement them in RPCharacters. Move Gathering, Magic, BirdMessenger and Cooking onto the shared interface. - Migration manifest. Build the heir table and the exceptions listed under Migration for staff to review.
- Select screen and separate bodies in RPCharacters, with per-character MMOCore progress. Prototype on Dev first.
- 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.
- 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.
- Remove the account-keyed paths once nothing calls them.
Bugs that exist today
- Permakill fires
CharacterActivatedEventwith 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.
RosterSyncServicereads 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
ryanbarlow97 commentedon Oct 11, 2026
Stage 1 PRs, one per bug. All passed their builds and tests and none are merged yet.
Found while fixing these, still open:
fix: cancel a reload when the character switches or the player quits (#…
fix: tie a research project to the character who started it (#15)
docs: research projects belong to the character who started them (#152)
ryanbarlow97 commentedon Oct 11, 2026
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:
/setblockwithdestroy, can still remove one.Still open:
CharacterActivatedEventfires. Surgery now listens for the permakill event instead, but Magic and focus still save only on quit.permission-groups.ymlis updated and the server restarts.ryanbarlow97 commentedon Oct 11, 2026
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
MISSINGmeans. 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 atLOWESTandPluginEnableEventhooks. 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 checksCharacters.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.CharacterStatusisALIVE,DEADorMISSING.CharacterInfo.alive()is a convenience forALIVE.CharacterInfois 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.CharacterIdentityis the service interface, registered in Bukkit'sServicesManager:Optional<CharacterSession> currentSession(Player)andboolean 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.nameis the real name.Charactersis 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
CharacterSessionorCharacterRef: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 reasonSWITCH,QUIT,DEATHorSHUTDOWN. 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.deathIdis 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 bydeathId, and consumers must treat it as idempotent. The cancellableCharacterPermakillEventin 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, andgetreturns 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 optionalonEnd(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
CharacterTransitionsclass handles join, switch, quit, permakill and shutdown, in this order:The join path stops firing activation twice. RPCharacters' own focus listener moves onto the new events.
Permakill: the death is saved with a
deathIdand checked for success beforeCharacterDeathEventfires. 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
TLibs gets the API, unit tests for every lifecycle case and the 100% coverage gate, followed by a release.
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.Pilot consumers move first, one PR each:
onEnd.Remaining consumers follow once the pilot holds up:
PluginEnableEventhooks.Gathering, Research, Infestations and GunsAndGadgets can drop RPCharacters entirely. Magic, Surgery, Cooking, BirdMessenger, Games, Thievery, SimpleFactions, TFMCCore and ActivityTF keep it for other APIs.
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'sEvilRpPlaysreflection, the RPCharacters ↔ SimpleFactions compile cycle (SimpleFactions#156) and the TFMCWeb Discord gate reflection.Add the shared character identity and session API (#41)
Document the shared character identity and session API (#158)
ryanbarlow97 commentedon Oct 12, 2026
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.pybuilds the heir manifest, its checksum and a staff review report fromplugins/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:
resource-pack.require-on-joinand RPCharacters'require-resource-packin the same restart. RPCharacters alone shows every joining player a Leave-only failure dialog. Remove the ConditionalEventstcore packjoin sender at the same time, as on Dev.collisions.max-entity-collisions: 0in the selection world'spaper-world.yml.permission-groups.ymlto the 4/3/2/1-day cooldowns.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.ryanbarlow97 commentedon Oct 12, 2026
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.
ryanbarlow97 commentedon Oct 12, 2026
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
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).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 fromCharacterBodies.migrationHeir.Characters.ready()already exists asavailable(); plugins that migrate wait foravailable()and for RPCharacters' bodies to report enabled (newCharacterIdentity.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).lastPlayedto report dead characters as before but adds nothing else; nothing in stage 5 routes money bylastPlayed(finding 8).deathIdon the character; consumers reconcile instead of relying on delivery. SimpleFactions runs the death cleanup fromCharacterDeathEventand 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: ifop_idexists, compare the fingerprint (hash of reason + sorted legs) and return the stored result (APPLIEDorREFUSED) without changing anything; a different fingerprint returnsCONFLICTand 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>.jsonfiles are imported in one transaction intolegacy:<accountUuid>:*accounts (opimport:<accountUuid>), and the JSON folder is renamedPlayerData.imported. Claiming is a normal postingclaim:<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 oncelegacyHeirsReady(); the rest stay inlegacy: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), helperspouch(CharacterRef),bank(CharacterRef),guild(String guildId)returning account IDs.Result transfer(String opId, String reason, String from, String to, long cents)andResult post(String opId, String reason, List<Leg>).Result earn(String opId, CharacterRef, String account, long cents, boolean taxable): credit fromserver:mint; when taxable it firesCharacterEarnMoneyEvent(opId, ref, cents)after commit. Tax handlers must be idempotent byopId(finding 10).longcents.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).Accountobjects returned byPlayerManager.getbecome read-only views;change/setBalthrow (finding 10, direct mutation by BarterShops and SimpleFactions). All in-repo callers move toDenarsin 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:mintwith 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
MemberKeyvalue type wraps them in new code; persistence keeps strings (finding "simplify"). Helpers inidentity/Members:key(Player): the current session's character ID, or none (a player with no session is a stranger).display(key): character name (offline viaCharacters.info). ReplacesCharacterNamesand every place that prints a member.online(key): the onlinePlayerwhose current character is that key, else empty. ReplacesBukkit.getPlayer(name)/getOfflinePlayer(name)on member strings, includingWarband, raid elimination and loot (finding 7).ref(key):CharacterReffor money.isLeader/isMember/addMember/setLeadertake keys; menus capture the opener'sCharacterSessionand recheckCharacters.isCurrenton every click.Combat state. Warbands, raid muster, battle votes,
BattleManagerand 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'sCharacterRefalongside the UUID;BattleStartedEvent/BattleEndedEventgaingetParticipants()(captured refs) whilegetParticipantIds()stays.Treasury.
Bankbecomes a thin view over the DenarEconomy accountguild:<guildId>. Every deposit/withdraw is aDenarsposting with an operation ID from its caller.GuildData.balanceis imported once (opimport-guild:<guildId>) and then ignored (finding 2).Settlement. The daily settlement for a guild is one
Denars.postwith IDsf: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 postingsf:upkeep:<day>:<vehicleId>; fees usesf:fee:<vehicleId>:<event>:<nonce-stored-with-the-request>.Migration. A single migration manifest
plugins/SimpleFactions/migrations/character-v1.jsonrecords 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 whenlegacyHeirsReady(); 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, intomigrationQuarantineon 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 tomigrations/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
mercenarytrait, not any living character on the account (finding 12).Vehicles.
Cache/vehicles_registry.jsonandvehicle_fees.jsonstore the paying character key;lastOwnersbecomes keys. Upkeep finds the payer from the registry row, not VehicleFramework'splayer_<username>tag (finding 11). Existing rows map account -> heir. VehicleFramework tags stay for stage 6.Export.
Compilerwrites 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
Playerand by character ID forgetByMember,getGuildByMember,getByLeader,getGuildByLeader;Stringversions take member keys.Guild.isLeader(Player).Consumers (all released with the set)
Denarswith character refs. Paid change: write the pending-payment record (paying character, amount, op IDspaid:<characterId>:<stage>:<nonce>andrefund:<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.Denars.transfereach with IDshop:<shopId>:<txNonce>, and the item moves only after the posting isAPPLIED(finding 10). Embargo by owner character.Denars.earn(..., taxable=true)with event-derived IDs.earn's event; guild table leader check byGuild.isLeader(Player).Denars.transferbetween the two sessions' pouches. Guild lookups by Player overloads;GuildAccessCooldownmaps 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).Members.display/online. Node upkeep via the guild account.Order and rollout
stage5integration 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.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.populateDailyTransfersand friends stop mutating; they return plan entries. The plan is written tosettlements/<day>.json(atomic, fsync) with its hash before anything is posted. Then one postingsf:settle:<day>carries all legs. AfterAPPLIEDthe 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 isREFUSED(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, andserver:tax-voidfor tax with no jurisdiction) may go negative; every other account may not. Legs are aggregated per account withMath.addExactbefore 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
TaxPolicyservice: 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)postsmint -> character netandmint -> treasury taxin one transaction. SimpleFactions'citizenTaxesbuckets and thePlayerEarnMoneyEventtax path are removed; the citizens ledger history is read from postings with reasontax.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.applyandMoneyManagermutation 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 theResult; callers act only onAPPLIED.R2 #6 Guild incarnations. Every guild gets an immutable
incarnationUUID (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)isAPPLIED; a pending record whose debit is absent orREFUSEDis 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 (onPluginEnableEventor a 5 s poll) it migrates and then starts everything. Per store: copy the original tomigrations/backup/with its hash, recordin-progressin the manifest, write the new file atomically, recorddone. On restart anin-progressstore 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>.jsonlisting 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; onCharacterSessionStartEventit is restored only for that character and dropped otherwise (join no longer restores it). Thievery's offline owner-guild check andLockAccessowner 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 aCharacterRef;mayBreakand 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'
SqliteDatabaseif 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 tosynchronous=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 toDenarsin 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.isBankruptreads the account balance). Imported negativeGuildData.balancevalues 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 intoguildtax:<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. ExistingcitizenTaxesbuckets import once into those holding accounts fromserver:mint(opimport-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 firesSimpleFactionsReadyEventonce migrated and started. Before that, lookups return a distinctunavailableresult (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=FULLpermanently; the latency fallback is removed. If the rehearsal shows unacceptable tick cost, that is a separate decision.