Skip to content

Nanny NPC system

Nanny NPC system

The largest feature in the plugin. Citizens2-backed NPC caregiver that watches over wards: changes their diapers, feeds them, navigates to find them, chats, applies discipline, and (optionally) gates an AI chat tier behind Patreon/Subscribestar membership. Feature-complete through Phase 6.

Design spec (read this first when starting any Nanny work): docs/superpowers/specs/2026-04-30-nanny-npc-design.md

Membership integration spec: docs/superpowers/specs/2026-04-30-membership-integration-design.md


Phase 1 — Core NPC (shipped)

  • Component layout under com.storynook.nanny:
    • NannyData — pure data + per-Nanny YAML at <dataFolder>/nannies/{nannyUUID}.yml. Not part of PlayerStats — separate file format like DiaperPails. Inner enums: MoodTier {SWEET, CARING, STRICT, WARDEN, CUSTOM}, ChestMode, CraftingMode, ChatRespondTo, ChatTier. Adding a field touches only save() + load() (single class, no LoadStats/SavePlayerStats/CreateDefaultStats triplet).
    • NannyEntity — Citizens2 NPC wrapper. Uses CitizensAPI.getNPCRegistry().createNPC(EntityType.PLAYER, name) + SkinTrait.setSkinName(playerName) to copy another player’s skin (URL-based skins via setTexture(value, signature) are a future enhancement). All public methods early-return when !plugin.citizensEnabled.
    • NannyManager — orchestrator + Listener. Owns allNannies (UUID→NannyData), activeNannies (UUID→NannyEntity), wardToNannies index, and pendingNannyCreations (player UUID → spawn location, used to bridge the Nanny-Egg-prompts-for-name flow). Handles PlayerJoinEvent/PlayerQuitEvent lifecycle, PlayerInteractEvent for the Nanny Egg, and EntityDamageEvent/EntityTargetEvent to make Nannies invincible and mob-aggro-immune. Public API: getNannyForOwner, addWard, removeWard, deleteNanny, setFollowMode, updateNannyName, updateNannySkin, setHome, summonToPlayer, createNanny. Plugin.getNannyManager() is the canonical accessor.
    • NannyEventLog — Phase-1 stub: log(NannyEventType, wardUUID, details) appends to in-memory list (max 100), async YAML write. Full event types and AI context wiring is Phase 4.
  • Lifecycle: Nanny spawns when any ward or owner is online; despawns when all relevant players are offline. If owner has not logged in within Nanny_Owner_Timeout_Days (default 14), the Nanny goes dormant — won’t spawn even for wards — until the owner returns. Server-wide cap via Nanny_Max_Server_Nannies (default 50).
  • Multi-ward model: one owner per Nanny; owners add wards via NannyMenu’s Wards tab. One Nanny per owner.
  • Nanny Egg (items/Nanny.createNannyEgg(String displayName)): admin-granted only via /nanny give <player> [name]. Material.ZOMBIE_SPAWN_EGG, CustomModelData 629001, PDC key nanny_egg (PersistentDataType.BYTE). Not craftable — sold in the server shop. If the egg has been renamed (display name ≠ “Nanny Egg”), that name is used at spawn; otherwise the player is prompted via awaitingInput type nannyName.
  • NannyMenu (in com.storynook.menus) follows the SettingsMenu static-opener + InventoryClickEvent pattern. Three tabs in Phase 1: General (rename / skin / follow / sethome / summon), Wards (paginated player list), Behavior (mood tier — visual only in Phase 1, chat enabled, chat scope cycle). Tab navigation reads stripped display-name "Tab: General" etc.
  • NannyCommand (in com.storynook.Commands) — Phase-1 subcommands give | list | remove | info | settings | sethome | summon | reload. Routed through CommandHandler.onCommand via the nanny entry in dualCommands; CommandHandler delegates to plugin.getNannyCommand().handle(sender, args) (and tabComplete similarly).
  • PlayerEventListener.onPlayerChat handles two awaiting-input types: nannyName (creates a Nanny if pendingNannyCreations has a location for the player; otherwise renames the owned Nanny) and nannySkinUrl ("default" clears the skin). Both branches re-open the Nanny menu’s General tab on success.
  • Config: Nanny: block in config.yml is nested, not flat. Loaded into globalConfig as Nanny (boolean from Nanny.enabled), Nanny_Owner_Timeout_Days, Nanny_Max_Server_Nannies, Nanny_Default_Home_Radius, Nanny_Default_Mood, Nanny_Default_Chest_Mode, Nanny_Default_Crafting_Mode, and a flattened Nanny_Chat_* family (including the Nanny_Chat_AI_* keys reserved for Phase 4). The old root-level Nanny: true flag and NannyNPCskin: setting were removed from config.yml.
  • nanny_messages.yml ships a stub with care_reminder, keyword_wet, found_ward keyed by mood tier (SWEET/CARING/STRICT/WARDEN). Wired in Phase 4.
  • CustomModelData allocation: 629001 is now Nanny Egg. Add this to the resource pack’s font/item JSON if changing.

