Eastern Story: Dragon Slaying War

✅ 可玩

东方故事 — esI("屠龙之战")

esI

🔑 fluffos / Mud@2026 更新 ba58e77 2026-09-01 源码 下载 ZIP

▶ 开始游玩 · Play Now

东方故事,站内活动主题为"屠龙之战"(Dragon Slaying War)。与本批次的 `es1_win`(东方故事·蓝天版)同属 ES / TMI 引擎家族,两者核心代码几乎一致,仅站点内容与分支细节不同。本档案独有一段完整的终局剧情:`eastland/dragon` 是一座三十余间房的洞穴迷宫,深处镇守着 19 级、2500 点血量的最终 boss"孽龙恨天"——传说中因过去屠戮生灵而被玉帝封印于此的恶龙,身披并会掉落一枚龙形护身符。

English

Another build of the ES/TMI "Eastern Story" engine, sharing its Western-fantasy world with sibling archive es1_win (human, elf, dwarf, half-orc, and goblin races; knight, mage, priest, monk, scholar, and thief guilds; the farwind trading house's list/buy shopping) -- but built around its own in-game storyline, the "Dragon Slaying War" (屠龙之战). The payoff is a real endgame encounter unique to this build: a 30-plus-room cave dungeon (eastland/dragon) guarding 孽龙恨天 ("Evil Dragon, Hatred of Heaven"), a level-19, 2,500-HP boss lore-bound as a beast the Jade Emperor sealed away for past slaughter, who wears and drops a dragon amulet. This particular snapshot also ships with a test-era configuration that grants new characters automatic wizard privileges -- a holdover from internal testing/load trials rather than intended public-play behavior.

README

内容亮点

在线试玩

https://mudlibs.fluffos.info/esI/

管理员账号 / Admin account

警告:公开架站前请务必修改此默认密码。

本地运行

cd libs/esI
~/src/fluffos/build-debug/src/driver config.fluffos

游戏端口:40010

NOTES · 移植与修复记录

esI.rar → esI

Status: DONE — boots clean, playable over telnet

Full flow confirmed: banner, staff/admin credits, uptime status, name-length validation, name-not-taken confirmation prompt.

What was fixed

Applied both fixes discovered on lib #12 (same lineage) proactively, before the first boot attempt, since a quick grep confirmed both patterns present in the raw archive: 1. std/object/prop.lpc's #include </std/object/prop_logic.c> (angle brackets, local directory, reached via preload) — convert_lib.sh's automated .c>.lpc> fix only fixes the extension, not the bracket-vs-quote distinction, so this still needed the manual switch to quotes (#include "/std/object/prop_logic.lpc") per AGENTS.md §8d's preload-timing addendum. Worth teaching convert_lib.sh to convert local angle-bracket #includes to quotes automatically the next time this pattern recurs in enough libs to justify it (seen twice now). 2. master.lpc's domain_file() — same explode("/", "/") returning a zero-length array, !path guard not catching it — added || !sizeof(path).

Both fixes applied before ever attempting a boot; booted clean on the first real attempt. Plus the routine per-lib steps: encoding (8471 converted, 1741 already UTF-8/ASCII, 107 skipped binary, 14 lossy), .c.lpc (7035 files, 1009 refs + 1 angle-bracket ref auto-fixed), staticnosave (121 files).

Known remaining issues (documented, not fixed)

How to run

cd libs/esI
~/src/fluffos/build-debug/src/driver config.fluffos
python3 ../../scripts/mudclient.py 127.0.0.1 40010 --timeout 10 --send "" --send "look" --send "quit"

Re-verification pass: driver rebuild + LPC formatter + WASM build

WASM-enablement pass (loopback / admin seeding)

Identical to sibling es1_win (ES II family, 008).

深度功能测试 / Deep functional test (2026-07-24)

Round-two pass (AGENTS.md §10.7) on the native driver, one continuous session per checklist item, with several deliberate net-dead disconnect/reconnect probes (both prompt and after a real ~80s wall-clock wait) plus a fresh-boot verification of every fix. Test characters (both kept, not cleaned up, as evidence):

Ported from sibling es1_win (§11 lineage, byte-identical files confirmed via diff before porting)

Both fixes below were previously found+fixed on es1_win's own §10.7 pass and explicitly flagged there as "sibling esI has byte-identical unfixed copies of both files". Confirmed via diff against es1_win's pre-fix commit (92be845f197^) that esI's copies were indeed byte-identical (module trivial pragma/comment differences), ported the same fix, and live-verified both work on esI specifically.

1. Net-dead disconnect during registration permanently strands the account (§7.21 variant) — std/user.lpc restart_heart(). Same root cause as es1_win: net_dead() only records linkdead_room if (env), so a player who disconnects between finishing the registration Q&A and pressing RETURN at the MOTD prompt (i.e. before complete_setup() ever places them in a room) has no linkdead_room to restore and gets stuck in LINKDEAD_ROOM forever on every future login. Fix ported verbatim: in restart_heart(), when linkroom is unset but the player is currently sitting in LINKDEAD_ROOM, call this_object()->complete_setup("") to finish the interrupted setup. Live-verified on esI: registered Muyunhe through to the "[请按 RETURN 键继续]" prompt, let the connection close without answering it, reconnected with the correct password — landed correctly in /d/adventurer/hall/adv_guild (the real start room), not /d/std/rooms/netdead. look/score both correct, zero debug.log signal either before or after the fix (matches §7.20's "zero log signal" warning).

2. #include <compress_obj.h>'s set_default_ob(__FILE__) — §7.14 class. __FILE__ inside the pasted-in fragment always resolves to /include/compress_obj.h itself, not whichever of the ~40 objects it was pasted into, so every affected clone's fallback property lookup throws *call_other() couldn't find object '/include/compress_obj.h' the first time it's needed (e.g. a shop's list command reading an item's price). Fix ported verbatim: base_name(this_object()) instead of __FILE__. Live-verified on esI: pre-fix, walking into /d/noden/farwind/shop and typing list (or the room's own first-ever reset(), see bug #4 below) would have hit this on the very first item lookup; post-fix, list renders the full item list with prices cleanly (油灯/绷带/魔法地图/火把), and a real purchase (buy torch) completed without error (see "What was tested" below).

New bugs found on esI (not present/not reproduced on es1_win)

3. Leftover developer debug checkpoints printed to every player on every real login — adm/daemons/logind.lpc (~line 477-490). Five tell_object(player, "ttt\n") / "ttt1\n" / ... "ttt4\n" calls, interleaved between the real ENTER_D/TOPPLAYER_D login-chain calls, unconditionally print literal ttt/ttt1/ttt2/ttt3/ttt4 lines to every player's screen on every ordinary login (confirmed reproducible — not the net-dead-reconnect path, which goes through restart_heart() instead and doesn't show them). Confirmed present in the raw archive (/bin/grep -a ttt raw/esI/adm/daemons/logind.c — same 5 lines, byte-identical), i.e. original author debug instrumentation that was never cleaned up before this snapshot shipped, not a conversion artifact. Same class as this lib's own already-documented "aadsaaaaaaaaaaaaaaaaaaaa" stray-debug-string bug (see "Native retest" section above) — zero player-facing content value, unambiguously leftover instrumentation, not a game-content/design question. Not present on es1_win (grep ttt es1_win/.../logind.lpc — zero hits; this snapshot's own addition, so no sibling port needed in that direction). Fix: deleted the 5 tell_object() lines, kept the 5 original comments and the real calls they were checkpointing. Verified: re-logged in as both Jiangfeng and Muyunhe post-fix — no more ttt* lines anywhere in the transcript, login chain output otherwise unchanged.

