v0.60.0 -- Beta
The beta release. Everything authored after v0.57.20 is consolidated here, including a major new feature (GMCP for modern MUD clients), a one-time founder commemoration tied to the alpha-era online server, several long-tail bug fixes, an architectural fix for character-deletion data loss, and the full JS-side translation pipeline for the Electron graphical client.
Beta launch and the alpha wipe
The online server database is wiped at beta launch. Eleven alpha-era founders, identified from the live database before the wipe, are commemorated in-world by themed statues at three permanent locations. Two of those eleven (Coosh and Zengazu) accidentally deleted their own characters before the wipe; their statues are visibly cracked, with dramatic-irony inscriptions. See "Founder Statues" below for the full list and placements.
The same wipe revealed a real architectural issue (Zengazu hit it twice, on different characters): prestige class unlocks were tied to the character save file, not the SSH account. Tier 1 of the fix ships in this release; full account/character separation is scheduled for a v0.60.x point release once beta has stabilized.
GMCP support -- modern MUD client integration
Adds GMCP (Generic MUD Communication Protocol) out-of-band event support to the MUD server so Mudlet, MUSHclient, TinTin++, and other modern MUD clients can render player vitals, status, room info, and chat messages in dedicated UI panes (gauges, status bars, split windows, chat capture) instead of dumping everything into the scroll buffer.
Player request from a TinTin++ user pointed out that the split-screen status pane in TT++ has nothing to feed it without GMCP -- the client sees only one stream of ANSI text and can't separate narrative output from status updates. With this release, those split panes work.
What players get with a GMCP-capable client
- Char.Vitals -- live HP / MaxHP / MP / MaxMP / SP delta-pushed every prompt cycle. Mudlet renders this as a graphical health bar; TT++ scripts can wire it to a status pane; MUSHclient has a built-in mapper.
- Char.Status -- name, class, level, race, gold, bank, xp, location. Pushed on every location change.
- Room.Info -- room number + name on every location change. Future iterations will add the exit list (current shape just emits placeholder).
- Comm.Channel.Text -- every
/gossip,/shout,/tell, and/gcmessage goes out as a structured{channel, talker, text}payload to recipients. Chat capture scripts work without text-pattern triggers.
GMCP is a strict opt-in via telnet negotiation. SSH-relayed sessions, web terminal users, BBS terminals, and the local Electron client never see a single GMCP byte -- they get the same plain-ANSI experience they always have.
Wire format
Standard GMCP framing per the official spec:
IAC (0xFF) SB (0xFA) GMCP (0xC9) "Package.Message" SP "{json}" IAC (0xFF) SE (0xF0)
Two implementation details that matter:
- 0xFF doubling inside SB. Per RFC 854, any 0xFF byte inside an SB block must be sent as
0xFF 0xFFso the SE terminator (0xFF 0xF0) is unambiguous. UTF-8-encoded JSON never produces a single 0xFF for any valid Unicode codepoint, butGmcpBridge.EscapeIacByteshandles it defensively for caller-controlled package name strings. - camelCase JSON. Conventional GMCP servers (Achaea, all Iron Realms muds) use camelCase property names.
GmcpBridgeconfiguresJsonNamingPolicy.CamelCaseso off-the-shelf Mudlet scripts and existing Achaea-compatible clients work without remapping.
Negotiation flow
- Client connects to port 4000 (or sslh-multiplexed equivalent).
- After the 500ms AUTH-header timeout (which distinguishes raw MUD clients from SSH-proxied AUTH connections), the server sends
IAC WILL ECHO,IAC WILL SGA, andIAC WILL GMCPin a single packet. - Inside
ProbeTtypeAsync, the server reads incoming IAC sequences for ~250ms looking for both the TTYPE response AND anyIAC DO GMCP/IAC DONT GMCPreply. - If
IAC DO GMCParrives, the session is markedGmcpEnabled = trueonSessionContext.IAC DONT GMCP(or no response) leaves it false. - From that point on,
GmcpBridge.Emit("Package.Name", payload)checksSessionContext.Current.GmcpEnabledand writes IAC-framed bytes to the session'sOutputStream. Non-GMCP sessions get no frames.
The negotiation is bundled into the existing TTYPE probe to avoid a second 250ms round-trip -- both responses race in the same window.
Packages shipped
| Package | Trigger | Payload Shape |
|---|---|---|
Char.Vitals
| Top of BaseLocation.LocationLoop (every prompt cycle) when any tracked stat changed
| { hp, maxHp, mp, maxMp, sp }
|
Char.Status
| LocationManager.EnterLocation (every location change)
| { name, class, level, race, gold, bank, xp, location }
|
Room.Info
| LocationManager.EnterLocation (every location change)
| { num, name, area, exits }
|
Comm.Channel.Text
| MudChatSystem.HandleGossip / HandleShout / HandleTell / HandleGuildChat
| { channel, talker, text }
|
Selection rationale: these are the four packages every Mudlet/MUSHclient/TT++ user actually scripts against on day one. Combat events, inventory deltas, character skills, and room exits are deferred to a v0.60.x point release once we see what the real userbase wants.
What's not shipped (yet)
- Char.Items. (inventory deltas)* -- would need hooks at every InventorySystem mutation site. Iterative.
- Char.Skills. (skill list / proficiency changes)* -- needs hooks in TrainingSystem.
- Combat events -- full GMCP combat protocol is non-trivial. Defer to a point release.
- Char.MaxStats / Char.BaseStats -- players rarely script against these vs. Vitals.
- Room.Exits enum -- Room.Info ships with empty
exitsplaceholder for now. Each location implementation would need to expose its exit list, which is more refactoring than a hotfix-class change warrants. - Server-supported package list (
Core.Supports.Set) -- clients can negotiate which packages they want; we currently emit unconditionally to GMCP-enabled sessions. A point release should respect the supports list so we don't waste bandwidth on packages clients ignore.
Founder statues -- alpha-era commemoration
Eleven alpha-era founders are commemorated in-world at beta launch. Identified from the live online database before the wipe; criteria: reached Level 100, ascended to Immortal, or defeated Manwe and started NG+. Each statue carries a unique themed ASCII silhouette (harvest god, benevolent giver, speed-runner, dark conqueror, pure healer, long-walker, contradiction-bearer, cycle-wanderer, first shaman, lost immortal, renamed-and-erased) and a carved-stone narrative composed from that founder's actual playstyle data.
The eleven
| Founder | Class | Identity | Achievement |
|---|---|---|---|
| spudman | Barbarian | "Spud of the Harvest" | Lv.100 Cycle II Savior, immortal |
| quent | Barbarian | "Quent the Benevolent" | Lv.100 Cycle II Savior, immortal |
| fastfinge | Warrior | "fastfinge the Unburdened" | Lv.100, immortal |
| sandoval | Warrior | (43,753 lifetime Darkness pre-cap) | Lv.93 Cycle II Usurper, immortal |
| amaranth | Tidesworn | "the Unstained" | Lv.57 Cycle II Savior |
| lueldora | Tidesworn | "Lumina Starbloom, Long-Walker" | Lv.51 Cycle II Savior, 610 hours, all 7 seals |
| mystic | Cyclebreaker | (faith-hearted, chose Usurper) | Lv.34 Cycle II Usurper |
| biaxin | Voidreaver | "Alistar The Almighty, Cycle-Walker" | Cycle 4, Usurper + Defiant endings |
| sedz | Mystic Shaman | "First Shaman" | Cycle II Savior |
| Coosh | (cracked / lost) | Immortal who deleted on a class-reroll | Cycle II, accidentally erased |
| Zengazu | (cracked / lost) | NG+ veteran who renamed to "2zengazu2furious" then deleted | Cycle II, accidentally erased |
Placements
- Pantheon: Hall of the Ascended -- 5 statues (4 immortals + cracked Coosh). Visible only to immortals (the divine realm).
- Castle Courtyard: The Slayers of Manwe -- 9 statues (8 NG+ veterans + cracked Zengazu). Visible to all players from inside the throne room or from OutsideCastle.
- Temple: Hall of the Ascended (mortal-facing duplicate) -- 5 statues (same as Pantheon). Mortals can't enter the Pantheon, so the immortal-founder statues are mirrored here at the Temple where the public venerates the gods. Same data, two access points.
- Main Square: Founders' Plinths -- 3 mini statues for the Lv.100 trio (spudman, quent, fastfinge).
Access
[V] Courtyard Statues-- Castle royal and commoner menus[V] Hall of the Ascended-- Temple menu[H] Hall of the Ascended-- Pantheon menu (immortal-only)/founders//statues//hall-- universal slash command from any location, opens a hub picker for all three placements
Cracked variant
Coosh and Zengazu's statues render in dark_gray with their own cracked ASCII art (Coosh: dissolving halo with diagonal cracks and "( no refund )" engraving; Zengazu: scratched-out name plate showing "2 ze 2 / n##zu fu### / ## us"). Their detail screens add (Their record was lost to misadventure.) in italic gray above the carved-stone narrative.
The data is hardcoded in Scripts/Data/FounderStatueData.cs and shipped in the binary. No DB schema, no save migration, survives the beta wipe by design.
Tier 1 character-deletion safety
Two players (one of them Zengazu) accidentally deleted characters and lost prestige class unlocks plus immortal status. Investigation revealed the prestige check at CharacterCreationSystem.GetUnlockedPrestigeClasses reads StoryProgressionSystem.CompletedEndings from the character save rather than the existing MetaProgressionSystem.UnlockedEndings which lives in a separate file. MetaProgressionSystem already records ending unlocks via RecordEndingUnlock() -- the prestige check just wasn't consulting it.
Tier 1 ships now (pre-beta). Full account/character architectural separation is Tier 2, scheduled for a point release.
Prestige unlock now SSH-account-scoped
GetUnlockedPrestigeClasses unions the current character's CompletedEndings with the account's MetaProgressionSystem.UnlockedEndings. The cycle gate is satisfied if either the current character is past cycle 1 OR the account has any recorded ending. A player who deletes their immortal character and rerolls on the same SSH account now keeps prestige class access. A fresh-character reroll picks up where the deleted one left off.
Enhanced delete-character warning
The "Delete Save" flow at Main Street now enumerates explicitly what gets erased (inventory / stats / quests / cycle / immortal status), what's preserved (account-level prestige unlocks, ending records, Steam achievements), and prominently displays the 7-day grace window before the typed DELETE confirmation. The success message after deletion also reminds the player how to restore.
deleted_characters archive table + 7-day grace window
New SQLite table in the online server's database. When a character is deleted via SqlSaveBackend.DeleteGameData, the JSON blob is copied here with expires_at = now + 7 days BEFORE the live player_data is cleared. Indexed on lowercased username + expires_at. Each delete also opportunistically purges expired rows (cheap with the index).
/restore slash command
Looks up the most recent archived character for the calling SSH account, refuses if there's already an active character on that account (won't silently overwrite a new reroll), restores the JSON blob to players.player_data, removes the archive entry, tells the player to log out + back in. Failure modes (expired, no active row, race condition, sql error) all have specific error messages routed through the slash-command response.
Why this is enough for beta
- Recovers the most common accidental-delete failure mode (player doesn't realize delete is permanent).
- Doesn't require a save format change.
- The prestige-unlock fix solves the specific bug Zengazu hit twice without architectural refactoring.
- Tier 2 (full account/character split with multi-character per account) is a 40-60h refactor scheduled for a v0.60.x point release once beta is stable.
/tell multi-word display name fix
Player report (2Zengazu2Furious): "I can't seem to /tell my friend Lumina starbloom." Root cause: MudChatSystem.HandleTell's parser at args.Substring(0, spaceIndex) took everything before the first space as the target name. So /tell Lumina Starbloom hi parsed as target=Lumina, message="Starbloom hi" -- the second word of her display name got eaten by the message. Multi-word display names couldn't be addressed at all.
Fix: new ResolveTargetAndMessage helper does greedy longest-prefix matching. For /tell Lumina Starbloom hi friend:
- Try target =
"Lumina Starbloom hi"→ no match - Try target =
"Lumina Starbloom"→ match → message ="hi friend"
So /tell Lumina Starbloom hi friend now correctly delivers "hi friend" to Lumina Starbloom. Single-word names still work -- the loop just stops at the only available prefix. Falls back gracefully to "X is not online" with the first-word interpretation if no prefix resolves, so the player gets a sensible error instead of something baffling.
/group was audited and already takes the entire args as a target name (no message parsing), so it already handled multi-word names correctly. /spectate is initiated via the main menu, not chat-system parsing, so it's also unaffected.
French translation gaps + UTF-8 defense in depth
Player report on the live server: "the french translation is not encoding correctly. some words aren't translated."
Translation gaps
Audit of Localization/fr.json against en.json found 18,171 keys with 270 sharing the English value. Most of those (~260) are intentional cognates (Combat, Faction, Exploration, Confession, Abdication, Anticipation), proper nouns (USURPER REBORN, Halls of Avarice, Seth Able), or universal acronyms (CON, DEX, INT, MVP, XP). About 10 were genuine gaps and are now translated:
prefs.off: "OFF" → "ARRÊT"spec.active_marker: "[ACTIVE]" → "[ACTIF]"dungeon.strangers_orcs: "orcs" → "orques"base.standing_hostile: " (Hostile)" → " (Hostile)" (already cognate, kept)- Plus minor banner/header polish on faction headers, achievement category labels, confession headers, abdication headers
UTF-8 defense in depth
Investigation of the encoding complaint showed the player-output path uses explicit Encoding.UTF8.GetBytes() on the network stream, and fr.json itself contains valid UTF-8. The most likely root cause is the player's SSH client not being configured for UTF-8. As a server-side belt-and-suspenders, Console/Bootstrap/Program.cs now explicitly sets Console.OutputEncoding = UTF8 and Console.InputEncoding = UTF8 at startup (skipped only for BBS door mode where CP437 is intentional). This protects against systemd-on-Linux services running without a UTF-8 locale, where Console.Out defaults to ASCII and silently strips accented characters from any code path that uses Console.WriteLine rather than the network stream directly.
Electron graphical client -- Phase 9.5 polish
(Cumulative work from v0.58 graphical-client phases consolidated for the beta launch.)
Settings overlay
New /settings and /set slash commands open a focused settings card with: language <select>, font scale slider (0.85x–1.4x), screen reader toggle, compact mode toggle, art toggle, date format readout. Each control fires its own /settings KEY VALUE slash command on change so updates apply incrementally. Confirmation toast confirms each change. Font scale persists via localStorage.usurper.fontScale so preference survives restart.
Sub-commands: /settings lang es, /settings sr on, /settings compact off, /settings art on -- all callable directly without opening the overlay.
JS-side i18n shim
New electron-client/src/i18n.js with window.i18n.t(key, ...args), setLanguage(code), getLanguage(), getEnglishSource(). Embedded English baseline (~75 keys) covering settings, lifecycle events, multiplayer overlays, common UI. Fallback chain: current language → embedded English → key (so missing keys surface as the literal key, easy to spot during translation review).
Translations dropped for all four target languages: electron-client/lang/{es,fr,hu,it}.json. Translation method: 4 parallel general-purpose agents, each given the English source JSON + glossary hints (alignment terms, banner CAPS convention, placeholder preservation, "(Y)/(N)" → language-appropriate keys where natural). About 75 keys × 4 languages = 300 translations.
JS-rendered overlays refactored to use t(): level-up, death, quest complete, group invite, spectate, chat panel, news feed, boss phase, settings.
Audio infrastructure scaffolding
Web Audio receiver in electron-client/src/audio.js. Lazy AudioContext creation gated on user interaction (browser autoplay policy). Channel mixer graph: master → {sfx, music, ui}. Per-channel volume restored from localStorage.usurper.volumes. loadBuffer() lazy-fetches assets/audio/<channel>/<name>.ogg on first play, caches misses to avoid repeated 404s. playSound() silent no-op on missing buffer -- emit-from-C# is safe even before assets exist.
Bridge helpers added in Scripts/UI/ElectronBridge.cs:
EmitSound(soundId, volume, pitch, channel)-- fire a sound triggerEmitSoundStop(soundId)-- stop a looping soundEmitVolumeSet(channel, volume)-- set per-channel volume
C# emit hooks at four sites:
LevelMasterLocation.CheckAutoLevelUp--sfx.level_up_milestonefor every-5th levels,sfx.level_upotherwiseCombatEngine.HandlePlayerDeath--sfx.death_permadeathfor Nightmare mode,sfx.deathotherwiseAchievementSystem.TryUnlock--sfx.achievement.{tier}per Bronze/Silver/Gold/Platinum/DiamondCombatEngine.PlayBossPhaseTransition--sfx.boss.phase_transition
Asset directory structure (electron-client/assets/audio/sfx,music,ui) created with README.md documenting the path convention and the 10 currently-emitted soundIds. Assets directory is empty in this release. As .ogg files land, they become audible immediately with no code changes.
Files Changed (cumulative)
New files
Scripts/Data/FounderStatueData.cs-- 17 statue placements representing 11 unique founders, with per-founder narrative inscriptions, achievement tags, custom art arrays, andIsCrackedflag for the two who deleted themselves.Scripts/Systems/FounderStatueSystem.cs-- list rendering, examine flow, ASCII art rendering with custom-art support per founder, Electron emit for graphical clients.Scripts/Server/GmcpBridge.cs-- Static class withEmit(package, payload)for current-session emit andEmitTo(session, package, payload)for cross-session fan-out.Tests/GmcpBridgeTests.cs-- 8 unit tests for GMCP framing, IAC escaping, JSON output, edge cases.electron-client/src/i18n.js-- JS i18n shim with embedded English + per-language overrides.electron-client/src/audio.js-- Web Audio receiver with channel mixer.electron-client/lang/{es,fr,hu,it}.json-- Translations for the JS overlay strings.electron-client/lang/_template.json-- Source template for translators.electron-client/lang/README.md-- Translation workflow notes.electron-client/assets/audio/README.md-- Audio asset path convention.
Modified files
Scripts/Core/GameConfig.cs-- version bump 0.57.20 → 0.60.0;VersionNameupdated to "Beta".Scripts/Server/SessionContext.cs-- newGmcpEnabledproperty.Scripts/Server/MudServer.cs-- initial negotiation extended withIAC WILL GMCP;ProbeTtypeAsyncrecognizesIAC DO GMCP; passes through toPlayerSession.Scripts/Server/PlayerSession.cs-- acceptsgmcpEnabledparameter, stores it, copies toctx.GmcpEnabled.Scripts/Systems/LocationManager.cs--EnterLocationemitsRoom.InfoandChar.Statusafter the room registry update.Scripts/Locations/BaseLocation.cs--EmitGmcpVitalsprivate method with 5-field delta tracking. New/founders//statues//hallslash command +ShowFounderHubMenuhub picker. New/settings//setslash command +HandleSettingsCommand+EmitSettingsScreenToElectron. Em dashes replaced with cleaner punctuation in the founder hub copy.Scripts/Server/MudChatSystem.cs--Comm.Channel.TextGMCP fan-out viaFanoutChannelTexthelper. NewResolveTargetAndMessagegreedy multi-word name parser for/tell. NewHandleRestorefor the/restoreslash command. Newrestorecase in the command switch.Scripts/Systems/CharacterCreationSystem.cs--GetUnlockedPrestigeClassesunions character-scopeCompletedEndingswith account-scopeMetaProgressionSystem.UnlockedEndings. Cycle gate satisfied if either character is past cycle 1 OR account has recorded endings.Scripts/Systems/SqlSaveBackend.cs-- newdeleted_characterstable + indexes.DeleteGameDataarchives JSON blob todeleted_characterswith 7-day expiry before clearingplayer_data. NewGetMostRecentDeletedCharacterandRestoreFromDeletedmethods. NewDeletedCharacterInfodata class.Scripts/Locations/MainStreetLocation.cs-- enhanced delete-character warning enumerating what's lost vs preserved, with prominent grace-window note.Scripts/Locations/CastleLocation.cs--[V] Courtyard Statuesaction wired into both royal and commoner menus + Phase 5 Electron emit.Scripts/Locations/PantheonLocation.cs--[H] Hall of the Ascendedmenu option (immortal-only access path).Scripts/Locations/TempleLocation.cs--[V] Hall of the Ascendedmenu option (mortals' access path to immortal-founder statues).Console/Bootstrap/Program.cs-- explicitConsole.OutputEncoding = UTF8andInputEncoding = UTF8at startup (skipped only for BBS door mode).Localization/fr.json-- translation gaps fixed (~10 strings).Scripts/UI/ElectronBridge.cs-- Phase 9 settings + Phase 9.5 audio bridge helpers (EmitSettingsScreen,EmitSettingsApplied,EmitSound,EmitSoundStop,EmitVolumeSet); Phase 6 dialogue + quest helpers; Phase 7 lifecycle event helpers; Phase 8 multiplayer parity helpers; newcustomArtandartColorfields onstatue_detailemit.Scripts/Locations/LevelMasterLocation.cs--EmitSoundfor level-up.Scripts/Systems/CombatEngine.cs--EmitSoundfor player death, boss phase transition.Scripts/Systems/AchievementSystem.cs--EmitSoundfor tier-specific achievement chime.electron-client/src/game-ui.js-- new event handlers forstatue_list/statue_detail/settings/settings_applied/sound/sound_stop/volume_set. JS-rendered overlays refactored to usewindow.i18n.t().electron-client/styles/gui.css-- new--gui-font-scaleCSS variable,.settings-cardstyling.electron-client/renderer.html-- script loadsi18n.jsandaudio.jsbeforegame-ui.js.
Tests
8 new GMCP tests + 0 changes to existing tests. Full suite: 661/661 green.
Gods are no longer recruitable as team members
Player report (Spudder, Lv.22 Voidreaver -- alt of "spudman" who ascended as Spud of the Harvest): "God characters showing up in team after rolling new character, can be used in dungeons."
Two distinct holes in the team-recruitment pipeline allowed immortal characters to end up on mortal rosters and be marched into combat:
-
Recruitment filter missing the immortal check.
TeamSystem.IsRecruitablealready filtered out dead NPCs, NPCs already on a team, special NPCs (Seth Able etc.), prisoners, and partners (spouses/lovers/FWB/exes). Immortals weren't on the list. Players who had ascended (IsImmortal = true) appeared in the recruitment menu like any other NPC, with their own level + class + relationship score -- and could be hired with gold. -
Ascension didn't auto-quit the team.
EndingsSystem.OfferImmortalityalready auto-abdicates if the ascending player is the king, but it never quit their team membership. A player who was on a team and ascended remained listed as a team member. Then their teammates could see them in team rosters, take them into dungeons, etc.
For Spudder specifically, his main account spudman ascended cleanly (no team to leave at the time, so #2 didn't apply to him). His issue is more likely #1 -- the alt's recruitment menu surfaced other ascended players as eligible, and he hired one or more without realizing they were gods.
Fix:
TeamSystem.IsRecruitablenow blocksnpc.IsImmortalalong with the existing five filters. New immortals never appear in the recruitment list.EndingsSystem.OfferImmortalityauto-quits the player's current team vianew TeamSystem().QuitTeam(player)after the king-abdication step. The QuitTeam helper handles the side effects (CTurf cleanup, team-dissolution check if last member). New flavor message: "Your mortal team bonds dissolve in the divine ascension."
What this does NOT do: heal existing teams that already contain immortals as members. The data lives in the SQL player_teams table indexed by team name + member name; a one-shot heal would need to scan teams, resolve each member name to its NPC record, check IsImmortal, and unlink. That's a defensible v0.60.x point-release task once the prevention layer is in place. For now, sysops can clean up via the wizard console (OnlineAdminConsole) by removing the immortal manually from any team where they're listed.
Files modified:
Scripts/Systems/TeamSystem.cs--IsRecruitableaddsnpc.IsImmortalfilter.Scripts/Systems/EndingsSystem.cs--OfferImmortalityauto-quits team after auto-abdicating king.
Guild parity (same fix shape, same release)
Question raised in audit follow-up: "When we patched gods out of teams, did we also make sure they aren't in guilds too?" Audit confirmed no -- the guild system had the same two holes:
GuildSystem.AddMemberhad noIsImmortalcheck. An ascended player could accept a guild invite via/acceptand would appear on the guild roster like any mortal member. The same "have a god in our guild" narrative break the team filter prevented.GuildSystem.CreateGuildalso bypasses AddMember, doing a directINSERT INTO guild_membersfor the founder, so it needed an independent immortal check at the top.EndingsSystem.OfferImmortalitydidn't auto-leave guild the same way it auto-quits team. An ascending player who was already in a guild stayed on the roster permanently -- and once the new AddMember/CreateGuild filters land, they couldn't even rejoin if removed.
Fix: same shape as the team patch.
GuildSystem.AddMemberchecksSessionContext.Current.Player?.IsImmortalfor the username being added (the accept-invite flow runs while the invitee has an active session, so the live Character is reachable). Returns"Gods do not join guilds."on hit.GuildSystem.CreateGuildadds the same check at the top with the message"Gods do not lead guilds.". Catches the founder direct-insert path.EndingsSystem.OfferImmortalityqueriesGuildSystem.GetPlayerGuild(username), callsRemoveMember(username)if the player is in one (RemoveMember handles leader-promotion automatically if they led the guild), and prints"Your guild crest tarnishes and falls away. The faithful have no guild."
Same caveat as teams: existing guilds that already contain immortals from before this fix are not auto-cleaned. Sysop intervention via the admin console can resolve any stragglers.
Additional files modified:
Scripts/Systems/GuildSystem.cs--AddMemberandCreateGuildboth gate onIsImmortal.Scripts/Systems/EndingsSystem.cs--OfferImmortalitynow also auto-leaves guild after auto-quitting team.
Permanently mis-typed shields now self-heal on load
Player report (Glamdring, Lv.58 Troll Warrior): "Shield abilities are not active for me. Even though a shield is equipped they are not usable. I did notice that my combat style is listed as dual wield even with the shield equipped."
This is the same bug-class as the v0.53.13 "Shield-turned-sword on save reload" fix, but for a corner case that v0.53.13's heal couldn't catch. The v0.53.13 migration added a isShield = ShieldBonus > 0 || BlockChance > 0 || WeaponType ∈ {Shield, Buckler, TowerShield} check that skips the bad weapon-type re-inference. Subsequent loads heal an item that was momentarily mis-typed. But for a player whose shield got mangled by a load that ran BEFORE v0.53.13, then got saved with WeaponType=Sword, ShieldBonus=0, BlockChance=0 (because the mangled state was the new reality at save time), no later load can recognize the item as a shield. All three stat-side signals are gone. The item silently stays a "Sword" forever.
HasShieldEquipped then returns false, IsDualWielding returns true (one-handed weapon + one-handed "sword" in offhand), and:
- Shield-required abilities (
shield_wall,shield_wall_formation,divine_shield,divine_mandate,iron_fortress,aura_of_protection, etc.) fail theirRequiresShieldgate. - Combat status display says "dual wield" because the engine sees two one-handed weapons.
- Tank spec damage bonuses tied to shields don't apply.
- IsDualWielding fires an off-hand "swing" each round that does nothing because the "weapon" has no WeaponPower.
Fix: name-based shield detection added as a third signal alongside the stat checks. New ShopItemGenerator.LooksLikeShieldByName(string name) matches the keywords Shield, Buckler, Aegis, Bulwark, Pavise, Targe, Heater, Kite, Round Wood, Ward of , Fortress, and Tower (excluding Towering to avoid false positives). Both migration sites in GameEngine.cs (single-player save load and online-mode SQL load) now consult this as a third clause: if the item is in OffHand AND the name looks shield-like, it's treated as a shield even when all stat signals were stripped. The existing heal branch then re-runs InferShieldType(name) to restore the proper WeaponType (Shield/Buckler/TowerShield) and Handedness = OffHandOnly.
After Glamdring's next login, his shield will be detected by name, healed back to a proper shield, and his shield abilities will activate. The fix is one-shot: once the shield's WeaponType is corrected and saved, subsequent loads stay fast (the migration's existing stat-side signals match again).
This does NOT recover lost ShieldBonus / BlockChance values -- those were zeroed by the original v0.53.13-era bug and we have no source-of-truth to restore them from. Glamdring's shield will function as a shield (handedness, ability gating, and "sword and board" detection all work) but with whatever stat values are currently in the equipment record. If he wants the original BlockChance back, he can sell + rebuy / reroll the shield, but the basic functionality is fixed.
Files modified:
Scripts/Systems/ShopItemGenerator.cs-- newLooksLikeShieldByName(string name)helper.Scripts/Core/GameEngine.cs-- both load-path migration sites add the name-based shield detection as a third clause toisShield/isShieldNpc.
Magic Shop enchantment silently stripped existing enchants
Player report (Lumina Starbloom, Lv.39 Wavecaller): "Adding enchantment to a looted weapon removes existing enchantments. I had Piercing Bardiche WP:262 ArmorPen 48%, Fire, Frost. Wanted to add lifedrinker. But after Ravanella enchanted the weapon, only lifedrinker remained. There was no warning the weapon had too many enchantments."
Two distinct issues:
Cause #1: Equipment.Clone() was missing 13 fields
The Magic Shop's enchant flow does var enchanted = selectedEquip.Clone(); enchanted.IncrementEnchantmentCount(); /* apply new enchant */; player.EquipItem(enchanted);. The intent is "copy the existing equipment, layer the new enchant on top, replace the original." But Equipment.Clone() was hand-written and silently dropped:
HasFireEnchant,HasFrostEnchant,HasLightningEnchant,HasPoisonEnchant,HasHolyEnchant,HasShadowEnchant(6 elemental enchant flags from v0.30.9+)ManaSteal,ArmorPiercing,Thorns,HPRegen,ManaRegen(5 proc-based enchantment ints from v0.40.5)HasBossSlayer,HasTitanResolve(2 world-boss-exclusive flags from v0.54.0)
So Lumina's Piercing Bardiche (HasFireEnchant=true, HasFrostEnchant=true, ArmorPiercing=48) went through Clone() and the cloned copy reset all three to defaults (false, false, 0). The Lifedrinker enchant added LifeSteal += 3 and the resulting weapon had ONLY Lifedrinker. Same bug class as the v0.53.5 "Loot stat transfer fix -- CHA/AGI/STA silently dropped on equip" -- fields added to the schema after Clone() was first written never got threaded into the copy method.
Fix: 13 fields added to Clone() to bring it back into parity with the field declarations. Verified by inspection that no other code calls Clone() with intent to RESET these fields (Magic Shop uses Clone() for both the success and 4th/5th-enchant-failure paths; the failure path WANTS to drop one enchant via decremented counter + stat reduction, but doesn't expect the elemental flags to silently vanish either).
Cause #2: no pre-attempt warning when the weapon already has 3+ enchants
The Magic Shop has a "4th/5th enchant has X% chance to fail and destroy an existing enchant at random" mechanic via GameConfig.FourthEnchantFailChance / FifthEnchantFailChance. The narrative flavor at the failure point already says: 'I warned you... that's the risk of pushing beyond three enchantments.' -- but that's narrated AFTER the gold and materials are spent. There was no actual warning before the player committed.
Fix: pre-confirmation warning block in MagicShopLocation.cs. When the selected weapon has GetEnchantmentCount() >= 3, the confirmation prompt now prepends a bright-red WARNING listing:
- The current enchant count
- The exact failure percentage based on the current count
- What the failure consequences are (gold + materials consumed, one existing enchant destroyed at random)
- An in-fiction line from Ravanella ("You are testing the weave.")
The player still has to type Y at the existing proceed prompt; no second typed phrase. The warning is informational, not a separate gate -- players still control their own risk.
Files modified:
Scripts/Core/Items.cs--Equipment.Clone()now copies all elemental enchant flags, proc-based enchantment ints, and world-boss-exclusive flags. 13 fields added.Scripts/Locations/MagicShopLocation.cs-- pre-confirmation warning whenGetEnchantmentCount() >= 3.
Symphony of the Depths -- teammates now actually get the auto-crit
Player report (Lumina Starbloom, Lv.30 Wavecaller): "Symphony of the Depths does not always trigger crit on the next attack. Several attacks after using Symphony were normal attacks, despite the message saying a crit is guaranteed on the next hit."
Root cause: the spell description explicitly promises "All allies: +60 attack, +40 defense, guaranteed crit on next hit" but the implementation only set the Hidden status (the engine's auto-crit-on-next-attack marker) on the caster. Per the SpellSystem case 5 block at line 1512, caster.ActiveStatuses[StatusEffect.Hidden] = 2 was applied to the caster only. The multi-target buff loop in CombatEngine at line 14789 then iterated teammates and applied +60 attack (TempAttackBonus) and +40 defense (Blessed + MagicACBonus) -- but did NOT propagate Hidden. So teammates' next attacks were ordinary, exactly matching Lumina's report.
Lumina has Mira and Melodia in her party (per her save log), so when they attacked between her Symphony cast and her next own attack, they fired normal hits. The "next hit" the description was promising for "all allies" was only ever delivered to the caster.
Fix: in the multi-target buff loop, after applying ProtectionBonus and AttackBonus to each teammate, propagate Hidden = 2 if the caster has Hidden (Symphony's marker). Hidden=2 matches the caster's value and the established shadow-ability convention from v0.57.17. Teammates have no per-round ProcessStatusEffects tick anyway, so the 2 vs 1 distinction matters less for them, but consistency keeps the code symmetric.
After the fix, Lumina + Mira + Melodia each crit on their next attack after Symphony resolves. The "guaranteed crit on next hit" promise in the description is now actually true for all allies.
Files modified:
Scripts/Systems/CombatEngine.cs-- Symphony / Covenant multi-target buff loop now propagatesHiddenstatus to all living teammates when the caster has it.
/gos mute now blocks Discord-originated gossip too
Player report: "The /gos mute feature isn't blocking Discord gossips, just in-game gossips. It should turn it totally off for the player."
The v0.57.15 per-channel mute (/gos / /shout / /gc / /tell with no message toggles mute) wires channelKey through RoomRegistry.BroadcastGlobal so recipients with the channel in their MutedChannels set are skipped. In-game gossip respects this. The Discord inbound path missed it.
MudServer.DiscordBridgePollerAsync polls the discord_gossip SQLite table and rebroadcasts each Discord-side message in-game. Pre-fix, that broadcast called RoomRegistry.Instance.BroadcastGlobal(text) with no channelKey argument so the per-channel mute filter was skipped entirely. A player who had typed /gos to silence gossip kept seeing every Discord post in the same channel.
The GMCP fan-out for chat-capture clients (Mudlet/MUSHclient/TT++ split panes feeding Comm.Channel.Text to a status pane) was also missing for Discord-inbound, so those clients got Discord posts through the regular ANSI scroll instead of the structured GMCP stream they'd subscribed to.
Fix:
BroadcastGlobalcall inDiscordBridgePollerAsyncnow passeschannelKey: "gossip"so the sameMutedChannelsfilter that gates in-game/gosalso gates Discord-inbound gossip.MudChatSystem.FanoutChannelText(the GMCPComm.Channel.Textemitter) was changed fromprivate statictointernal staticso the Discord poller can call it. Discord-inbound gossip now also reaches GMCP-enabled clients via the same channel, with the same mute-respecting fanout logic.
Outbound direction (a muted player typing /gos themselves) intentionally still sends -- the player explicitly took an action; muting is for incoming traffic.
Files modified:
Scripts/Server/MudServer.cs-- Discord poller passeschannelKeyand callsFanoutChannelText.Scripts/Server/MudChatSystem.cs--FanoutChannelTextvisibility raised fromprivatetointernal.
Lifedrinker cap
Player report: a single 11,873-damage hit drained 14,571 HP via Lifedrinker plus 716 from Divine Boon, total 15,287 healed for 11,873 damage. ~129% combined lifesteal. Mathematically and thematically wrong, you cannot drain more life than the target had to give.
Two stacking gaps caused it:
-
Character.GetEquipmentLifeSteal()summedLifeStealfrom every equipped slot with no cap. Stack Lifedrinker on weapon (30%) + necklace (25%) + two rings (20% each) + a body armor piece (10%) and you sit at 105% lifesteal from equipment alone. Then it gets multiplied by damage and applied as flat HP heal. No upper bound. -
Five lifesteal sources fire independently in
ApplyPostHitEnchantmentswith no shared budget: divine blessing (dark god boon), equipment Lifedrinker, divine boon (worshipped player-god), Abysswarden Abyssal Siphon class passive (10%), and ability-granted status lifesteal (Voidreaver/Assassin/etc.). Each computed its own slice of damage and applied it to HP separately. Stacking three or four of these on one hit easily produced > 100% combined lifesteal.
Two-layer cap:
- Per-source cap on equipment lifesteal.
GetEquipmentLifeStealnow clamps the sum toGameConfig.MaxEquipmentLifeStealPercent = 60. Stacked Lifedrinker enchants still help, but the combined total maxes at 60% of damage. Individual enchant tooltips/displays are unchanged (the cap is at sum-time, not per-piece) so a player can still see "+30% LifeSteal" on a weapon and reason about gear correctly. - Total per-attack cap across all sources. New shared budget
damage * GameConfig.MaxTotalLifestealPercent / 100(default 100%). Each of the five sources draws from this budget in declaration order; once the budget is exhausted, later sources scale down or skip entirely. So even with maxed equipment lifesteal AND active Divine Boon AND Abysswarden passive AND status lifesteal all firing on one hit, total heal cannot exceed damage dealt.
For the player's report case (11,873 damage):
- Pre-fix: equipment lifesteal at ~123% = 14,571 healed + boon 716 + others = ~15,300 total heal.
- Post-fix: equipment capped at 60% = 7,124 healed, then boon scales into the remaining 100%-60% = 40% budget = 4,749 cap (their boon was 716, well under), then later sources draw what's left. Total heal cannot exceed 11,873.
Files modified:
Scripts/Core/GameConfig.cs-- newMaxEquipmentLifeStealPercentandMaxTotalLifestealPercentconstants.Scripts/Core/Character.cs--GetEquipmentLifeStealclamps to per-source cap.Scripts/Systems/CombatEngine.cs--ApplyPostHitEnchantmentsrefactored with a sharedlifestealBudgetandApplyLifestealSlicelocal helper that all five lifesteal sources draw from in sequence.
Alignment audit -- diminishing returns near the cap
The alignment-cheese pass below capped per-action gold-to-alignment conversion. This deeper audit covers the wider problem: even with per-action caps, a determined player could chain Old God saves (+150 each), knighthood (+50), and 30+ small donations to reach the 1000-point cap in a single session. Player request: getting 1000 should be hard.
Audited every alignment grant site (98+ ChangeAlignment calls plus 5 direct-mutation bypasses) and added two changes:
1. Diminishing returns on the GAIN side, applied globally in ChangeAlignment
The closer a scale gets to the 1000-point cap, the smaller each gain becomes:
| Current value | Grant multiplier |
|---|---|
| 0..499 | 100% (normal early/mid progression) |
| 500..699 | 75% |
| 700..849 | 50% |
| 850..949 | 25% |
| 950..999 | 10% |
All 98+ grant sites benefit automatically because ChangeAlignment is the funnel. Paired-movement (the v0.57.0 design where good deeds also lower darkness, and vice versa) uses the un-scaled amount on the OPPOSITE side, so a small evil deed at high chivalry still meaningfully cleanses the chivalry. Only the gain side is scaled.
Progression simulation (start at 0, no opposing alignment to offset):
| Per-action grant | Actions to reach 1000 |
|---|---|
| +50 (knighthood, castle/temple capped donations) | 38 |
| +25 (church donation/blessing) | 84 |
| +5 (routine quests, beggar gifts, bandage wounded) | 391 |
| +150 (Old God save, story-gated, max 7 per cycle) | 12 (if all 7 Old Gods spared/saved) |
Realistic mixed late-game path: knighthood (1 action) + 4-5 Old God saves (~40 actions worth of +150 gated by phase 4-7 dungeon floors) + ~30 capped temple/castle/church donations. Roughly one full play-through to max if dedicated, multiple if balanced.
Symmetric for darkness: maxing evil from 0 needs ~20 temple sacrifices, multiple murders (capped via DR), bank robberies, Old God killings, plus altar desecrations.
2. Routed remaining direct-mutation bypass sites through ChangeAlignment
The v0.57.12 audit caught most sites; v0.60.0's audit found four more that mutated Chivalry / Darkness directly and skipped both paired movement and (now) DR:
BankLocation.cs:1549(loan-delinquency minor, +5 darkness)BankLocation.cs:1574(loan-delinquency severe, +20 darkness)MagicShopLocation.cs:3397(death-spell success, dark shift)MagicShopLocation.cs:3440(death-spell failure, half dark shift)BaseLocation.cs:1207-1212(encounter-stage choice rewards, both directions)CastleLocation.cs:1980(king banishment punishment, +500 darkness)
All now go through ChangeAlignment. The opposing-side direct decrements (e.g. Chivalry = Math.Max(0, Chivalry - 5000) for banishment, Chivalry -= 50 for severe loan-delinquency) stay direct because they are flat punishments, not gains -- DR doesn't apply to losing alignment.
QuestSystem.cs already routed its quest reward grants through ChangeAlignment in v0.57.12; QuestHallLocation.cs:724/727 only populates a display DTO and never touches the live character, so it doesn't need wrapping.
Why per-action caps AND DR
The cheese pass alone would still let a player donate 20 times to reach max via the +50 cap. DR alone would still let one Old God save move alignment +150 from 0. Both together produce the right curve: small donations matter early, big story events have impact mid-game, and the final stretch (850 to 1000) costs 5-10x as much as the first 500 points. Maxing alignment is now a long-term commitment, not a checkbook decision or a single dungeon run.
Files
Scripts/Systems/AlignmentSystem.cs--ChangeAlignmentnow scales gain by current value via newScaleAlignmentByDRhelper. Paired opposite-side movement unchanged (uses raw amount).Scripts/Locations/BankLocation.cs,MagicShopLocation.cs,CastleLocation.cs,BaseLocation.cs-- four bypass sites routed throughChangeAlignment.
Alignment cheese pass
Player report from in-game gossip: "Overloading goodness or darkness is crazy easy. Donate 100k gold to an evil god, probably less, and I have max darkness. Pay the king some 200k, then go to church and pay them like 50k, and I have max chivalry."
Audit found three alignment-grant paths that converted gold to alignment 1:1 without per-action caps, against an AlignmentCap of 1000. The Castle treasury donation was already correctly capped at +50 per action (CastleLocation.cs:7090); two other paths missed the same cap:
| Path | Pre-fix formula | 100k donation result | Behavior |
|---|---|---|---|
| Castle treasury | Math.Min(50, amount/100)
| +50 chivalry | already capped (correct) |
| Church donation | amount/11
| +9090 chivalry, clamped to 1000 | maxed in one transaction |
| Church blessing | amount/15
| +6666 chivalry, clamped to 1000 | maxed in one transaction |
| Temple gold sacrifice (evil/good god) | amount/100
| +1000 darkness or chivalry, exactly the cap | maxed in one transaction |
The setter clamp (added in v0.57.12) prevented values from exceeding 1000, but it didn't slow down the path to 1000. One donation = full alignment.
Fix. Cap per-action alignment gain across all three paths so reaching the alignment cap requires sustained behavior, not one big payment:
- New
GameConfig.MaxChivalryGainPerChurchAction = 25-- church donation and blessing now cap at +25 chivalry per action regardless of donation size. A 100k donation still costs 100k gold (more, with the v0.60.0 issue #89 penance) but only moves alignment by +25. - New
GameConfig.MaxAlignmentGainPerTempleSacrifice = 50-- temple gold sacrifice to evil/good gods caps at +50 darkness/chivalry per action. A 100k sacrifice grants +50 darkness (and the same value to faction reputation, also intentional -- big donors should not buy a top faction rank in one shot either). - Castle treasury donation unchanged (already capped at 50).
Net effect: maxing chivalry from 0 now requires roughly 40 church donations or 20 castle/temple actions plus knighthood (a one-shot +50 grant) and quest rewards. Maxing darkness from 0 requires roughly 20 temple sacrifices, plus murders, bank robberies, and altar desecrations. Both paths cost real time and real gold; alignment is once again a meaningful long-term lever instead of a checkbook decision.
The raw gold cost is unchanged, the displayed actual delta after clamping (added in this same release) makes the new per-action gain transparent to the player. Faction reputation paths (TheFaith from church donations, TheShadows from evil sacrifices) inherit the temple cap because they share the same standingGain variable; this is intentional and prevents factional rank-buying.
Files modified:
Scripts/Core/GameConfig.cs-- newMaxChivalryGainPerChurchActionandMaxAlignmentGainPerTempleSacrificeconstants.Scripts/Locations/ChurchLocation.cs-- both donation and blessing paths caprequestedChivalryGain.Scripts/Locations/TempleLocation.cs-- gold sacrifice path capsstandingGain.
Stun-lock cheese pass
Player report from in-game gossip channel: "I just stun-lock the boss and walk through the fight." Audit confirmed the cheese was real and structural, not a one-off bug. The issue lived in three places at once:
- Three independent stun fields on
Monster-- legacyStunned(bool), modernIsStunned + StunDuration, and a thirdStunRoundscounter -- each ticked separately inProcessMonsterAction. A player using both a Magician Stun spell AND a Lightning-enchanted weapon would set TWO different fields on one monster; the monster then skipped two consecutive monster turns from a single round of player input. - No "already stunned" check in any application path. Spells, abilities, and enchant procs all used the pattern
if (target.StunImmunityRounds > 0) resist; else target.IsStunned = true;. They never asked "is this monster already stunned right now?", so a stun applied while already stunned just refreshed the duration viaMath.Max. Indefinite refresh = perma-stun. - Immunity window was 1 round AND ticked down before the player's next attack.
StunImmunityRounds = 1after a stun expired, thenProcessMonsterActiondecremented it to 0 on the very same monster turn, leaving zero gap before the next player input. Effective immunity duration: zero.
Lightning Enchant procs were the highest-volume vector. 15% per attack, but with multi-attack classes and dual-wielding the proc rolls compound to ~50% per round. With three stun fields stacking and no "already stunned" guard, a Magician/Sage with a Lightning Bardiche could keep an Old God boss perma-stunned for an entire fight.
Fix: centralized CombatEngine.TryStunMonster(Monster, int) helper that gates every stun application. All 14+ stun-application call sites (Magician/Sage Stun and Web spells, Cleric Web, Wavecaller Temporal Paradox, Cyclebreaker Temporal Prison, Bard Dissonant Wave, Bard Legendary, Ranger Legendary Shot, Lightning enchant proc both single + multi-monster, Assassin Nightshade Extract / Widows Kiss poisons, Sage holy-pacify spell-convert, generic ability "stun" effect) now route through this single helper. The helper enforces:
- Refuse if already stunned in any system. Checks
IsStunned,Stunned, ANDStunRounds > 0. No more refresh-stack onto an active stun. The player has to wait for the current stun to expire before attempting another. - Refuse during post-recovery immunity.
StunImmunityRoundsis now set toGameConfig.StunImmunityRoundsAfterRecovery = 3(was hardcoded1) when a stun expires. With the longer window, the monster always gets at least one free round to act between consecutive stuns. - Boss/mini-boss flat resist + duration cap. Bosses and mini-bosses get a 50% flat resist roll BEFORE the stun is applied (
GameConfig.BossStunResistChance). On a successful application, duration is capped atGameConfig.MaxStunDurationBoss = 1regardless of source. Old God bosses are now stuns-resistant by design rather than perma-stunnable. - Diminishing returns on consecutive stuns. New
Monster.RecentStunCountfield; first stun = 100% duration, second = 50%, third = 25%, fourth attempt within the DR window = outright immune. The DR window (GameConfig.StunDRWindowRounds = 5rounds without a stun) letsRecentStunCountdecay back toward zero, so a stun-resistant boss eventually becomes vulnerable again later in a long fight rather than staying permanently DR-immune after one good chain. - Hard duration cap. Normal monsters cap at
GameConfig.MaxStunDurationNormal = 3rounds. No matter what an ability claims, no stun lasts longer than that.
ProcessMonsterAction's tick logic was also fixed: StunImmunityRounds and the new DR decay (RoundsSinceLastStun) tick on every monster turn (alive, not stunned), not only when a stun expires. The pre-fix flow exited early on stun-expire and skipped the immunity tick on the same turn, which is why a 1-round immunity often acted like zero.
Net effect. A Magician with a Lightning Bardiche fighting a regular dungeon monster: first stun lands at full duration, second stun (if attempted within DR window) lands at half, third at quarter, fourth fizzles until the window resets. The same Magician fighting an Old God: 50% chance the first stun is resisted outright, 1-round cap if it lands, full DR thereafter. Stun is still useful as a "buy a free round" mechanic; it is no longer a fight-skipping cheese.
Files modified:
Scripts/Core/Monster.cs-- newRecentStunCountandRoundsSinceLastStunfields.Scripts/Core/GameConfig.cs-- newStunImmunityRoundsAfterRecovery,StunDRWindowRounds,MaxStunDurationNormal,MaxStunDurationBoss,BossStunResistChanceconstants.Scripts/Systems/CombatEngine.cs-- newTryStunMonsterhelper, refactored 14+ stun-application sites, updatedProcessMonsterActiontick logic.
Issue triage pass (issues #85, #86, #87, #88, #89, #91)
Six community-reported bugs from fastfinge knocked out together.
#85 Turn Undead damaged living enemies
The Cleric Lv.6 spell "Turn Undead" used SpecialEffect = "holy", which is a generic +50% bonus on top of regular spell damage. The base 35-50 damage applied to anything regardless of type, so a Cleric in a fight against orcs could cast Turn Undead and deal full damage to non-undead targets. Player expectation matches the D&D-style "Turn Undead" namesake: living enemies should be unaffected.
Fix: dedicated SpecialEffect = "turn_undead" routed through both single-monster and multi-monster spell paths. When the target's MonsterClass is not Undead/Demon (and Undead == 0 for hand-tagged monsters), the spell prints "X is not undead. The holy light has no effect." and applies zero damage. Companion AI (GetBestOffensiveSpell) drops Turn Undead from the candidate list when no undead/demon target exists, so a Cleric companion in a fight against orcs no longer burns a turn on a guaranteed whiff. New combat.spell_turn_undead_unaffected loc key in all 5 languages. Spell description updated to make the restriction explicit.
#86 Autocompare suggested weapons that were "better than my shield"
When a teammate has sword + shield, IsDualWielding should return false (the existing !HasShieldEquipped clause handles it for correctly-typed shields). But for shields that got mis-typed by an older buggy load (re-inferred as WeaponType.Sword, no BlockChance/ShieldBonus left), HasShieldEquipped reads false, IsDualWielding reads true, and the autocompare's dual-wield "pick the weaker slot" branch targets the OffHand. With shield stats stripped, every weapon looked like a huge upgrade vs effectively-zero defense.
Fix: defense-in-depth shield detection in TryTeammatePickupItem. Before treating as dual-wield, check whether the OffHand item smells like a shield via stat signals (BlockChance > 0 || ShieldBonus > 0) or name keywords (ShopItemGenerator.LooksLikeShieldByName). If yes, skip the slot-swap and keep MainHand as the comparison target. Catches both correctly-typed and mis-typed shields without touching the player-side comparison display, which already always targets MainHand for weapon drops.
#87 Sometimes enemies disappear after combat
v0.57.12 added a kill-tracking sweep at the top of HandleVictoryMultiMonster: any dead monster not yet in DefeatedMonsters gets added before reward calculation. That fix only ran on the victory branch. If the player fled after killing some enemies, HandlePartialVictory read DefeatedMonsters.Count directly and the untracked kills were lost. Same shape if the player died with kills mixed in.
Fix: hoist the sweep to run unconditionally right before the outcome branch, so flee, victory, and death paths all see the complete kill list. Idempotent (only adds dead monsters not already tracked). Player report: "After combat, sometimes I'm told I defeated 0 enemies and got 0 XP. Or when fighting more than one enemy, the results give me rewards for one less enemy than I actually fought."
#88 Weapon enchant procs triggered after the enemy was already dead
CheckElementalEnchantProcs fired Fire/Frost/Lightning/Poison/Holy/Shadow procs unconditionally after every basic attack. When the basic attack's damage already killed the target, the proc rolls still ran, dealt their bonus damage to a 0-HP corpse, and printed messages like "Flames erupt! Skeleton takes 15 damage!" on a dead body. Pure spam, no balance value.
Fix: gate the elemental proc damage/status branches on target.HP > 0 && target.IsAlive. Mana steal at the bottom of the same method intentionally still fires on the killing blow (it's an attacker-side resource gain that's still useful when the target dies). Same fix in the CheckElementalEnchantProcsMonster twin for multi-monster combat.
#89 Church confession was free and unlimited
Confession at the church reduced 5-10 darkness and granted 2-5 chivalry per use, with no cost and no limit. Confession at the temple costs gold for darkness cleansing; the church should have an equivalent friction. Player suggestion was "a cost (maybe in penance?)".
Fix: penance gold cost added to ProcessConfession, scaling with the player's darkness so the price tracks the benefit. New constants GameConfig.ConfessionBaseCost = 100 and ConfessionCostPerDarkness = 5. 50 darkness = 350g, 200 darkness = 1100g, 1000 darkness (cap) = 5100g. Confession refuses if the player can't afford it. Three new loc keys (church.confess_penance_cost, church.confess_no_gold, church.confess_penance_paid) in all 5 languages. Existing blood-absolution flow (which already had a gold cost) is unchanged.
#91 Damage immunities by monster type
Player request: angels immune to holy damage and Syphon (mana steal), demons immune to shadow damage and Lifedrinker (equipment lifesteal). The codebase already does name-based type detection for the +2x holy bonus on undead, so the new immunities follow the same pattern.
Fix: new helpers IsAngelMonster (matches Angel/Archangel/Seraph/Throne/Empyrean/Cherub/Celestial in the name) and IsDemonMonster (matches Demon/Devil/Imp/Archfiend/Hellspawn/Fiend, plus the existing MonsterClass.Demon enum). Holy enchant proc skips with "Divine light washes over X, who absorbs it harmlessly" when target is angelic. Shadow enchant proc skips with "Shadow tendrils slide off X's infernal hide" when target is demonic. Mana steal silently fizzles on angels (they carry no mana). Lifedrinker silently fizzles on demons (no mortal life force to drain). Two new loc keys in all 5 languages.
Files modified across all six fixes:
Scripts/Systems/CombatEngine.cs-- Turn Undead damage gate (single + multi-monster), companion AI Turn Undead filter, autocompare shield detection, kill-tracking sweep hoisted, post-death enchant gate (both procs methods), angel/demon helpers, holy/shadow/siphon/lifedrinker immunity gates.Scripts/Systems/SpellSystem.cs-- Turn UndeadSpecialEffect = "turn_undead", description text update.Scripts/Locations/ChurchLocation.cs-- penance cost, gold check, deduction, paid-message.Scripts/Core/GameConfig.cs--ConfessionBaseCost,ConfessionCostPerDarknessconstants.Localization/{en,es,fr,hu,it}.json-- six new keys (combat.spell_turn_undead_unaffected,church.confess_penance_cost,church.confess_no_gold,church.confess_penance_paid,combat.enchant_holy_immune_angel,combat.enchant_shadow_immune_demon).
Idle disconnect warning now beeps (issue #90)
GitHub issue #90 from fastfinge: "Sometimes I get distracted. Because work. Or meetings. Or whatever. And I have to minimize the window. … Would it be possible to add a 'beep!' sound that plays when the 'two minute warning' idle disconnection message plays?"
The MUD server's idle watchdog at MudServer.cs:1014 was firing the warning text fine but as a silent yellow line -- invisible to anyone with the window minimized.
Fix: emit an ASCII BEL character (\x07) before the warning text and emit a structured sound event for the Electron client. The BEL is universal across every client we ship to -- xterm, WezTerm, Mudlet, TinTin++, MUSHclient, VIP Mud, NVDA-paired clients, web terminal (xterm.js with audible-bell on), SyncTERM/NetRunner, and the bundled Play.bat desktop terminal all honor it. Two BELs because some terminals coalesce a single bell into a tick that's easy to miss while alt-tabbed; two reliably gets noticed without crossing into obnoxious.
Implementation routes through Terminal.WriteRawAnsi("\x07\x07") so the byte passes through the existing plain-text / CP437 / ANSI handling untouched (ToPlainText strips ANSI escapes and markup tags but explicitly leaves control characters like BEL alone -- important for screen-reader MUD clients that translate BEL to system sound or NVDA speech feedback). For the Electron graphical client, ElectronBridge.EmitSound("sfx.idle_warning", channel: "ui") fires; the actual .ogg asset is part of the Phase 9.5 audio scaffolding backlog and the JS side gracefully ignores unknown soundIds, so this is safe to ship before the audio file lands.
Files modified:
Scripts/Server/MudServer.cs-- idle-warning block now emits\x07\x07viaWriteRawAnsiand an Electronsfx.idle_warningsound event (whenGameConfig.ElectronMode).
Auto-detect host screen reader on Windows (issue #92)
GitHub issue #92 from fastfinge: blind Steam players might not realize they need to set the --screen-reader launch option (via Steam launch options or by picking Play-Accessible.bat) to enable screen-reader mode at the title screen and character creation. The accessible launcher exists, but discovering it requires a sighted helper or a hint in the Steam description.
Fix: on Windows, auto-detect a running screen reader at startup using the SPI_GETSCREENREADER system parameter (NVDA, JAWS, Narrator, and most other Windows screen readers set this flag while running -- it's the documented Microsoft accessibility API for this purpose). When detected and no explicit --screen-reader flag was passed, the game enables screen-reader mode automatically and prints a one-line announcement so the user knows what happened and how to opt out via in-game preferences.
Implementation:
- New
Scripts/UI/AccessibilityDetection.cs-- single-method helper with the[DllImport("user32.dll")] SystemParametersInfo(SPI_GETSCREENREADER, 0, out bool, 0)P/Invoke. Returnsfalseon macOS / Linux / containers withoutuser32.dllso the game stays portable. Any P/Invoke exception is treated as "no screen reader detected" -- the explicit flag still works as the fallback. Console/Bootstrap/Program.cs-- auto-detect runs afterDoorMode.ParseCommandLineArgs(so--screen-readershort-circuits the check viaif (!GameConfig.ScreenReaderMode && ...)) and before the door-mode vs standard-console-mode branch dispatches. Both Steam paths (Play.bat→ WezTerm →--local→ door-mode-with-CommType.Local AND any direct standard-mode launch) trigger the check.- Skipped for non-local door connections: real BBS doors (the host's screen reader is irrelevant to the remote user, and BBS clients negotiate accessibility via the existing TTYPE probe in
MudServer), MUD server / MUD relay / world-sim modes (headless services). Detection runs for--local, no-args, and pure standard-console launches. - Saved characters' explicit
ScreenReaderModepreference still wins on character load viaGameConfig.ScreenReaderMode = player.ScreenReaderModeatGameEngine.LoadSaveByFileName-- this only sets the pre-character-load default and the inheritance value for fresh characters at creation time.
After the fix, a blind player who installs the game on Windows and launches it via the Steam shortcut sees:
Screen reader detected -- accessible mode enabled automatically.
(Disable in-game via the Preferences menu if you don't want this.)
The main menu, character creation, and all subsequent screens are then accessible without any launch-option configuration. Sighted players whose screen-reader software was running for an unrelated reason can flip the toggle off in-game.
macOS (VoiceOver) and Linux (Orca) don't expose an equivalent system-wide flag, so users on those platforms still need the --screen-reader flag or the in-game preferences toggle. The Steam blind-gaming audience is overwhelmingly Windows so this fix covers the high-value target; cross-platform detection can land in a follow-up release if requested.
Files added:
Scripts/UI/AccessibilityDetection.cs-- Windows SPI_GETSCREENREADER P/Invoke wrapper.
Files modified:
Console/Bootstrap/Program.cs-- auto-detect call between argument parsing and dispatch, with announcement to stdout when SR is enabled by detection rather than the explicit flag.
Stun (and other control effects) now actually disable party members
Player report: "Stun only works on enemies. A party member afflicted with stun can act next round as though nothing happened."
Confirmed and broader than just stun. Both teammate action paths -- ProcessTeammateAction (single-monster) at CombatEngine.cs:5684 and ProcessTeammateActionMultiMonster at CombatEngine.cs:16070 -- only had if (!teammate.IsAlive) return; at the top. They never:
- Called
teammate.ProcessStatusEffects()to tick status durations or apply DoT damage (poison, bleed, burn). - Checked
teammate.CanAct()forPreventsActionstatuses (Stun / Paralyze / Sleep / Freeze).
So when a monster ability landed a Stun on a party member via the path at CombatEngine.cs:17325 (companion.ApplyStatus(abilityResult.InflictStatus, abilityResult.StatusDuration)), the entry went into ActiveStatuses[StatusEffect.Stunned] and just sat there forever. The teammate's next turn ran the full healing → spell → ability → basic-attack chain like nothing happened, AND because the duration was never decremented, the status entry remained for the rest of combat (it eventually got blown away by the next combat-init reset, which is why nobody noticed the duration leak earlier).
Same gap covered Sleep (via spells like Magician's Sleep), Frozen (frost spells), and Paralyze. The player path has had this guarded since combat was first written -- player.ProcessStatusEffects() ticks at line 1654, player.CanAct() checks at line 1660, and the HasStatus(Stunned) skip prints "you are stunned and cannot act!" at line 1670. Teammates skipped the whole thing.
Fix: both teammate methods now mirror the player path -- ProcessStatusEffects() first (so DoTs land and durations tick), then IsAlive re-check (in case the DoT killed the teammate), then CanAct() guard with a "{Name} is stunned and cannot act!" line that bails out of the action. New combat.teammate_status_prevented localization key with {0} name + {1} status placeholders added in all 5 language files.
After the fix: a Mind Flayer's stun on Aldric actually skips Aldric's turn and the duration ticks down so it expires correctly. Sleep, Freeze, and Paralyze on teammates work the same way. Ongoing poison/bleed ticks now also fire on teammates each round (previously they were silently invisible -- applied by status apply, but the per-round damage hook never ran).
Files modified:
Scripts/Systems/CombatEngine.cs--ProcessTeammateActionandProcessTeammateActionMultiMonsternow tickProcessStatusEffectsand guard onCanActat the top of each method.Localization/{en,es,fr,hu,it}.json-- newcombat.teammate_status_preventedkey.
Church donation chivalry display matched the cap
Player report (the reporter, Lv.91 Mutant Assassin): "you cap chivalry at 1000 yet, when I go to the church, I donated 100,000 gold and it says I gained 9090 chivalry -- that's a crazy amount."
ChurchLocation.ProcessDonation at line 418 used the original Pascal formula chivalryGain = Math.Max(1, amount / 11) and then displayed that raw number in the post-donation message. The Character.Chivalry setter has clamped to GameConfig.AlignmentCap = 1000 since v0.57.12 so the actual stat couldn't go past the cap, but the message stubbornly displayed the uncapped 9090. A player at 950 chivalry donating 100,000g actually gained +50 (clamp), but the screen read "+9090". A player already at 1000 chivalry donating ANY amount gained 0 but the screen still said "+9090". The same shape was sitting in ProcessBlessingPurchase at line 502 using the amount / 15 formula.
The v0.57.12 alignment audit explicitly left church donation/blessing/confession as direct mutations rather than routing through AlignmentSystem.ChangeAlignment because they have asymmetric design (donation: small chivalry boost + flat -1 darkness; blessing: bigger chivalry + proportional darkness reduction) that paired-movement's 50% rule would flatten. That decision still stands -- the bug here is purely the display side.
Fix:
- Both
ProcessDonationandProcessBlessingPurchasenow snapshotChivalryandDarknessbefore mutation, apply the existing formulas (still goes through the v0.57.12 setter clamp), and computeactualChivalryGainandactualDarknessReductionfrom the before/after snapshot. The post-action message displays the actual deltas -- so the player sees the real change to their stat. - New
church.donate_chivalry_cappedflavor line added in all 5 language files for the case where the player donates while already at chivalry cap (clarifies that the donation provided no virtue benefit instead of just printing "+0 chivalry"). - Blessing path mirrors the same pattern, reading the actual darkness reduction rather than the requested amount so the line matches reality if the player started below the requested reduction.
After the fix: at 950 chivalry, donating 100,000g shows "+50 chivalry" (the actual delta). At 1000 chivalry, donating shows the new "your virtue could not climb higher" line. Donations still work as before -- the gold sink is unchanged, only the displayed numbers now match what landed.
Files modified:
Scripts/Locations/ChurchLocation.cs--ProcessDonationandProcessBlessingPurchasesnapshot before mutation, display post-clamp deltas, emitdonate_chivalry_cappedline when already at cap.Localization/{en,es,fr,hu,it}.json-- newchurch.donate_chivalry_cappedkey.
Wave Echo no longer silently fails on debuffed targets at high level
Player report (the reporter, Lv.55 Wavecaller, floor 54): "I wonder if Wave Echo is working properly. There used to be a message the damage was doubled on a weakened target, usually after I cast Siren's Lament. Now there is no such message."
Wave Echo is a Wavecaller level-5 ability with BaseDamage = 100, SpecialEffect = "double_vs_debuffed". The ability description promises double damage if the target is debuffed (poisoned, weakened, stunned, etc).
Type = AbilityType.Attack abilities run damage in two phases:
- The base-damage block (CombatEngine.cs:12263-12404 for multi-monster, mirror in single-monster) applies
abilityResult.Damagewith stat scaling, crit roll, and defense reduction, then deducts HP and emits the standard "you deal X damage" line. - A post-damage
switch (abilityResult.SpecialEffect)block at line 13565 (and twin at line 20764) hadcase "double_vs_debuffed"adding more damage and printing the resonance flavor message -- but the case was guarded byif (target != null && target.IsAlive).
At low levels the base damage doesn't kill the monster, the post-damage handler runs, the resonance message prints. The reporter saw this fine in the early game. By Lv.55 the base damage already kills floor-54 monsters in one hit, target.IsAlive == false skips the entire case, and the resonance message is gone -- even though the player has the buff prerequisite (Siren's Lament weaken applied) and the ability is supposedly proccing.
Same bug-class as v0.57.2's Shadow Harvest fix (separate post-damage handler that double-applied damage and silently skipped when the first hit killed the monster). The fix pattern is identical -- inline the modifier into the base-damage branch like Backstab and Shadow Harvest.
Fix:
- New
else if (abilityResult.SpecialEffect == "double_vs_debuffed" && target != null)branch added to the base-damage block in bothApplyAbilityEffectsMultiMonsterandApplyAbilityEffects. Checks the same 12 debuff conditions, doublesactualDamageif any are active, and emits one of two new flavor lines (combat.wave_echo_resonatesfor the doubled hit,combat.wave_echo_strikesfor the standard hit). The doubling now flows through the rest of the base-damage pipeline (crit roll, defense reduction, HP deduction, post-hit enchantments) and the standard "you deal X damage" line reports the correct doubled total. - The post-damage
case "double_vs_debuffed"in both switches becomes a no-op so the ability doesn't double-dip on damage. The case stays so unrouted ability code paths don't warn. - New keys
combat.wave_echo_strikesandcombat.wave_echo_resonatesadded to all 5 language files (en.json,es.json,fr.json,hu.json,it.json).
After the fix: Cast Siren's Lament → enemies have WeakenRounds > 0 → cast Wave Echo → resonance message prints + damage actually doubled. The "RESONATES with X's weakness -- DAMAGE DOUBLED!" line shows reliably from level 5 to 100 regardless of whether the base hit one-shots the target.
Files modified:
Scripts/Systems/CombatEngine.cs-- inlinedouble_vs_debuffedbranches in both ability paths, post-damage switch cases reduced to no-op.Localization/{en,es,fr,hu,it}.json-- newcombat.wave_echo_strikesandcombat.wave_echo_resonateskeys.
Newborn / renamed children no longer disappear in online mode
Player report (Lumina, Lv.53 Wavecaller): "I renamed a child and it disappeared. I should have 5 children, but only 4 are present. The child was newly born and renaming was the first thing I did."
The same architectural class as the marriage cross-player contamination bug fixed in v0.52.12. FamilySystem.Instance is a process-wide singleton holding every child every player has -- births, renames, orphan moves, and adult conversion all mutate this one shared list. WorldSimService's tick is the only authoritative writer of world_state["children"] (the durable copy that survives MUD server restarts). Player sessions read from world_state on login but used to also write the singleton from their own stale player-save children list inside SaveSystem.RestoreStorySystems at line 2461 -- FamilySystem.Instance.DeserializeChildren(data.Children) clears _children and replaces it with the logging-in player's view.
Cross-player race that broke Lumina's newborn:
- Lumina has a baby in IntimacySystem (
RegisterChildadds it to the singleton). Singleton has 5 of her children. - Lumina renames the newborn from the Home parenting menu (
HomeLocation.cs:1965mutatesselectedChild.Name). - WorldSim has not ticked yet so
world_state["children"]still reflects the pre-baby state with 4 of her children. - Another player logs in. Their
LoadSaveByFileNamerunsRestoreStorySystemswhich callsDeserializeChildrenwith that player's stale save snapshot -- the singleton is wiped and replaced with the second player's view, which does not contain Lumina's newborn (the new player went offline before the baby was born). - WorldSim's next tick fires while the singleton is in this contaminated state.
SaveChildrenState()reads the singleton (which now has no record of Lumina's newborn) and overwritesworld_state["children"]. The newborn and the rename are gone permanently. - Lumina's session continues. The singleton no longer has her newborn (it was clobbered in step 4 and re-loaded from the stale world_state in step 5). The Home parenting menu shows 4.
The marriage equivalent of this bug was already fixed by skipping RestoreMarriages/RestoreAffairs in online mode (see LoadSharedChildrenAndMarriages comment at GameEngine.cs:3786). Children needed the same treatment.
Three-part fix:
-
Skip
DeserializeChildrenin online mode duringRestoreStorySystems.LoadSharedChildrenAndMarriagesruns immediately afterRestoreStorySystemsin the online-mode load path (GameEngine.cs:2797-2801 and 4275-4282) and reloads from world_state, so the singleton still ends up with the authoritative children list -- the player's stale save copy is just no longer allowed to clobber the singleton in the brief window before that reload. Single-player mode is untouched (!IsOnlineModefalls through to the originalDeserializeChildrencall). -
Persist births to world_state immediately.
OnlineStateManager.SaveSharedChildrenNow()(new public method) writes the same wrapper schema WorldSimService uses (childrenRaw+ dashboard summary). Fire-and-forget invocation added toIntimacySystem.csright after the player names the newborn. The baby is durable across an unexpected MUD restart instead of living only in the singleton until WorldSim's next tick. -
Persist renames to world_state immediately. Same
SaveSharedChildrenNow()fire-and-forget call added to the rename branch inHomeLocation.InteractWithChild(after theselectedChild.Name = newFirst + surnamemutation).
Existing affected players: Lumina's missing newborn cannot be recovered from world_state because WorldSim already overwrote the durable copy with the contaminated singleton. Sysop intervention via OnlineAdminConsole would be needed to reconstruct the child manually. Going forward, any newborn or rename is durable from the moment of the action, and concurrent logins cannot wipe in-flight children.
Files modified:
Scripts/Systems/SaveSystem.cs--RestoreStorySystemsnow skipsFamilySystem.DeserializeChildrenwhenDoorMode.IsOnlineMode. Comment block explains the cross-player contamination class and points at the marriage-skip equivalent.Scripts/Systems/OnlineStateManager.cs-- new publicSaveSharedChildrenNow()method that writes the world_state children blob with the samechildrenRaw+ dashboard summary schema WorldSimService uses, callable from any player session.Scripts/Systems/IntimacySystem.cs-- birth-time naming flow now fires_ = OnlineStateManager.Instance.SaveSharedChildrenNow()immediately after the player names the newborn, so a fresh baby is durable to world_state without waiting for the WorldSim tick.Scripts/Locations/HomeLocation.cs--InteractWithChildrename branch fires the same fire-and-forget save, so renames don't depend on a WorldSim tick to persist.
Server-wipe button (Danger Zone, admin dashboard)
New "Wipe Server" button at the bottom of the admin dashboard (/admin.html) for the May 1 beta launch. Multi-step typed-confirmation flow ("WIPE THE WORLD" exactly), 5-second cancel-able countdown after the typed confirmation, calls /api/admin/nuke which spawns the server-side nuke script via NOPASSWD sudo. Script:
- Backs up the live DB to
/var/usurper/nuke-backups/pre-nuke-{timestamp}.db(kept indefinitely) - Stops
sshd-usurperandusurper-mudservices - Wipes all gameplay tables (
players,world_state,news,messages, guilds, teams, auctions, world bosses, sieges, deleted-character archive, audit log, etc.) inside a single transaction - Resets the
peak_onlinerow inadmin_configwithout dropping the table - Runs
VACUUMto reclaim space - Restarts both services
- Logs every step to
/var/log/usurper-nuke/nuke-{timestamp}.log(survives the wipe -- the in-DBadmin_commandsaudit table is itself one of the things wiped)
Preserved: balance_config (admin password hash), dashboard_users (admin login records), dashboard_sessions (so the operator stays logged in after pressing the button). Schema preserved (DELETE FROM, not DROP).
Server-side install (one-time, before May 1):
# Copy script + sudoers drop-in to the AWS server
scp -i "C:/Users/jason/.ssh/usurper.pem" \
scripts-server/nuke-server.sh \
scripts-server/sudoers-usurper-nuke \
ubuntu@3.150.229.205:/tmp/
# Install on server
ssh -i "C:/Users/jason/.ssh/usurper.pem" ubuntu@3.150.229.205 "
sudo mkdir -p /opt/usurper/scripts &&
sudo cp /tmp/nuke-server.sh /opt/usurper/scripts/nuke-server.sh &&
sudo chown root:root /opt/usurper/scripts/nuke-server.sh &&
sudo chmod 0755 /opt/usurper/scripts/nuke-server.sh &&
sudo cp /tmp/sudoers-usurper-nuke /etc/sudoers.d/usurper-nuke &&
sudo chown root:root /etc/sudoers.d/usurper-nuke &&
sudo chmod 0440 /etc/sudoers.d/usurper-nuke &&
sudo visudo -c -f /etc/sudoers.d/usurper-nuke &&
rm /tmp/nuke-server.sh /tmp/sudoers-usurper-nuke
"After install, deploy the updated web/ssh-proxy.js (which contains the /api/admin/nuke endpoint) and web/admin.html (which contains the Danger Zone section). Restart usurper-web.
Recovery if the wipe was a mistake: stop services, copy the most recent file from /var/usurper/nuke-backups/ over /var/usurper/usurper_online.db, restart services. The backup files are atomic SQLite snapshots (.backup not cp), so they're safe to restore as-is.
Files added:
scripts-server/nuke-server.sh-- root-run wipe script (~85 lines, audit-logged, idempotent)scripts-server/sudoers-usurper-nuke-- sudoers drop-in grantingusurperuser NOPASSWD access to ONLY the nuke script
Files modified:
web/ssh-proxy.js-- newPOST /api/admin/nukeendpoint with admin-token auth, 5-min-per-IP rate limit,confirmPhrasevalidation, 90-second timeout, structured stdout/stderr capture, in-process state reset (peak online stats) on successweb/admin.html-- new Danger Zone section + 4-step nuke confirmation modal (overview → typed phrase → countdown → result)
Deploy notes
- Game binary + new SQLite table. The
deleted_characterstable is created viaCREATE TABLE IF NOT EXISTSon first server startup post-deploy. No manual migration required. - Online server: publish linux-x64, tar, scp, restart
usurper-mudandsshd-usurper. Standard recipe. - Beta wipe: the database wipe at beta launch is unrelated to this release; statues survive because they're hardcoded in the binary, not stored in the database.
- Non-MUD clients (web, SSH, BBS, Electron, single-player) are entirely unaffected by GMCP -- only path they exercise is the constant-time
boolcheck inGmcpBridge.IsActiveandGmcpBridge.Emit's early return.
For testing post-deploy:
- Mudlet: connect to
play.usurper-reborn.net:4000, open Settings → Mapper → check thatChar.Vitalsshows up in the GMCP debug output. Build a status pane wired togmcp.Char.Vitals.hp/.maxHp. - TinTin++:
#config GMCP ON,#config DEBUG TELNET 5to see incoming GMCP frames, then#splitto enable a status pane fed by#gmcpevent handlers. - MUSHclient: enable Plugins → GMCP, drop in any Achaea-compatible vitals plugin.
- Founder statues: walk into Castle, Temple, or type
/foundersfrom any location. - Delete safeguard + restore: delete a test character at Main Street, observe the new warning text, then run
/restoreto bring it back. - French translation: switch in-game language to French via
/settings lang fr(Electron) or in-game preferences (terminal); verify accents render correctly in your SSH client.