Phase 2 — Care Behaviors (shipped)

  • NannyCareEngine (in com.storynook.nanny) — single repeating BukkitTask (40 ticks). For each active Nanny × each online ward (and the owner if not also a ward), evaluates four priority-ordered conditions and dispatches one task at a time: (1) wetness/fullness > changeThreshold → change, (2) underwearType == 0 → equip, (3) hunger < feedThreshold → feed, (4) hydration < hydrationThreshold → hydrate (melon slice). No real navigation in Phase 2 — Nanny entity.teleportTo(ward.getLocation()) to act. The engine starts on the first Nanny spawn and stops when the last despawns. Citizens2-guarded (no-op if plugin.citizensEnabled == false). Started/stopped from NannyManager.spawnNanny/despawnNanny.
  • NannyInventoryManager (in com.storynook.nanny) — supply chain abstraction. 18-slot per-Nanny inventory stored on NannyData.personalInventory (ItemStack[]) and serialised via the YAML personalInventory: list. Smart filter (isUsableItem) accepts 626xxx CustomModelData diaper-family items, a small food whitelist (bread/cookie/melon/apple/golden_apple/cooked meats/baked_potato/carrot), and any leather leggings. takeOne(NannyData, predicate) checks personal inventory first, then chests according to chestMode (INVENTORY_ONLY/SELECTED/ALL). tryCraftFood (BASIC: bread or cookies) and tryCraftDiaper (ALL/EVIL: stuffer 626007 + tape 626008 + leather leggings → 626006) are simulated — they consume ingredients and return a fresh ItemStack without opening a real crafting GUI, gated on a crafting table block existing within homeRadius of homeLocation. EVIL-specific recipes (Curse of Binding, laxative food) are deferred to Phase 5; in Phase 2 EVIL is treated equivalent to ALL. prepareFollowSupplies(NannyData) tops up the personal inventory to ≥ 3 clean diapers + ≥ 3 food before the Nanny departs home in follow mode.
  • New NannyData fields: changeThreshold (default 70), feedThreshold (default 14), hydrationThreshold (default 30), personalInventory (ItemStack[18]). All persisted via NannyData.save/load.
  • New static helpers (extracted to make the existing event-driven listeners callable from the autonomous Nanny path):
    • Changing.applyChange(Player target, ItemStack cleanDiaper) — performs the diaper-change stat mutation (delegates to existing private resetAndUpdateStats). Replaces the inline stat block in handleInteraction. Why a non-actor signature: handleRightClickHold reads the actor’s mainhand every tick and runs a boss-bar UI on the actor — a Citizens2 NPC actor would NPE. The helper bypasses all actor-side UI.
    • FeedingAction.applyFeed(Player target, ItemStack food) — applies food/laxative/melon-slice stat side effects without the caregiver-auth gate. FeedingAction.plugin was made static to enable the static helper (matches the existing static-plugin anti-pattern).
    • EquipArmor.applyEquip(Player target, ItemStack legging) — thin delegation to Changing.applyChange (the equip-on-empty case is functionally a “change” in this domain). Existing equipArmor(sender, target) (player command for non-diaper armor) is unchanged.
    • DiaperPail.deposit(Location origin, double radius, ItemStack soiled) — finds the nearest pail ArmorStand (custom name prefix Pail_<UUID>) within radius blocks, loads its YAML via loadInventory, adds the item via Inventory.addItem, saves via saveInventory. Used by NannyCareEngine.doChange to dispose of the soiled diaper after a change.
  • New menu tab: Supplies (NannyMenu.openSupplies, title "Nanny Supplies"). 4-row inventory: row 0 = tabs (4 tabs now), row 1 = chest mode cycler / crafting mode cycler / 3 threshold paper items (click to prompt via awaitingInput), rows 2–3 = the 18-slot personal-inventory editor (real Bukkit drag-and-drop slots). The personal inventory persists on InventoryCloseEvent for TITLE_SUPPLIES. Three new awaitingInput types in PlayerEventListener.onPlayerChat: nannyChangeThreshold, nannyFeedThreshold, nannyHydrationThreshold (clamped 0–100/0–20/0–100 respectively, save and re-open the Supplies tab on confirm).
  • New Nanny.Default_Change_Threshold / Default_Feed_Threshold / Default_Hydration_Threshold config keys, loaded into globalConfig as Nanny_Default_*.
  • NannyManager exposes getCareEngine(), getInventoryManager(), getEventLog(UUID). getEventLog lazily instantiates a per-Nanny NannyEventLog and caches it in eventLogs (Map<UUID, NannyEventLog>).