4. Uncatchable eval-cost abort on a room's cold first compile — another instance of §7.22's general shape, config-level fix — the farwind shop's reset(). /d/noden/farwind/shop.lpc's create() calls reset() synchronously (this shop's own explicit choice, matching a lib-wide convention — 1553 files across the tree call reset() directly from create(), not just relying on the inherited std/room/room.lpc's async call_out("reset", 0)). On this snapshot's shop, that synchronous reset() populates FOUR objects on the room's very first compile, one of which — "woodman": "/d/zeq/woodman" — is a heavily-decorated level-19 guard NPC (85350 HP) that equips 10 armor pieces plus a weapon in its own create(), each an independent first-time compile+clone. On a fresh boot, the FIRST player to walk east into the shop hit a real, on-screen, cascading crash:

执行时段错误:*Too long evaluation. Execution aborted.
程式:/adm/obj/master.lpc 第 349 行
...
呼叫来自:/d/noden/farwind/shop.lpc 的 create() 第 35 行,物件: /d/noden/farwind/shop
呼叫来自:/std/room/room.lpc 的 reset() 第 47 行 ...

cascading into log_error()/domain_file() themselves (each hitting the SAME already-exhausted eval budget as soon as execution resumed), ending in *Can't catch eval cost too big error. The player's move() into the shop simply failed (they stayed in the previous room — not left environment-null, unlike §7.22's original START_ROOM instance), but the crash is loud and repeats identically every time the shop's FIRST-ever visit this boot is attempted. Confirmed first-visit-only: after the aborted attempt, east a second time succeeded cleanly (shop and all 4 NPCs already resident in memory from the partial first attempt). Root cause confirmed by direct test, not guesswork: rebooting with maximum evaluation cost raised from the shipped 300000 to 5000000 and repeating the exact same fresh-boot east into the shop now succeeds cleanly with zero errors — i.e. this is genuinely "legitimately a lot of synchronous first-load work", not an infinite loop. Worth noting the archive's OWN config comment (config.fluffos:72-73, "Because of the heavy load on the Power PC, made it larger to pass the bad time. (By Annihilator)") shows the original author already had to raise this exact value once, on their own era's hardware, for what is plausibly this same class of problem. 5000000 is also already the single most common maximum evaluation cost value across this project's other converted libs (30 of them, per grep -rh "^maximum evaluation cost" libs/*/config.fluffos), so this is a precedented, low-risk config fix, not a one-off value pulled from nowhere. Fix: libs/esI/config.fluffos, maximum evaluation cost : 3000005000000. Live-verified: fresh reboot, fresh character, east into the shop on the very first attempt this boot succeeds cleanly (全部 4 只 NPC 正确显示), zero debug.log errors.

5. §7.12 tell_room() 2-arg wrapper bug — adm/simul_efun/tell_room.lpc, hit from a self-rescheduling call_out with no enclosing catch() (the severity-escalation shape, not just cosmetic). varargs void tell_room(mixed room, mixed msg, mixed exclude) passes exclude straight through to the real message() efun; called with only 2 args (the overwhelmingly common case throughout this lib), exclude defaults to int 0, which message() rejects (Bad argument 4 to EFUN message() Expected: object, array, Got: int(0)). Reproduced live: adm/daemons/weather_d.lpc's change_phase() — a self-rescheduling call_out ("it call_outs to itself recursively so there's always one version in the call_out queue", per the file's own comment) — calls tell_room(notifications[i], msg) with only 2 args inside a loop, with no catch() anywhere in the function. The uncaught throw aborts change_phase() at that exact statement, meaning the function's OWN tail — call_out("change_phase", day_phases[current_phase]["length"]) — never runs, so the entire day/night phase cycle daemon permanently stops forever for the rest of that boot, with the single runtime-error line in debug.log as the only trace (no crash, no other visible symptom; weather/ambient broadcasts and phase-change text simply never happen again). Trivially reachable: WEATHER_D->request_weather_notices() is called by every outdoor room's set_outside() (std/room/room.lpc), so registering even ONE outdoor room (any ordinary walk outside) arms the crash for whenever the phase timer next fires — some phases in adm/etc/daytime.data are as short as 1 second, so this fires quickly in practice, well within a normal play session. Confirmed via diff: es1_win's copy of tell_room.lpc is byte-identical and carries the same unfixed bug (not caught by that lib's own §10.7 pass — plausibly a timing/room-visited difference between sessions). Fix (matching AGENTS.md §7.12's documented remedy exactly):

// BEFORE
message("tell_room", msg, room, exclude);
// AFTER
message("tell_room", msg, room, exclude || ({}));

Live-verified on esI two ways: (a) played normally afterward with an outdoor room registered for ~15 real seconds, zero new debug.log errors; (b) direct proof via the fluffos admin account's eval command — eval tell_room(environment(this_player()), "TESTBROADCAST_2ARG\n"); return 1; (the exact 2-arg shape that crashed weather_d.lpc) broadcast cleanly with Result = 1 and zero debug.log error, both pre-fix (reproduced the crash directly this way too) and post-fix (clean). Ported to es1_win (libs/es1_win/work/adm/simul_efun/tell_room.lpc, same one-line fix, comment cites this lib's finding) and sanity-checked there with a fresh boot + existing test-character login/look/quit (clean, zero new errors) — not a full independent replay of es1_win's own §10.7 checklist, since that lib was already marked done; flagged here for whoever next touches es1_win to note in ITS OWN NOTES.md.

