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 ofPlayerStats— separate file format likeDiaperPails. Inner enums:MoodTier {SWEET, CARING, STRICT, WARDEN, CUSTOM},ChestMode,CraftingMode,ChatRespondTo,ChatTier. Adding a field touches onlysave()+load()(single class, no LoadStats/SavePlayerStats/CreateDefaultStats triplet).NannyEntity— Citizens2 NPC wrapper. UsesCitizensAPI.getNPCRegistry().createNPC(EntityType.PLAYER, name)+SkinTrait.setSkinName(playerName)to copy another player’s skin (URL-based skins viasetTexture(value, signature)are a future enhancement). All public methods early-return when!plugin.citizensEnabled.NannyManager— orchestrator +Listener. OwnsallNannies(UUID→NannyData),activeNannies(UUID→NannyEntity),wardToNanniesindex, andpendingNannyCreations(player UUID → spawn location, used to bridge the Nanny-Egg-prompts-for-name flow). HandlesPlayerJoinEvent/PlayerQuitEventlifecycle,PlayerInteractEventfor the Nanny Egg, andEntityDamageEvent/EntityTargetEventto 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 viaNanny_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 keynanny_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 viaawaitingInputtypenannyName. NannyMenu(incom.storynook.menus) follows theSettingsMenustatic-opener +InventoryClickEventpattern. 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(incom.storynook.Commands) — Phase-1 subcommandsgive | list | remove | info | settings | sethome | summon | reload. Routed throughCommandHandler.onCommandvia thenannyentry indualCommands;CommandHandlerdelegates toplugin.getNannyCommand().handle(sender, args)(andtabCompletesimilarly).PlayerEventListener.onPlayerChathandles two awaiting-input types:nannyName(creates a Nanny ifpendingNannyCreationshas a location for the player; otherwise renames the owned Nanny) andnannySkinUrl("default"clears the skin). Both branches re-open the Nanny menu’s General tab on success.- Config:
Nanny:block inconfig.ymlis nested, not flat. Loaded intoglobalConfigasNanny(boolean fromNanny.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 flattenedNanny_Chat_*family (including theNanny_Chat_AI_*keys reserved for Phase 4). The old root-levelNanny: trueflag andNannyNPCskin:setting were removed fromconfig.yml. nanny_messages.ymlships a stub withcare_reminder,keyword_wet,found_wardkeyed by mood tier (SWEET/CARING/STRICT/WARDEN). Wired in Phase 4.CustomModelDataallocation:629001is now Nanny Egg. Add this to the resource pack’s font/item JSON if changing.
Phase 2 — Care Behaviors (shipped)
NannyCareEngine(incom.storynook.nanny) — single repeatingBukkitTask(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 — Nannyentity.teleportTo(ward.getLocation())to act. The engine starts on the first Nanny spawn and stops when the last despawns. Citizens2-guarded (no-op ifplugin.citizensEnabled == false). Started/stopped fromNannyManager.spawnNanny/despawnNanny.NannyInventoryManager(incom.storynook.nanny) — supply chain abstraction. 18-slot per-Nanny inventory stored onNannyData.personalInventory(ItemStack[]) and serialised via the YAMLpersonalInventory: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 tochestMode(INVENTORY_ONLY/SELECTED/ALL).tryCraftFood(BASIC: bread or cookies) andtryCraftDiaper(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 withinhomeRadiusofhomeLocation. 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
NannyDatafields:changeThreshold(default 70),feedThreshold(default 14),hydrationThreshold(default 30),personalInventory(ItemStack[18]). All persisted viaNannyData.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 privateresetAndUpdateStats). Replaces the inline stat block inhandleInteraction. Why a non-actor signature:handleRightClickHoldreads 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.pluginwas madestaticto enable the static helper (matches the existing static-pluginanti-pattern).EquipArmor.applyEquip(Player target, ItemStack legging)— thin delegation toChanging.applyChange(the equip-on-empty case is functionally a “change” in this domain). ExistingequipArmor(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 prefixPail_<UUID>) withinradiusblocks, loads its YAML vialoadInventory, adds the item viaInventory.addItem, saves viasaveInventory. Used byNannyCareEngine.doChangeto 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 viaawaitingInput), rows 2–3 = the 18-slot personal-inventory editor (real Bukkit drag-and-drop slots). The personal inventory persists onInventoryCloseEventforTITLE_SUPPLIES. Three newawaitingInputtypes inPlayerEventListener.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_Thresholdconfig keys, loaded intoglobalConfigasNanny_Default_*. NannyManagerexposesgetCareEngine(),getInventoryManager(),getEventLog(UUID).getEventLoglazily instantiates a per-NannyNannyEventLogand caches it ineventLogs(Map<UUID, NannyEventLog>).
Phase 3 — Navigation (shipped)
NannyNavigator(incom.storynook.nanny) — per-Nanny Citizens2 Navigator wrapper; one instance is created/started inNannyManager.spawnNannyand stopped/removed indespawnNanny. ExposesnavigateTo(Location),setFollowTarget(Player)(entity-follow, no fixed target),seekTo(Player)(navigate + mark seekingWardUUID + log SEEKING_WARD),cancelNavigation(),isNavigating(),getSeekingWardUUID(),clearSeekingWard(). Runs a 10-tickBukkitTaskthat does two things: (1) stuck-teleport fallback — ifcurrentTargetis set but Citizens2 is no longer navigating (cancelled bystationaryTicks(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 forMaterial.OAK_DOOR/SPRUCE_DOOR/BIRCH_DOOR/JUNGLE_DOOR/ACACIA_DOOR/DARK_OAK_DOOR/CRIMSON_DOOR/WARPED_DOOR/IRON_DOORandLEVER. Doors →Openable.setOpen(true), auto-close after 60 ticks. Levers → togglePowerable.setPowered, auto-restore after 60 ticks. Iron door without an adjacent lever → wait 100 ticks then teleport pastcurrentTarget.pendingRestoreset keys (world,x,y,z) prevent double-toggling. All Citizens2 calls behindplugin.citizensEnabled;navigateTo/setFollowTargetfall back to teleport when disabled.NannyCareEngineupgraded to navigate-then-act:evaluateAndActnow computesneedsChange/Equip/Feed/Hydrateflags first, callsnavigator.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-tickcheckHomeEnforcement(when not in follow mode and homeWorld is set, navigate back to home if outsidehomeRadius; teleport if in wrong world; logs RETURNED_HOME),checkFollowBoundary(when in follow mode and owner >homeRadius × 2from home, setfollowMode=false, navigate home, send “I’ll wait here for you!” message, log RETURNED_HOME),checkSeekArrival(when ward UUID matchesnavigator.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 callsinventoryManager.prepareFollowSupplies(data)before enabling follow, then dispatches tonavigator.setFollowTarget(ward)ornavigator.cancelNavigation(). Falls back toentity.setFollowMode(follow, ward)only when navigator is null (Citizens2 absent or Nanny not yet spawned).NannyManager.onPlayerJointriggersnavigator.seekTo(player)for every Nanny whereseekEnabledis 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, thenseekTo. Owner timeout/dormancy logic from Phase 1 still runs first — seek only fires when the Nanny is being spawned for this player.NannyEntityexposesgetLocation()(current world location, null if not spawned) andgetNpc()(raw Citizens2NPCreference, null if not created).NannyDataaddsseekEnabled(boolean, defaulttrue, persisted asseekEnabled:in YAML).NannyEventLog.NannyEventTypeaddsSEEKING_WARDandSTUCK_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(incom.storynook.nanny) — Listener forAsyncPlayerChatEvent(skipped whenplugin.VentureChatis true;NannyVentureChatHookdelegates back viafireTriggers(Player, String)). Per-Nanny throttle (default 30s, keyNanny_Chat_AI_Cooldown_Seconds). Skips messages with <Nanny_Chat_Min_Wordswords. For each active Nanny inNanny_Chat_Local_Radiusof speaker: logsWARD_CHAT(always), then ifchatEnabled+chatRespondTopermits + throttle clear, evaluates triggers (name mention →greeting; keyword stemwet/messy/hungry/thirsty/tired/cute→keyword_<stem>;Nanny_Chat_Ambient_Chancepercent roll →idle_ambient). Picks a mood-keyed response fromnanny_messages.yml(CUSTOM tier falls back to CARING; missing tier falls back to CARING). Broadcasts[Name] lineto all players within local radius in same world. LogsNANNY_CHAT. AI tier path: gated onMembershipProvider.isUnlocked(ownerUUID)AND non-emptyNanny_Chat_AI_Endpoint. AI HTTP call usesHttpURLConnectionon a single-threadExecutorService; on success or any failure, returns to main thread viarunTaskand either broadcasts the AI text or falls back to BASIC silently. Providesreload()for/diaperreload.MembershipProviderinterface (incom.storynook.nanny). Phase 4 shippedAlwaysLockedProvideras the always-locked stub; Phase 6 replaced it with the composite + Permission/Patreon/Subscribestar implementations. The provider is instantiated inNannyManager.initand exposed viagetMembershipProvider().NannyVentureChatHook(incom.storynook.Integrations) — registered inPlugin.onEnableonly whenplugin.VentureChatis true. MirrorsVentureChatHook. Resolves speaker viae.getMineverseChatPlayer().getPlayer()and delegatesVentureChatEventtoNannyChatEngine.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.handleAccidentnow logsWARD_HAD_ACCIDENTfor every active Nanny whose ward list (or owner) matches the affected player. Wrapped intry/catch (Throwable)so logging cannot break the accident pipeline.NannyMenuBehavior 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
globalConfigkeys:Nanny_Chat_Min_Words(3),Nanny_Chat_Ambient_Chance(1, percent),Nanny_Chat_AI_Endpoint("" disables AI even when unlocked). NannyEventLogactively logsWARD_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)
-
Capabilityenum (incom.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(incom.storynook.nanny) — static gate.allows(NannyData, Capability)returns true based on mood tier (SWEET/CARING/STRICT/WARDEN have hard-coded sets) OR, whendata.getMoodTier() == CUSTOM, readsdata.getCustomSettings().get(cap.name()).BLOCK_CAREGIVERSis a special case: at STRICT/WARDEN it consultsdata.isBlockOtherCaregivers()(defaults false; toggle exposed via menu).minTier(Capability)returns the lowest tier that grants a capability for menu lore display. -
NannyDataaddscustomSettings(Map<String, Boolean>— Capability name → enabled, used only when moodTier == CUSTOM) andlockedArmor(Map<UUID, Boolean>— wardUUID → leggings-slot locked). Both persisted as YAML config sections (customSettings:andlockedArmor:). -
ArmorLockListener(incom.storynook.nanny) — registered inPlugin.onEnable. AtEventPriority.LOWEST, cancelsInventoryClickEventon raw slot 38 (leggings) andMOVE_TO_OTHER_INVENTORYshift-clicks of leggings items, but only when the clicker is a ward of an active Nanny withARMOR_LOCKpermission ANDlockedArmor.get(ward) == true. All vanilla leggings types covered (leather/chainmail/iron/golden/diamond/netherite). -
Changing.handleRightClickHold— first statement: try-block scanningmgr.getAllNannies()for any Nanny watching the target withBLOCK_CAREGIVERSpermission. If actor != owner, sends red[Name] Only Name or her owner may change this ward.and returns. Wrapped intry/catch (Throwable)so policy lookup never breaks the change pipeline. -
NannyChatEngine.startReminderTask— 1200-tick (60s)BukkitTaskstarted fromNannyManager.init, stopped viashutdown(). For each active Nanny withPOTTY_REMINDERSpermission andchatEnabled: scans wards withinNanny_Chat_Local_Radius, picks the first whosebladder >= 70ORbowels >= 70, broadcasts acare_reminder.<TIER>line viabroadcast(). Reuses the per-NannylastResponsethrottle map (default 30s). Also ties into the existingshutdown()so graceful disable cancels the task. -
NannyCareEngine.doChangenow unlocks-around-change: ifARMOR_LOCKis permitted AND the ward is currently locked, the lock is temporarily cleared beforeChanging.applyChangeand re-set afterDiaperPail.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 ofevaluateAndActwhen ward food level ≤ 6 (sleep proxy) ANDCRIB_PLACEMENTis permitted AND ward is not already a vehicle passenger. Scans withindata.getHomeRadius()for anArmorStandwhose custom name equals literal"Crib"(matches the name set byCribPlacement.java), teleports the ward, callsaddPassenger. LogsPLACED_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):
doPlaceInCribnow usesCribRegistry.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 (theCribContainmentTask) instead ofaddPassenger. Legacy cribs continue to useaddPassengerexactly as before. Seedocs/superpowers/specs/2026-05-03-crib-redesign-design.md. -
NannyMenuBehavior 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” (togglesdata.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 fromhandleInteraction. Readshypnosis/HypnoTriggerWord/HypnoTypePDC keys from the clock; gates onstats.getHypnoPermission() > 0; callsstats.cleanExpiredTriggers(); respectsHypno_Max_Triggerscap; builds aHypnoTrigger(word, type, expiry, null)(4th arg is String casterUUID, Nanny passes null since it isn’t a player); callsstats.addHypnoTrigger(...)andSavePlayerStats.savePlayerStats(target). Returns true on success.NannyInventoryManager.tryCraftDiaperextended: whencraftingMode == EVIL, the produced diaper hasEnchantment.BINDING_CURSEapplied 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 inhomeRadius). Produces aMaterial.BREADItemStack tagged with the existinglaxative_effectPDC key (matchesItemManager.LaxativeforFeedingAction.applyFeedpipeline compatibility), dark-purple “Laxative” name.NannyData.lockedRoomBlocks—List<String>of"world,x,y,z"keys. Persisted aslockedRoomBlocks:YAML list. Populated by/nanny lockroom, cleared by/nanny unlockroom.RoomLockListener(incom.storynook.nanny) — registered inPlugin.onEnableafterArmorLockListener. AtEventPriority.LOWEST, cancelsPlayerInteractEvent(door/lever right-click) andBlockBreakEventfor any ward whose Nanny hasROOM_LOCKDOWNpermission AND the affected block’sworld,x,y,zis indata.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’slockedRoomBlockslist (clears existing first). RequiresROOM_LOCKDOWN./nanny unlockroomclears the list. Tab-completer updated.NannyCareEngine— four new action methods +tryDisciplineActionsdispatcher.doForceFeedLaxativecallstryCraftLaxativethenFeedingAction.applyFeed; logsFORCE_FED.doEquipBindingLeggingstakes/crafts a diaper, force-appliesEnchantment.BINDING_CURSE, callsEquipArmor.applyEquip; logsEQUIPPED_WARDwith detail “binding”.doLeashcallstarget.setLeashHolder((LivingEntity) npcEntity)(Citizens2 NPC entity is a Player-type LivingEntity); logsLEASHED_WARD.doHypnotizefinds a hypnosis-PDC clock in the personal inventory, callsHypno.applyHypnosis, returns the clock to inventory regardless of result; logsHYPNOTIZED_WARDonly when the helper returned true.tryDisciplineActionsruns inevaluateAndAct’s no-care branch after basic-care + crib placement; usesisWithinActionRangeto navigate-then-act. Trigger thresholds: bowels < 30 → laxative, distance >homeRadius/2from home (same world) → leash, leggings without Curse of Binding → bind, otherwise → hypnotize (throttled to once per 5 minutes per ward vialastHypnotizemap).NannyMenuAdvanced 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 togglesdata.customSettings.<cap.name()>, saves, re-opens.NannyEventLogactively logsFORCE_FED,LEASHED_WARD,HYPNOTIZED_WARD(in addition to the Phase 5aPLACED_IN_CRIB). LOCKED_WARD remains reserved — armor-lock state lives onNannyData.lockedArmordirectly, no event firing for it.
Phase 6 — Membership Integration (shipped)
- New packages:
com.storynook.nanny.crypto(CryptoService) andcom.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 viaCrypto.Key_Pathconfig (supports${ENV_VAR}expansion). Encrypted values useenc:<base64>prefix. Initialised inPlugin.onEnableimmediately aftermergeConfigFiles("config.yml")and beforeloadGlobalConfig(); 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 forNanny.Membership.{Patreon,Subscribestar}.Client_SecretandNanny.Chat.AI.API_Key(the existing Phase 4 AI key is now encrypted-at-rest via the same mechanism).Plugin.buildMembershipProvider()— assemblesCompositeMembershipProviderfrom enabled sub-providers; returnsAlwaysLockedProviderwhenNanny.Membership.enabledis false or no sub-providers are enabled. Re-invoked by/diaperreloadvianannyManager.setMembershipProvider(buildMembershipProvider())so config changes pick up without a restart.- New
Settings_Menu.Membershipflag gates persistence of six newPlayerStatsfields: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 thecode|statestring from the static redirect helper page (docs/security/oauth-redirect.html), runs/nanny link <provider> <code|state>in chat. CSRF state generated viaOAuthHelper.generateState(UUID), validated viaconsumeState(15-min TTL, single-use). Token exchange + tier query happen in async tasks; result message returned to main thread viarunTask. PermissionMembershipProvider— checksPlayer.hasPermission(node)at call time. No persistence needed. For LuckPerms + DiscordSRV setups;Allow_Linking: falsehides the OAuth flow when this is the only path.MembershipProvider.refresh(UUID)triggered on player join (NannyManager.onPlayerJoinfirst 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 offerspatreon/subscribestarfor the second arg oflink. - Reference helper page (
docs/security/oauth-redirect.html) — single static HTML, parses?code=&state=from URL +#providerfrom hash, displayscode|stateas a copyable string. Self-hosters can deploy to GitHub Pages / Cloudflare Pages / etc. and overrideNanny.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).