Phase 3 — Navigation (shipped)

  • NannyNavigator (in com.storynook.nanny) — per-Nanny Citizens2 Navigator wrapper; one instance is created/started in NannyManager.spawnNanny and stopped/removed in despawnNanny. Exposes navigateTo(Location), setFollowTarget(Player) (entity-follow, no fixed target), seekTo(Player) (navigate + mark seekingWardUUID + log SEEKING_WARD), cancelNavigation(), isNavigating(), getSeekingWardUUID(), clearSeekingWard(). Runs a 10-tick BukkitTask that does two things: (1) stuck-teleport fallback — if currentTarget is set but Citizens2 is no longer navigating (cancelled by stationaryTicks(600) or arrival) AND the NPC is > 3 blocks from target, teleport to target and log STUCK_TELEPORT; (2) door/lever scan — 2-block radius around the NPC for Material.OAK_DOOR/SPRUCE_DOOR/BIRCH_DOOR/JUNGLE_DOOR/ACACIA_DOOR/DARK_OAK_DOOR/CRIMSON_DOOR/WARPED_DOOR/IRON_DOOR and LEVER. Doors → Openable.setOpen(true), auto-close after 60 ticks. Levers → toggle Powerable.setPowered, auto-restore after 60 ticks. Iron door without an adjacent lever → wait 100 ticks then teleport past currentTarget. pendingRestore set keys (world,x,y,z) prevent double-toggling. All Citizens2 calls behind plugin.citizensEnabled; navigateTo/setFollowTarget fall back to teleport when disabled.
  • NannyCareEngine upgraded to navigate-then-act: evaluateAndAct now computes needsChange/Equip/Feed/Hydrate flags first, calls navigator.navigateTo(ward.getLocation()) if !isWithinActionRange(entity, ward) (3-block threshold), and acts only on a later cycle once close. The four action methods (doChange/doEquipDiaper/doFeed/doHydrate) no longer teleport. Added per-tick checkHomeEnforcement (when not in follow mode and homeWorld is set, navigate back to home if outside homeRadius; teleport if in wrong world; logs RETURNED_HOME), checkFollowBoundary (when in follow mode and owner > homeRadius × 2 from home, set followMode=false, navigate home, send “I’ll wait here for you!” message, log RETURNED_HOME), checkSeekArrival (when ward UUID matches navigator.getSeekingWardUUID() and within 3 blocks, send “Found you! ♥” and log FOUND_WARD; if not navigating, re-seek; cross-world → teleport).
  • NannyManager.setFollowMode(uuid, follow, ward) now calls inventoryManager.prepareFollowSupplies(data) before enabling follow, then dispatches to navigator.setFollowTarget(ward) or navigator.cancelNavigation(). Falls back to entity.setFollowMode(follow, ward) only when navigator is null (Citizens2 absent or Nanny not yet spawned).
  • NannyManager.onPlayerJoin triggers navigator.seekTo(player) for every Nanny where seekEnabled is on AND the joining player is > 10 blocks (sq dist > 100) or in a different world from the Nanny. Cross-world: teleport Nanny to ward’s world first, then seekTo. Owner timeout/dormancy logic from Phase 1 still runs first — seek only fires when the Nanny is being spawned for this player.
  • NannyEntity exposes getLocation() (current world location, null if not spawned) and getNpc() (raw Citizens2 NPC reference, null if not created).
  • NannyData adds seekEnabled (boolean, default true, persisted as seekEnabled: in YAML).
  • NannyEventLog.NannyEventType adds SEEKING_WARD and STUCK_TELEPORT.
  • New menu item: COMPASS “Hide and Seek” toggle in the Behavior tab (slot 25), toggles data.setSeekEnabled(...) on click and re-opens the tab.