What was tested and confirmed working

Not verified live (explicitly)

Deep functional test round two (2026-08-14)

Independently re-verified all 5 round-one fixes against current code and live behavior rather than trusting the writeup above. All 5 confirmed still holding; no new bugs found or fixes needed this pass.

Also verified: real admin write access via update /adm/daemons/logind (succeeded, "Updated and loaded.") — used as the actual privileged-action check rather than trusting any login banner. One reconnect exercised (the connection's own quit is gated behind okip for wizards, same as es1_win's sibling architecture — a genuine safety feature, not a bug — so the reconnect went through the silent net-dead body-reattach path, landing back in the shop with identical state, which is itself valid reconnect-stability evidence).

Verification method

Booted native build-debug driver (zero eval-cost/preload errors this boot, confirming fix 4 holds from cold start), admin login (fluffos/Mud@2026). Driver killed by exact PID after testing; incidental fluffos.o/d/noden/log/d/zeq/log save churn reverted before commit (no code changes this pass — nothing else to stage).

§7.100 sub-threshold instance (2026-08-20)

Found during the §7.100 tail-sweep (the original 166-lib corpus sweep only surveyed libs with >=100 live occurrences; this lib was below that threshold and never checked). 1 live replace_program(ROOM); occurrence in d/noden/6,8.noden.lpc's create(), immediately after inherit ROOM; — same fatal shape as the rest of the §7.100 family (sets a permanent "pending replace" flag that crashes the object the first time anything binds a closure to it). No roommaker.lpc-style room-building tool exists in this lib, so no factory-template copy of the bug. Fixed by deleting the redundant line. Verified via a clean native driver boot (zero new debug.log errors, port listening, killed by exact PID after ~8s).