Phase 4 — Chat & Event Log (shipped)

  • NannyChatEngine (in com.storynook.nanny) — Listener for AsyncPlayerChatEvent (skipped when plugin.VentureChat is true; NannyVentureChatHook delegates back via fireTriggers(Player, String)). Per-Nanny throttle (default 30s, key Nanny_Chat_AI_Cooldown_Seconds). Skips messages with < Nanny_Chat_Min_Words words. For each active Nanny in Nanny_Chat_Local_Radius of speaker: logs WARD_CHAT (always), then if chatEnabled + chatRespondTo permits + throttle clear, evaluates triggers (name mention → greeting; keyword stem wet/messy/hungry/thirsty/tired/cutekeyword_<stem>; Nanny_Chat_Ambient_Chance percent roll → idle_ambient). Picks a mood-keyed response from nanny_messages.yml (CUSTOM tier falls back to CARING; missing tier falls back to CARING). Broadcasts [Name] line to all players within local radius in same world. Logs NANNY_CHAT. AI tier path: gated on MembershipProvider.isUnlocked(ownerUUID) AND non-empty Nanny_Chat_AI_Endpoint. AI HTTP call uses HttpURLConnection on a single-thread ExecutorService; on success or any failure, returns to main thread via runTask and either broadcasts the AI text or falls back to BASIC silently. Provides reload() for /diaperreload.
  • MembershipProvider interface (in com.storynook.nanny). Phase 4 shipped AlwaysLockedProvider as the always-locked stub; Phase 6 replaced it with the composite + Permission/Patreon/Subscribestar implementations. The provider is instantiated in NannyManager.init and exposed via getMembershipProvider().
  • NannyVentureChatHook (in com.storynook.Integrations) — registered in Plugin.onEnable only when plugin.VentureChat is true. Mirrors VentureChatHook. Resolves speaker via e.getMineverseChatPlayer().getPlayer() and delegates VentureChatEvent to NannyChatEngine.fireTriggers.
  • nanny_messages.yml — fully populated: 14 categories (care_reminder, keyword_wet/messy/hungry/thirsty/tired/cute, found_ward, arrived_home, low_supplies, discipline (WARDEN-only), greeting, farewell, idle_ambient) × 4 mood tiers.
  • HandleAccident.handleAccident now logs WARD_HAD_ACCIDENT for every active Nanny whose ward list (or owner) matches the affected player. Wrapped in try/catch (Throwable) so logging cannot break the accident pipeline.
  • NannyMenu Behavior tab adds a “Chat Tier” toggle (slot 19): WRITABLE_BOOK when AI is unlocked for the menu opener (BOOK when locked). BASIC ↔ AI cycle on click; lore explains “AI tier requires membership” when locked. Click guard: only fires when stripped display name equals “Chat Tier” (avoids the “Tab: General” BOOK).
  • New globalConfig keys: Nanny_Chat_Min_Words (3), Nanny_Chat_Ambient_Chance (1, percent), Nanny_Chat_AI_Endpoint ("" disables AI even when unlocked).
  • NannyEventLog actively logs WARD_CHAT, NANNY_CHAT, WARD_HAD_ACCIDENT. Other defined event types (LOCKED_WARD, FORCE_FED, LEASHED_WARD, HYPNOTIZED_WARD) remain reserved for Phase 5b. (PLACED_IN_CRIB now active in Phase 5a.)

Phase 5a — Strict-Tier Discipline (shipped)

  • Capability enum (in com.storynook.nanny) — keys for the mood-tier action matrix: BASIC_CARE, POTTY_REMINDERS, ARMOR_LOCK, CRIB_PLACEMENT, BLOCK_CAREGIVERS, plus reserved Phase 5b keys (FORCE_FEED_LAXATIVE, BINDING_LEGGINGS, LEASH_WARD, HYPNOSIS_USE, ROOM_LOCKDOWN, EVIL_CRAFTING).

  • NannyPolicy (in com.storynook.nanny) — static gate. allows(NannyData, Capability) returns true based on mood tier (SWEET/CARING/STRICT/WARDEN have hard-coded sets) OR, when data.getMoodTier() == CUSTOM, reads data.getCustomSettings().get(cap.name()). BLOCK_CAREGIVERS is a special case: at STRICT/WARDEN it consults data.isBlockOtherCaregivers() (defaults false; toggle exposed via menu). minTier(Capability) returns the lowest tier that grants a capability for menu lore display.

  • NannyData adds customSettings (Map<String, Boolean> — Capability name → enabled, used only when moodTier == CUSTOM) and lockedArmor (Map<UUID, Boolean> — wardUUID → leggings-slot locked). Both persisted as YAML config sections (customSettings: and lockedArmor:).

  • ArmorLockListener (in com.storynook.nanny) — registered in Plugin.onEnable. At EventPriority.LOWEST, cancels InventoryClickEvent on raw slot 38 (leggings) and MOVE_TO_OTHER_INVENTORY shift-clicks of leggings items, but only when the clicker is a ward of an active Nanny with ARMOR_LOCK permission AND lockedArmor.get(ward) == true. All vanilla leggings types covered (leather/chainmail/iron/golden/diamond/netherite).

  • Changing.handleRightClickHold — first statement: try-block scanning mgr.getAllNannies() for any Nanny watching the target with BLOCK_CAREGIVERS permission. If actor != owner, sends red [Name] Only Name or her owner may change this ward. and returns. Wrapped in try/catch (Throwable) so policy lookup never breaks the change pipeline.

  • NannyChatEngine.startReminderTask — 1200-tick (60s) BukkitTask started from NannyManager.init, stopped via shutdown(). For each active Nanny with POTTY_REMINDERS permission and chatEnabled: scans wards within Nanny_Chat_Local_Radius, picks the first whose bladder >= 70 OR bowels >= 70, broadcasts a care_reminder.<TIER> line via broadcast(). Reuses the per-Nanny lastResponse throttle map (default 30s). Also ties into the existing shutdown() so graceful disable cancels the task.

  • NannyCareEngine.doChange now unlocks-around-change: if ARMOR_LOCK is permitted AND the ward is currently locked, the lock is temporarily cleared before Changing.applyChange and re-set after DiaperPail.deposit. State-restore + save happen only when the lock was actually flipped, so nothing changes for non-locked wards.

  • NannyCareEngine.doPlaceInCrib — new action. Triggered from the no-care branch of evaluateAndAct when ward food level ≤ 6 (sleep proxy) AND CRIB_PLACEMENT is permitted AND ward is not already a vehicle passenger. Scans within data.getHomeRadius() for an ArmorStand whose custom name equals literal "Crib" (matches the name set by CribPlacement.java), teleports the ward, calls addPassenger. Logs PLACED_IN_CRIB. Navigator handoff: when out of range, the engine instead navigates the Nanny toward the ward and acts on the next cycle.

    Dual crib-system lookup (added 2026-05-03): doPlaceInCrib now uses CribRegistry.findNearestCrib(location, radius) which searches new display-entity cribs first and falls back to legacy invisible-armor-stand cribs. New cribs require a vanilla bed in the cavity (crib.hasBed()); bedless cribs are skipped. New cribs use soft containment (the CribContainmentTask) instead of addPassenger. Legacy cribs continue to use addPassenger exactly as before. See docs/superpowers/specs/2026-05-03-crib-redesign-design.md.

  • NannyMenu Behavior tab: three new items (slots 27/29/31): IRON_BARS “Armor Lock” (toggles lock on every current ward + the owner; capability-gated; sends red message if mood tier is too low), OAK_FENCE “Crib Placement” (informational — placement runs automatically when permitted), BARRIER “Block Other Caregivers” (toggles data.blockOtherCaregivers; lore explains tier requirement when not yet effective). Each item’s lore reflects current state and shows mood-tier requirement when not currently allowed.