Deep functional test round four (2026-08-20)

Targeted re-test of the two things every prior round explicitly left unverified: a real death→respawn cycle, and a real join into a non-starting guild. Per the project's current scope rule, only pursued actual programming bugs (crash / debug.log error / stuck state) — not content or balance.

Method note: both tests needed a wizard-controlled second connection alongside the test character, run as a two-socket Python script (not tmux_mud.sh/single-connection mudclient.py) so the admin account could eval state changes on a character that stayed connected and interactive throughout. Two real methodology traps hit and resolved along the way, worth flagging for whoever does this next on another lib: 1. The eval command's underlying _set() (std/object/prop_logic.lpc) has an intentional security guard that silently no-ops any set() call from a /open/-rooted script onto a non-wizard living object's properties (blocks /open sandboxes from writing player state directly) — so ob->set("hit_points", 0) from eval does nothing, with no error, no exception, query() afterward just returns the unchanged old value. This is by design, not a bug. The correct way to deal admin-driven damage is ob->receive_damage(N), which is a real gameplay API that mutates the property mapping directly from the target's own context, bypassing that guard exactly as real combat damage does — and it independently no-ops (also by design) if the target is currently linkdead (receive_damage()'s own guard), so the test character's socket must be kept open/connected throughout, not closed between steps. 2. A genuinely fresh (non-net-dead) login stops at the "[请按 RETURN 键继续]" MOTD gate (setup()'s input_to("complete_setup", 2)) before complete_setup() actually places the character in a room — a *reconnect* (net-dead resume) skips this entirely via a different code path (restart_heart()/"重新连线完毕"), which is why earlier rounds' sessions never hit this. A command sent into that still-armed input_to() slot (e.g. look) gets silently consumed as the RETURN-prompt's answer instead of dispatching as a real command, and complete_setup() then places the character at their *default* start room, silently overriding anything done to them (e.g. an admin move_player() teleport) in between. This produced two confusing false leads before being root-caused: an apparent "teleport didn't stick" (character 's join replied "you're already a member" because they'd been silently routed back to the adventurer guild, their real default start room) and an apparent unexplained second death (almost certainly the same mechanism misfiring on a different test character, though not re-isolated as precisely — a clean, admin-untouched 60-second silent-watch re-run with a fresh character never reproduced any second death, so this is recorded as a methodology artifact, not a mudlib bug). Fix for future sessions: after a fresh (not reconnect) login, send one more blank line and wait for the actual room description — not just the OKIP-check line — before sending any other command or doing any admin-side manipulation of that character.

Real bug found and fixed: die() crashes forever (never completes) when the character has no living last attacker

std/user.lpc's die() sets killer = query("last_attacker"); if (!killer) killer = previous_object(); and then, in the un-fixed code, used killer in two places without checking it might still be 0:

// BEFORE
if (!wizardp(this_object()) && killer->query("npc"))
  ...
if (this_object()->query_level() < 5 && userp(killer) && query("last_attacker")) {

last_attacker is only set by std/body/attack.lpc's receive_damage() when the damage's previous_object() at the time of the call is itself a living() object — i.e. real melee/spell combat from another living creature. Any OTHER damage source (poison, bleeding, sickness, drowning/suffocation, desert exposure — all real, reachable status-effect conditions in std/conditions/, each calling receive_damage() from a non-living condition-daemon caller — or an admin-applied receive_damage() call) never sets last_attacker, and previous_object() *at the point die() itself runs* (called from heart_beat()continue_attack(), with no calling object in that chain) is also 0. killer then stays int 0, and killer->query(...) /userp(killer) both throw a driver-level call_other/efun-argument error on this exact line, every single time, since continue_attack() re-invokes die() on every subsequent heart_beat() tick as long as hit_points < 1 — the crash never lets execution reach the link_data("dead") early-return guard further down (line 1009, *after* the crash site), so the character never actually dies: no corpse, no ghost, just an infinite repeating on-screen runtime-error spam and a permanently stuck negative-HP body. Reproduced live twice (once via the crash itself, once again confirming the SAME crash recurred one line further down after the first fix, until both call sites were guarded) using receive_damage(9999) via the fluffos admin account against a real, fully-connected test character (蜘蛛精/ testdieb's predecessor characters 夜寒霜/testmage, 白骨精/ testdie — see method notes above for why an artificial admin damage source was needed to reach this branch, since ordinary NPC combat sets last_attacker correctly and never hits it). Confirmed byte-identical unguarded code in sibling es1_win's std/user.lpc (same lineage, not yet fixed there — flagged for whoever next touches that lib).

Fix: added killer && short-circuit guards to both call sites, matching every other killer-using line in the same function (lines 1012-1014, 1084, 1089-1095, 1109-1117 were already correctly guarded with if (killer) — only these two were missed):

// AFTER
if (!wizardp(this_object()) && killer && killer->query("npc"))
  ...
if (this_object()->query_level() < 5 && killer && userp(killer) && query("last_attacker")) {

Live-verified end to end, twice, on a fresh driver boot with a fresh test character each time (testdie, then testdiebtestdie's run additionally included the admin eval diagnostic that reproduced the raw crash text on-screen pre-fix and confirmed its absence post-fix): receive_damage(9999)你死了。 → ghost creation → the full black_wuchang/黑无常 narrative (death1 at +10s, death2 at +30s more) → revive() restoring the character to a real living body in /d/noden/farwind/cemetery (露天英雄纪念馆) with reduced stats (10/30 HP, matching the documented "skills reduced on death" mechanic) — zero crashes, zero new lines in work/log/debug.log or work/log/log throughout. testdieb (id testdieb, password TestPassD2, human female adventurer) is left parked alive in the cemetery room as evidence; testdie and the earlier testmage mage-guild character were cleaned up (redundant once testdieb/ testguild cleanly demonstrated the same things without the methodology-artifact confusion).

Real guild join into a non-starting guild — verified twice (mage, healer)

Code review in every prior round noted std/guild.lpc's do_join() / each guild's own join_player() looked uniform across all 7 guilds but was never live-exercised for anything but the starting adventurer guild's already-a-member branch. This round completed real joins into two different non-starting guilds, both via a wizard move_player() teleport (d/mage/tower/mage_guild, d/healer/building/healer_guild) followed by the character's own real join command — matching the project's established "sect-joining needs wizard goto, not on-foot exploration" precedent (guild rooms aren't exit-reachable from the starting hall within a reasonable test budget):

Both joins used the SAME std/guild.lpc→per-guild-file→std/guild_ob.lpc mechanism the prior rounds' code review predicted (the guild token object, moved into the player, sets owner->set("class", class_name) via guild_ob.lpc's move hook) — this round's live tests confirm that prediction was correct, generalizing cleanly to at least 2 of the 5 previously-unverified guilds (mage, healer). Knight/monk/thief/scholar remain code-reviewed-only, not live-verified, though there's no reason to expect them to differ given the shared mechanism.

Standing bug checklist sanity pass

All checked via direct code read against the CURRENT work/ tree (no live re-trigger needed for any of these — either already fixed, or confirmed the vulnerable shape was never present):

WASM 修复摘要(迁移自 meta.json 的 group_note)

同一血统(屠龙之战)。