Phase 5b — Warden Discipline + Custom Tier (shipped)

  • Hypno.applyHypnosis(Player target, ItemStack clock) — public static helper extracted from handleInteraction. Reads hypnosis / HypnoTriggerWord / HypnoType PDC keys from the clock; gates on stats.getHypnoPermission() > 0; calls stats.cleanExpiredTriggers(); respects Hypno_Max_Triggers cap; builds a HypnoTrigger(word, type, expiry, null) (4th arg is String casterUUID, Nanny passes null since it isn’t a player); calls stats.addHypnoTrigger(...) and SavePlayerStats.savePlayerStats(target). Returns true on success.
  • NannyInventoryManager.tryCraftDiaper extended: when craftingMode == EVIL, the produced diaper has Enchantment.BINDING_CURSE applied with red “Curse of Binding” lore. Recipe unchanged (stuffer + tape + leather leggings).
  • NannyInventoryManager.tryCraftLaxative — new method. EVIL-only. Recipe: 1 bread + 1 cocoa beans + 1 sugar (with crafting table in homeRadius). Produces a Material.BREAD ItemStack tagged with the existing laxative_effect PDC key (matches ItemManager.Laxative for FeedingAction.applyFeed pipeline compatibility), dark-purple “Laxative” name.
  • NannyData.lockedRoomBlocksList<String> of "world,x,y,z" keys. Persisted as lockedRoomBlocks: YAML list. Populated by /nanny lockroom, cleared by /nanny unlockroom.
  • RoomLockListener (in com.storynook.nanny) — registered in Plugin.onEnable after ArmorLockListener. At EventPriority.LOWEST, cancels PlayerInteractEvent (door/lever right-click) and BlockBreakEvent for any ward whose Nanny has ROOM_LOCKDOWN permission AND the affected block’s world,x,y,z is in data.getLockedRoomBlocks(). Sends red “Nanny says no.” on cancel.
  • /nanny lockroom — collects every door/trapdoor/lever within a 16-block cube (vertical ±4) around the issuing owner into the owner’s Nanny’s lockedRoomBlocks list (clears existing first). Requires ROOM_LOCKDOWN. /nanny unlockroom clears the list. Tab-completer updated.
  • NannyCareEngine — four new action methods + tryDisciplineActions dispatcher. doForceFeedLaxative calls tryCraftLaxative then FeedingAction.applyFeed; logs FORCE_FED. doEquipBindingLeggings takes/crafts a diaper, force-applies Enchantment.BINDING_CURSE, calls EquipArmor.applyEquip; logs EQUIPPED_WARD with detail “binding”. doLeash calls target.setLeashHolder((LivingEntity) npcEntity) (Citizens2 NPC entity is a Player-type LivingEntity); logs LEASHED_WARD. doHypnotize finds a hypnosis-PDC clock in the personal inventory, calls Hypno.applyHypnosis, returns the clock to inventory regardless of result; logs HYPNOTIZED_WARD only when the helper returned true. tryDisciplineActions runs in evaluateAndAct’s no-care branch after basic-care + crib placement; uses isWithinActionRange to navigate-then-act. Trigger thresholds: bowels < 30 → laxative, distance > homeRadius/2 from home (same world) → leash, leggings without Curse of Binding → bind, otherwise → hypnotize (throttled to once per 5 minutes per ward via lastHypnotize map).
  • NannyMenu Advanced tab (5th tab, slot 4 in tab strip, NETHER_STAR icon, title “Nanny Advanced”) — visible always. For non-CUSTOM moods, shows yellow “CUSTOM tier required” info paper. For CUSTOM: shows 10 LIME_DYE/GRAY_DYE toggles (POTTY_REMINDERS, ARMOR_LOCK, CRIB_PLACEMENT, BLOCK_CAREGIVERS, FORCE_FEED_LAXATIVE, BINDING_LEGGINGS, LEASH_WARD, HYPNOSIS_USE, ROOM_LOCKDOWN, EVIL_CRAFTING) laid out across two rows skipping decorative slot 18. Click toggles data.customSettings.<cap.name()>, saves, re-opens.
  • NannyEventLog actively logs FORCE_FED, LEASHED_WARD, HYPNOTIZED_WARD (in addition to the Phase 5a PLACED_IN_CRIB). LOCKED_WARD remains reserved — armor-lock state lives on NannyData.lockedArmor directly, no event firing for it.

Phase 6 — Membership Integration (shipped)

  • New packages: com.storynook.nanny.crypto (CryptoService) and com.storynook.nanny.membership (CompositeMembershipProvider, PermissionMembershipProvider, PatreonMembershipProvider, SubscribestarMembershipProvider, OAuthHelper).
  • CryptoService — AES-256-GCM at-rest encryption. Key file at <dataFolder>/.crypto.key (32 random bytes), overridable via Crypto.Key_Path config (supports ${ENV_VAR} expansion). Encrypted values use enc:<base64> prefix. Initialised in Plugin.onEnable immediately after mergeConfigFiles("config.yml") and before loadGlobalConfig(); failure disables the plugin.
  • Plugin.readEncryptedConfigString(path) — single entry point that decrypts on read and migrates plaintext to encrypted in-place on first save. Used for Nanny.Membership.{Patreon,Subscribestar}.Client_Secret and Nanny.Chat.AI.API_Key (the existing Phase 4 AI key is now encrypted-at-rest via the same mechanism).
  • Plugin.buildMembershipProvider() — assembles CompositeMembershipProvider from enabled sub-providers; returns AlwaysLockedProvider when Nanny.Membership.enabled is false or no sub-providers are enabled. Re-invoked by /diaperreload via nannyManager.setMembershipProvider(buildMembershipProvider()) so config changes pick up without a restart.
  • New Settings_Menu.Membership flag gates persistence of six new PlayerStats fields: nannyMembershipProvider, nannyMembershipEmail (encrypted), nannyMembershipRefreshToken (encrypted), nannyMembershipTier, nannyMembershipStatus (UNLINKED/ACTIVE/LAPSED), nannyMembershipLastCheck (ISO-8601). All three of LoadStats/SavePlayerStats/CreateDefaultStats updated.
  • OAuth code-paste flow: player runs /nanny link <patreon|subscribestar>, clicks the chat link, signs in on the provider, copies the code|state string from the static redirect helper page (docs/security/oauth-redirect.html), runs /nanny link <provider> <code|state> in chat. CSRF state generated via OAuthHelper.generateState(UUID), validated via consumeState (15-min TTL, single-use). Token exchange + tier query happen in async tasks; result message returned to main thread via runTask.
  • PermissionMembershipProvider — checks Player.hasPermission(node) at call time. No persistence needed. For LuckPerms + DiscordSRV setups; Allow_Linking: false hides the OAuth flow when this is the only path.
  • MembershipProvider.refresh(UUID) triggered on player join (NannyManager.onPlayerJoin first statement) and via admin command /nanny refresh [player].
  • New commands: /nanny link <provider> [code|state], /nanny unlink, /nanny refresh [player] (admin-gated). Tab completer supports them and offers patreon/subscribestar for the second arg of link.
  • Reference helper page (docs/security/oauth-redirect.html) — single static HTML, parses ?code=&state= from URL + #provider from hash, displays code|state as a copyable string. Self-hosters can deploy to GitHub Pages / Cloudflare Pages / etc. and override Nanny.Membership.Redirect_URI (must also re-pin the redirect URI in their own OAuth client registration).
  • Admin docs: docs/security/hardening.md (threat model, key path, backup advice), docs/membership-setup.md (per-provider setup guide).