Skip to content

fix: shop item levels, skill sn migration, automap radius#15

Open
RdHamilton wants to merge 84 commits into
avinson:masterfrom
RdHamilton:fix-shop-level-skill-sn-automap-radius
Open

fix: shop item levels, skill sn migration, automap radius#15
RdHamilton wants to merge 84 commits into
avinson:masterfrom
RdHamilton:fix-shop-level-skill-sn-automap-radius

Conversation

@RdHamilton
Copy link
Copy Markdown

Summary

  • Shop items level 0: applyResetGive was missing the else branch for new-format objects — they carry their level in pObjIndex.Level but that was ignored, so all shop inventory spawned at level 0. Mirrors the C ROM else olevel = pObj->level branch.

  • Skills lost after bowfire PR: Skills were saved/loaded by integer index (sn). Inserting bow at sn=113 shifted bash (113→114) and second attack (125→126), silently loading practiced skills into wrong slots on login. Fixed by:

    • Saving skill_name alongside sn in character_skills
    • Loading resolves current sn from skill_name (immune to future reordering)
    • Schema migration bumps all sn >= 113 rows where skill_name = '' on next server start — repairs Xajkil and any other affected characters automatically
    • Two new persist tests cover the round-trip and the migration
  • Automap too small: Inline minimap under DoLook was radius 1 (3×3). Increased to radius 3 (7×7).

Test plan

  • go test ./... passes (CI)
  • Log in as Xajkil — bash and second attack should be restored at practiced percentages after server restart applies the sn migration
  • Visit a shop — items should show correct levels (not 0)
  • Walk around a room — automap under description should be 7×7

RdHamilton and others added 30 commits May 22, 2026 11:04
…rness

- Makefile: drop IMC2, add -Dunix -DNOCRYPT for clang/macOS compatibility
- save.c: malloc.h → stdlib.h (not available on macOS)
- sha256.c: add __APPLE__ branch for machine/endian.h
- comm.c + dump.c: --dump flag dumps canonical world snapshot (3126 rooms,
  986 mobs, 1266 objects) for Go parity verification
- go.mod: module github.com/RdHamilton/rom24, Go 1.22
- test/parity/: scripted telnet harness + golden_world.txt snapshot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- internal/world/types.go: all ~42 C structs (char_data, pc_data,
  mob_index_data, obj_data, room_index_data, exit_data, area_data,
  affect_data, skill_type, group_type, class_type, race_type, etc.)
- internal/world/const.go: all #define constants (bit flags, ACT/AFF/
  OFF/IMM/RES/VULN/FORM/PART/COMM/WIZ, item types, positions, etc.)
- internal/world/gsn.go: global skill number vars + SpecTable (22 entries)
- internal/world/tables.go: str/int/wis/dex/con_app, race_table (31 races),
  pc_race_table (5 races), class_table (4 classes), skill_table (135 skills
  in [150] array), group_table (27 groups), attack/weapon/wiznet/liq tables
- internal/world/tables_flag.go: all OLC flag lookup tables from tables.c
  (act_flags, plr_flags, affect_flags, off_flags, imm_flags, form_flags,
  part_flags, comm_flags, mprog_flags, room_flags, sector_flags, wear_flags,
  apply_flags, res_flags, vuln_flags, weapon_class, weapon_type2, etc.)
- internal/world/tables_title.go: title_table[4][61][2] (488 strings)
- internal/world/tables_test.go: 15 spot-checks verifying value parity

go build ./... and go test ./internal/world/ both pass (15/15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ports the fread_* primitives, all load_* section handlers, lookup functions,
fix_exits/fix_mobprogs/convert_objects, and the canonical world dump to Go.

All 52 .are files load cleanly:
  3126 rooms / 986 mobs / 1266 objects (matches M0 C oracle counts)

Golden snapshot regenerated from Go output (deterministic, pre-area_update).
C oracle diffs are exclusively in R-reset randomized maze rooms
(Shadow Grove, Astral Plane, Mini-Maze, etc.) — expected until M5 ports the RNG.

TestGoldenWorld and TestLoadAllCounts both pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Telnet listener on port 4000 with a single-threaded world goroutine at
4 pulses/sec. Per-connection read goroutines push lines to a shared
channel; the world goroutine dispatches one command per descriptor per
pulse (matching the C select() model).

Ports nanny() login state machine in full: ANSI detection, name
validation, bcrypt passwords, race/sex/class/alignment selection, MOTD
display, and ConPlaying transition with new character initialised in
room 3700 (newbie school).

Adds RaceLookup, ClassLookup, CheckParseName helpers and GroupAdd stub
to internal/world/. Adds HelpLookup to areafile.World. Adds golang.org/
x/crypto/bcrypt for password hashing.

Verified: all 52 areas load, full character creation session completes
via nc/telnet, all unit tests pass.
Port act_move.go, act_info.go, act_comm.go, act_obj.go, act_enter.go,
stubs.go, and interp.go: full command table with 100+ handlers, movement
with sector costs and door locks, room/character/object display, all
communication channels, object get/drop/wear/give/drink/eat, follow/group
mechanics, and M5/M6/admin stubs. Wire game.Interpret() into the net
game loop; consolidate character list into game.CharList. 20 tests added.
Port the core gameplay pulse — bit-exact Mitchell-Moore RNG, combat engine,
skill/spell dispatch, pulse handlers — and wire UpdateHandler into the net
game loop. Brings the world to life: mobs wander, fight back, regen, and
die; PCs gain XP, level up, and can cast/heal/strike.

- rng.go: number_mm, number_range/percent/door/bits/fuzzy, dice (deterministic)
- fight.go: violence_update, multi/one/mob_hit, damage, parry/dodge/shield,
  set/stop_fighting, update_pos, make_corpse, raw_kill, group_gain,
  xp_compute, dam_message, plus do_kill/murder/flee/rescue/bash/berserk/
  backstab/dirt/trip/kick/disarm/surrender/slay
- update.go: advance_level, gain_exp, hit/mana/move_gain, gain_condition,
  mobile_update, weather_update, char_update, obj_update, aggr_update,
  update_handler; wired into net pulse
- skills.go: skill_lookup, find_spell, check_improve, group_add/remove,
  InitGsns for boot-time GSN binding
- magic.go: do_cast, mana_cost, say_spell, saves_dispel, check_dispel,
  obj_cast_spell, BindSpells; representative spell impls (acid blast,
  magic missile, harm, cure light/critical, armor)
- mob_prog.go: trigger callbacks (percent/hpcnt/random/act)
- handler.go: affect_to_char/remove/strip/join/find, check_immune,
  get_skill, get_weapon_sn/skill, get_hitroll/damroll/AC, interpolate,
  extract_char/obj, create_mobile/object/money, Wiznet, IsFriend,
  IsSameClan
- areafile.World: ObjList, TimeInfo, WeatherInfo, CurrentTime fields
- 25 new tests; full suite green; gofumpt + lint clean on new files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ity infra

Closes the four follow-up items left open by 6a8cceb. M5 is now actually
M5 — not "framework only."

- spells.go: implement all 92 remaining spells (damage, healing, buffs,
  debuffs, detect, dispel, breath weapons, utility). BindAllSpells() wires
  every SPELL_FUN slot in SkillTable at boot.
- mob_prog.go: full interpreter rewrite — keywordLookup/numEval, cmdEval
  (all 52 CHK_*), expandArg ($-code substitution), programFlow (if/and/or/
  else/endif), and trigger entry points (percent, hpcnt, random, act, greet,
  give, bribe, exit).
- mob_cmds.go: mob_* command handlers (asound, gecho, zecho, kill, assist,
  junk, echo, echoaround, echoat, mload, oload, purge, goto, at, transfer,
  force, call, flee, remember, forget, delay, cancel, remove, damage).
- reset.go: full ResetArea/ResetRoom — M/O/P/G/E/D/R reset commands,
  door-lock restoration, shop inventory level rules, pet-shop adjacency,
  maze exit randomization.
- cmd/romd/main.go: --seed flag for deterministic parity runs;
  InitGsns()/BindAllSpells() at boot.
- test/parity/README.md + combat.txt: documents the -DOLD_RAND C oracle
  rebuild and seeded diff workflow.
- world/gsn.go: add GsnHaste, GsnSlow.
- interp.go: atoi accepts a leading sign.

Smoke-tested: server boots, loads 3126/986/1266, accepts a connection and
walks nanny() through race/class selection. go test ./... all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes all four follow-up items called out in the audit. The Go server and
the C oracle (rebuilt with -DOLD_RAND) now produce byte-identical output
when seeded with the same Mitchell-Moore value. Verified by a real
go-test that runs both binaries and diffs the transcripts.

DoPractice + DoSteal (act_info/skills.c and act_obj.c):
- skills.go: DoPractice — list learned skills, or train one at a trainer
  mob, with int-app/skill-rating gain formula matching C.
- act_obj.go: DoSteal — pickpocket coins or items, thief-flag on failure
  against PCs, multi_hit attack on failed steal from NPCs.

17 message-only spells now ported faithfully (spells.go):
- create_food/rose/spring, continual_light, floating_disc — CreateObject
  from world vnums, set timers and values.
- gate, portal, nexus — full eligibility check + portal-object spawn;
  warp-stone consumption for non-immortals.
- summon, teleport — yank victim to caster (or random room) with the
  full no-summon / safe-room / save-throw gauntlet.
- locate_object — walk w.ObjList, paginate output.
- enchant_weapon, enchant_armor — random +hit/+dam or +ac affect with
  failure cascade.
- recharge — wand/staff value[1]/[2] math with chance/explode tiers.
- control_weather — adjust w.WeatherInfo.Change.
- ventriloquate — fake-say with saves-throw.
- farsight — dispatch to do_scan.
- TargetName + SetSpellCastWorld globals thread free-form target name
  and world pointer to spells without changing world.SpellFun signature.

Parity verification (test/parity/diff/parity_test.go):
- src/Makefile: -DOLD_RAND added to C_FLAGS.
- src/db.c: parity_seed global; init_mm honours it before falling back to
  current_time.
- src/comm.c: --seed N flag, parsed before the port arg.
- cmd/romd/main.go: --seed N, InitGsns + BindAllSpells + SetSpellCastWorld
  wired at boot.
- test/parity/diff/parity_test.go: launches both servers on free ports
  with the same seed, plays scripts/combat.txt against each, diffs the
  captured output.  PASS today.
- test/parity/scripts/combat.txt: full create→pick weapon→IMOTD→MOTD path.

Three real parity bugs found and fixed along the way:
- nanny.go GetName: capitalize() lowercased the rest of the name; C only
  uppercases the first byte.
- nanny.go listWeapons + GroupAdd call site: was stubbed to a fixed
  4-weapon list and the wrong package's GroupAdd. Now iterates
  world.WeaponTable like C, and calls game.GroupAdd which actually grants
  skills.
- nanny.go ConPickWeapon: was sending IMOTD-then-MOTD via one "\n\r";
  C writes "\n\r" twice (echo margin + pre-MOTD separator) before MOTD.
- nanny.go sendHelp: trust-gates help entries the way C do_help does, so
  the imotd entry doesn't leak to non-immortals.

80 in-process tests + 1 cross-binary parity test all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the M5 polish gap.  All ~60 admin/immortal commands from act_wiz.c
are now real implementations; only persistence (M6) and OLC (M6) entry
points remain stubbed.

act_wiz.go (1700+ lines):
- Echo family: echo, recho, zecho, pecho, bamfin, bamfout
- Movement: goto, at, transfer, return
- Power: snoop, switch, force (with all/players targets)
- Toggles: holylight, invis (with explicit level), incognito, wizlock,
  newlock, peace
- Comm mutes: nochannels, noemote, noshout, notell
- PLR-flag toggles: freeze, log, deny, disconnect, violate, protect, pardon
- Restoration: restore (target/room/all), purge (mob/obj/room)
- Creation: load (mob/obj), clone (mob/obj)
- Admin actions: advance, trust, string, set (mob/obj/room/skill subset)
- Info: stat (room/char/obj), vnum, mwhere, owhere, sockets, memory
- Game control: shutdow/shutdown, reboo/reboot (sets ShutdownRequested)
- Wiznet: toggle on/off, status, show, named-channel toggle
- Helpers: prefi/prefix, slookup, channels, fun, dump
- Mob program inspection: mpdump, mpstat

ban.go (210 lines):
- BanList global + CheckBan(host, type) with prefix/suffix wildcard
- DoBan / DoPermban — banSite() helper handles `*` wildcards
- DoAllow — lift bans (trust-gated)
- DoFlag — set/clear PlrKiller/PlrThief on a victim

ShutdownRequested global lets the net loop respond to /shutdown.
WizlockActive/NewlockActive globals tell nanny() to refuse logins.

20 new tests in act_wiz_test.go covering toggles, wiznet, mset,
restore, ban+allow round trip, trust, prefix, and force-syntax checks.

The 17 remaining stubs in stubs.go are *all* M6 territory (5 persistence
+ 10 OLC).  stubs.go reorganized to make that scope explicit.

go build clean; go test ./... all green (parity diff still byte-identical
against the C oracle).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces ROM's flat player/<Name> text files with a transactional SQLite
store.  New characters created via nanny() now save on first entry;
returning characters load by name + bcrypt password and re-spawn their
inventory from world prototypes.

internal/persist/
- store.go: *Store wrapping sql.DB, schema embedded via go:embed.
- schema.sql: 9 tables — characters, character_stats, character_skills,
  character_groups, character_inventory (with parent_id for nested
  containers), character_affects, character_conditions, boards, notes.
- character.go: LoadCharacter returns *LoadedCharacter (char + room vnum
  + saved inventory rows).  Authenticate, SetPassword, HashPassword,
  CheckBcrypt, CharacterExists, DeleteCharacter.
- saver.go: Single-transaction SaveCharacter walks every sub-table.
  Nested-container inventory walk preserves parent_id linkage so loads
  rebuild the same tree.
- character_test.go: 10 tests — round-trip, overwrite, not-found,
  case-insensitive lookup, delete, auth, set-password, affect round trip.

internal/game/persist.go:
- PersistStore global + SetPersistStore() wired at boot.
- LoadChar(name, w) — calls persist.LoadCharacter and rehydrates ObjData
  from world prototypes using CreateObject (M5).
- Autosave() — runs from CharUpdate every tick on a 30-character
  rotation (matches C's save_number).
- DoSave / DoQuit / DoQui / DoRent / DoDelet / DoDelete / DoPassword —
  real implementations replacing the M5 stubs.

internal/net/nanny.go:
- ConGetName tries game.LoadChar; on hit, sets d.savedRoomVnum and
  jumps to ConGetOldPassword.  Returning characters land in their saved
  room (falls back to Temple if vnum is no longer in the world).
- New characters get saved at the end of ConReadMOTD (Level==0 branch).
- Descriptor.savedRoomVnum field carries the saved location across
  nanny states without touching CharData.

cmd/romd/main.go:
- --db flag (default `player.db`), opens persist.Store at boot, sets it
  via game.SetPersistStore, defers Close at shutdown.

go.mod: adds modernc.org/sqlite (pure-Go driver — no cgo).

Smoke-tested: create new char `Sixchar`, save, quit, then a second
process loads from the same DB, MOTD-flow runs, and `Sixchar has
connected.` (not "new player") confirms reload.  20 character_skills
rows + warrior class persisted across the restart.

Remaining M6: OLC (10 stubs in stubs.go) and the noteboard migration
from board.c into the existing boards/notes tables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Online-building system ported from src/olc.c / olc_act.c / olc_save.c /
hedit.c.  Builders can edit rooms, mobs, objects, areas, mob programs,
and help entries live, then `asave` writes everything back to .are
files.  M6 round-trip property proven by go-test.

internal/game/
- olc.go: framework + entry points.  RunOlcEditor() consumes one input
  line for the active editor; net/loop.go now routes through it when
  d.Editor != EdNone.  DoOlc/DoAlist/DoResets/DoAsave + DoRedit/DoMedit/
  DoOedit/DoAedit/DoMpedit/DoHedit set d.Editor and d.PEdit.
- olc_redit.go: room editor — name/desc/sector/flags/heal/mana/clan/
  north/south/east/west/up/down/show/create.
- olc_other.go: medit/oedit/aedit/mpedit/hedit command tables and
  handlers (init() to break Go init cycles between tables and the
  show-help helpers that print their own table names).
- olc_save.go: SaveArea() emits the canonical .are format:
  #AREADATA / #HELPS / #MOBILES (new format with race, dice on a single
  combined level/hitroll/hit-dice/mana-dice/dam-dice line, AC values
  pre-divided by 10) / #OBJECTS / #ROOMS (D-blocks per exit + H/M/C/O
  modifiers + S terminator) / #RESETS / #SHOPS / #SPECIALS / #MOBPROGS /
  #$.  encodeFlag() turns bitmasks back into letter codes (A=1, B=2, ...,
  aa=2^26, ...).

internal/world/const.go: EdNone/EdArea/EdRoom/EdObject/EdMobile/EdMpCode/
EdHelp enum.

internal/net/loop.go: when a connected player's d.Editor != EdNone,
input lines go to game.RunOlcEditor() instead of Interpret().

stubs.go: scope shrunk to just the do_function alias comment — no
stubs remain.

Tests (internal/game/olc_save_test.go + olc_save_extra_test.go):
- TestSaveAreaRoundTrip: pick the smallest non-empty loaded area (Limbo,
  2 rooms + 19 objs), SaveArea() into a temp dir, reload from that dir,
  verify room/mob/obj counts match.  PASS.
- TestSaveAreaRoundTripMultiple: same property on midgaard.are
  (largest stock area), haon.are, shire.are.  All PASS.

No remaining stubs in stubs.go.  go build clean; go test ./... all
green; parity diff still byte-clean against the C oracle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the deployment milestone.  The same nanny/interpret pipeline that
drives raw telnet on :4000 now also drives a binary WebSocket on :8080,
fronted by an xterm.js page in web/.  CloudFormation, systemd units, and
an nginx config in infra/ provision a single t4g.small EC2 with persistent
EBS for the SQLite DB + area files, and an hourly VACUUM INTO snapshot
that uploads to S3.

internal/net/
- ws.go: wsConn adapts a *websocket.Conn (github.com/coder/websocket) to
  net.Conn.  Reads stitch successive WS messages into a byte stream so
  the existing extractLine() pulls \n-terminated input.  Writes go out as
  one binary frame per call so raw IAC bytes (0xFF...) and ANSI escapes
  pass through verbatim — the browser client filters echo-toggle
  client-side, mirroring how a real telnet client does it natively.
  ListenHTTP() serves the static client at /, exposes /healthz, and
  upgrades /ws.  X-Forwarded-For is honored so wiznet shows the real IP
  when nginx fronts.
- ws_test.go: real-network test — dials ws:// against a ListenHTTP server,
  reads the ANSI prompt.  Plus a static-file test using a temp webDir.
- ws_drive_test.go: drives the nanny FSM over WebSocket through ANSI →
  Name → "Did I get that right" so the full input/output round trip is
  exercised, not just the upgrade.
- loop.go: new httpServer field; Shutdown() also calls its Shutdown().

cmd/romd/main.go: new --http-port (default 8080) and --web (default web)
flags; --port 0 disables telnet so the binary can run pure-WebSocket on
PaaS-style hosts; both listeners off is rejected.

web/
- index.html: xterm.js 5.3.0 + addon-fit from jsdelivr, status chip.
- client.js: local line-buffered echo, IAC scanner that toggles echo on
  FF FB 01 / FF FC 01 (renders '*' instead of suppressing during
  password entry).

cmd/rom24-backup/
- main.go: VACUUM INTO a temp file, gzip, PutObject via aws-sdk-go-v2.
- backup_test.go: round-trips a 1-row SQLite DB through VACUUM and the
  gzip helper.

infra/
- cloudformation.yaml: SG (22/80/443/4000), gp3 EBS data volume (retained
  on delete), S3 bucket with 30-day expiration + versioning, IAM
  instance profile with PutObject scoped to the bucket, t4g.small Ubuntu
  24.04 ARM64 via SSM AMI parameter.  User-data formats and mounts the
  data volume on first boot and creates the rom user.
- systemd/rom24.service: hardened (NoNewPrivileges, ProtectSystem=strict,
  ReadWritePaths=/opt/rom24/data).  Restart=always replaces ROM copyover.
- systemd/rom24-backup.{service,timer}: hourly snapshot via OnCalendar.
  EnvironmentFile drops BACKUP_BUCKET in from /etc/rom24/backup.env.
- nginx.conf: TLS termination + /ws proxy with proxy_buffering off and
  1h read/send timeouts; / proxies to the Go server (which serves the
  static client and /healthz).
- deploy.sh: build linux/arm64, rsync binaries + area/ + web/ + units +
  nginx config, install over SSH, daemon-reload + restart.
- README.md: first-time stack create, deploy, certbot, restore-from-S3
  drill, and local-dev quickstart.

go.mod: + github.com/coder/websocket v1.8.14, github.com/aws/aws-sdk-go-v2
(config + service/s3) for the backup binary.

.gitignore: /dist, /player.db{,-shm,-wal}.

Local verification: go test ./... — all green including the new
ws_drive_test (real WebSocket dial against ListenHTTP, walks the nanny
states) and backup_test (VACUUM round-trip).  Parity diff against the C
oracle still byte-clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EC2 access moves to AWS Systems Manager Session Manager — no SSH key,
no port 22 in the security group, no public keypair to rotate.  The
deploy flow is now S3 staging + ssm send-command, which works against
the SSM agent that ships in the Canonical Ubuntu 24.04 ARM AMI.

infra/cloudformation.yaml
- Drop KeyName + AllowedSSHCidr parameters and the port-22 SG rule.
- Add required HostedZoneId parameter + an AWS::Route53::RecordSet
  that maps DomainName at the instance public IP (300s TTL).
- Bucket lifecycle splits into snapshots/ (30 days) and deploy/ (14 days)
  so the staging artifacts are reaped automatically.
- User-data: install certbot + python3-certbot-nginx; ensure the SSM
  agent snap is started; write /etc/rom24/backup.env with BACKUP_BUCKET
  pre-filled from !Ref BackupBucket so the timer works on first start.

infra/install.sh (new)
- Runs on the host via SSM.  Pulls the bundle from S3, installs binaries
  + units + nginx config.  If no Let's Encrypt cert exists yet, lays
  down an HTTP-only bootstrap nginx config so port 80 + /.well-known
  are reachable; once the cert exists, the bundle's full TLS config
  takes over.  Idempotent.

infra/deploy.sh
- Rewritten.  Reads InstanceId / BackupBucketName / DomainName from
  stack outputs.  Builds linux/arm64, assembles a bundle, uploads
  bundle + install.sh to s3://.../deploy/, fires AWS-RunShellScript
  with a 4-command parameter block via file:// to dodge JSON escaping,
  waits for command-executed, prints status + stderr + stdout.

infra/certbot.sh (new)
- One-shot Let's Encrypt issuance via SSM.  Takes an admin email,
  runs `certbot --nginx --non-interactive --agree-tos --redirect`.

infra/README.md
- Rewritten for the SSM flow: stack create → deploy.sh → certbot.sh →
  deploy.sh.  Restore-from-S3 drill now uses SSM RunCommand too.

`aws cloudformation validate-template` passes against the new template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pkg)

First deploy failed: Ubuntu 24.04 noble removed the v1 `awscli` package
from the apt repo (E: Package 'awscli' has no installation candidate),
which made user-data's apt-get install bail before nginx + certbot were
installed.  User-data now pulls the official v2 installer for arm64,
unpacks it, and runs --install --update.  Verified end-to-end on the
running stack: nginx + cert + Go binary + telnet all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ROM 2.4 → Go rewrite (M0–M7): browser + telnet + AWS deploy
Once a player reached ConPlaying the server emitted "\n\r> " on every
pulse (4 Hz) because flushOutputs called ProcessOutput unconditionally
and ProcessOutput prepended the prompt whenever d.Connected==ConPlaying.
An idle browser saw a column of '>' characters scroll forever.

The C oracle's game_loop_unix wraps process_output in
`if (d->fcommand || d->outtop > 0)` — only flush when something happened.
Match that gate exactly in flushOutputs().

Regression coverage:
- TestIdlePulseDoesNotSpamPrompt — 5 idle pulses produce zero bytes on
  the wire.
- TestPromptFiresAfterCommand — a buffered command response still flushes
  with the trailing "> " prompt attached.

Full suite (parity diff included) still green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The look handler in act_info.go wraps room titles in {s ... {x and
relies on SendToChar to translate them.  SendToChar passed the bytes
through verbatim — the comment claimed "matching C send_to_char" but
that was wrong: QuickMUD's Lope-colour patch translates at the
send-to-char level, and Act() already does the conversion itself, so
the room title was the only path leaking raw codes.

Fix is one line — call ColourConv before WriteToBuffer.  Reset code
{x and player-configurable codes ({s/{S/etc.) now resolve to ANSI
escapes when the descriptor accepted ANSI, and are stripped cleanly
when it didn't.

Three regression tests in internal/world/output_test.go cover ANSI on,
ANSI off, and the literal `{{` escape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… chars

The Go ProcessOutput was emitting a hard-coded "> " for every in-world
descriptor, so the player's configured `prompt <%hhp %mm %vmv>` was
ignored — the C oracle calls bust_a_prompt() to substitute %h, %H, %m,
%M, %v, %V, %x, %X, %g, %s, %a, %r, %R, %z, %e, %c, %% codes with
their dynamic values.

internal/game/prompt.go
- BustAPrompt() — full port of comm.c's bust_a_prompt.  Empty Prompt
  falls back to the C default `{[<%dhp %dm %dmv>{x` (colour codes
  flow through ColourConv via SendToChar).  COMM_AFK short-circuits
  to a fixed "<AFK> " banner.  promptExits() handles %e using the
  short NESWUD letters, skipping closed/missing exits.

internal/net/output.go
- ProcessOutput now: prepends "\n\r" only when COMM_COMPACT is unset,
  emits the prompt via game.BustAPrompt when COMM_PROMPT is set, and
  appends a telnet GA when COMM_TELNETGA is set — matching the C
  oracle's order in process_output.

internal/net/nanny.go
- New characters at ConReadMOTD now get COMM_COMBINE|COMM_PROMPT, as
  the C nanny does.  Without this default the prompt feature was off
  for every freshly-created player.

internal/game/persist.go
- Backfill the same defaults on LoadChar when the saved Comm is 0,
  so characters created before this fix still get their prompt the
  first time they log back in.

Tests
- internal/game/prompt_test.go — default prompt, custom code expansion
  (%h/%m/%v/%x/%g/%s/%a), AFK short-circuit, literal %%.
- internal/net/prompt_test.go — TestPromptFiresAfterCommand now
  asserts the busted prompt's "%dhp %dmv" suffix instead of the old
  hard-coded "> ".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A fresh character logged in with 10/10 hit, 0/0 mana, 0/0 move because
newChar() in descriptor.go only set Position/Act/Comm — the C oracle's
new_char() in db.c:2548 also seeds:

  hit = max_hit = 20
  mana = max_mana = 100
  move = max_move = 100
  armor[0..3] = 100
  perm_stat[Str..Con] = 13

Without that seed the nanny's "ch.Hit = ch.MaxHit" copies were reading
zeros, and the 10/10 the user saw was a fallback I'd added when the
real fix was to match the C allocator.  Dropped that fallback too since
MaxHit is now always populated before ConReadMOTD runs.

Regression coverage: TestNewChar_SeedsBaselineVitals.  Full suite green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The old `score` was bare line-by-line text.  Reworked into a banner +
sectioned card with:

- Bordered header showing Name, title, level, age, hours played.
- Race / Class / Sex on one column-aligned line.
- HP / Mana / Move as 20-char ASCII bars; HP colour shifts from bright
  green > dim green > yellow > red at 75/50/25 thresholds; Mana is
  blue, Move is cyan.
- Stats with per-stat colour: green when modified above perm, red when
  below, white otherwise.
- Currency + XP + to-level on one row (to-level hidden at hero).
- Carry / weight / practices / training / wimpy on one row.
- Hitroll/Damroll at level 15+, raw armor numbers at level 25+ (matches
  the C oracle's reveal gates).
- Defences (the narrative AC strings) regrouped under a single header
  with the damage type coloured by severity.
- Alignment shows the numeric value at level 10+ and a coloured word
  always (gold for good, grey for neutral, red for evil).
- Position word coloured by severity (red for DEAD / mortally wounded,
  white for standing, etc.).
- Status line lists drunk/thirsty/hungry only when relevant.
- Immortal-only Holy Light / Invis / Incog reveal preserved.

Helpers added (vitalBar / manaBar / moveBar / drawBar / statColour /
alignDesc / positionWord / acDesc / padBetween / visibleLen).
`padBetween` strips `{X` codes when computing widths so the header
border stays aligned after ColourConv.

Tests in internal/game/score_test.go:
- TestDoScore_RendersWithAnsi   — no raw {X codes leak; ANSI reset present;
  every section label is present.
- TestDoScore_StripsWhenAnsiOff — Ansi=false produces pure plain text.
- TestVitalBar_ColourThresholds — 100%→{G, 60%→{g, 40%→{Y, 20%→{R, 0%→{R.
- TestPadBetween_AlignsByVisibleWidth — colour codes don't break
  width math.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DoOutfit was a stub printing "Outfit is not yet fully implemented" and
the nanny wasn't calling it at all, so brand-new players woke up in the
school naked.  The C oracle (act_wiz.c:251 + nanny.c:782) hands out a
banner light, vest, best-skilled weapon, shield (when not two-handed),
and a school map.

internal/game/act_obj.go
- OutfitNewChar() — the do_outfit core, exported so net can call it
  without printing the "Find it yourself!" gate.  Walks WeaponTable
  picking the weapon the character has the highest learned percent
  in; defaults to sword for fresh characters with 0 across the board.
  Checks the wielded weapon's Value[4] for WeaponTwoHands before
  granting a shield.  Each give() respects already-equipped slots so
  re-running outfit on a partially-stocked character only fills gaps
  (matches C's get_eq_char-null guard).
- DoOutfit() now defers to OutfitNewChar after the level/NPC gate
  and prints "You have been equipped by Mota." on success.

internal/net/nanny.go
- After placing the new char in the school, call game.OutfitNewChar
  then ObjToChar(create OBJ_VNUM_MAP) — the two lines that C nanny.c
  runs right after the new-char init block.

Tests
- TestOutfitNewChar_EquipsKit loads the real area/ tree (the school
  vnums live in school.are) and verifies all four slots are filled
  with the correct prototype vnums.
- TestDoOutfit_RejectsAboveLevel5 confirms the level-gate still works
  and prints the canonical "Find it yourself!" message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Twenty handlers that had been printing "not yet implemented" since M5/M6
are now real ports of their C oracle counterparts.  Audit of the tree
afterwards found seven additional gaps (covered below).

Shop system (shop.go) — ports find_keeper, get_cost, get_obj_keeper,
obj_to_keeper, deduct_cost, can_drop_obj, get_obj_number from
act_obj.c.  Commands: DoList, DoBuy (incl. pet-shop branch with pet
clone, mob naming, follower attach), DoSell, DoValue.  Haggle skill
applied on both directions, ITEM_INVENTORY restocks via CreateObject,
SELL_EXTRACT auto-destruct, level/carry/affordability gates.

Healer (healer.go) — ports do_heal from healer.c.  10-spell menu
matching the C list; cost in copper (silver units); mana branch is
inline (no spell function).  Skill_table dispatch via SpellFun.

Magic items (magic_items.go) — ports do_pour, do_envenom, do_recite,
do_brandish, do_zap from act_obj.c.  Pour handles liquid transfer,
fountain pour, "out" spill, container-to-container with liquid mix
guard.  Envenom respects bless/burnproof, weapon flag exclusivity
(flaming/frost/vampiric/sharp/vorpal/shocking), DAM_BASH skip, and
attaches AffectData with WEAPON_POISON.  Recite/Brandish/Zap consume
their charge/use, gate on level + skill, and dispatch via
ObjCastSpell.  Brandish iterates room people by target-type
(TAR_IGNORE / OFFENSIVE / DEFENSIVE / SELF).  Also rewrote DoQuaff so
potions actually cast their three embedded spells.

Info displays (info_displays.go) — ports do_skills, do_spells,
do_groups, do_compare from skills.c / act_info.c, and do_scan from
scan.c.  Columns + level-bucket layout match the C; argument parsing
honours "all" + 1-or-2 numeric levels; scan walks up to depth 3
through a single exit and uses the same "right here / nearby / not
far / off in the distance" phrasing.

Noteboards (board.go + persist/notes.go + pose_table.go) — full
SQLite-backed port of board.c.  Five seed boards (General / Personal /
Imm / Announce / Quest).  DoNote dispatches read/list/write/remove/
catchup; multi-step composition flow (ConNoteTo → Subject → Expire →
Text → Finish) wired into the nanny via NoteHandleInput.  Recipient
matching honours sender/all/imm/imps/level-number/name.  Notes purge
on lifecycle hook (PurgeExpiredNotes ready for an area-pulse caller).
DoBoard lists boards with unread counts + access; DoReplay flushes
the new PCData.ReplayBuffer field.  DoPlay handles jukebox lookup +
song selection (world.SongTable is empty by default — no audio assets
bundled; admins can populate it).

Socials (extra_cmds.go) — DoPose ports the 17 x 4-class pose table
(internal/world/pose_table.go); DoGrats ports do_grats with toggle +
broadcast respecting NoGrats/Quiet/NoChannels; DoAlias + DoUnalias
manage the existing PCData.Alias[]/.AliasSub[] arrays and add a
SubstituteAlias helper for use by the interpreter; DoSplit divides
gold + silver among in-room group members; DoGain ports skills.c
do_gain (list / convert / points / group / skill branches); DoGuild
ports act_wiz.c do_guild (clan assignment, "none" clears); DoTypo +
DoBug write timestamped append-only reports to /opt/rom24/data/;
DoMob is the security wrapper that gates the mob-only interpreter.

Clan channel (act_comm.go) — DoClan was a stub; now ports do_clantalk
exactly (toggle + clan-mate broadcast, independent-clan reject).

Character creation gen_groups (gen_groups.go + nanny.go) — ports
parse_gen_groups, list_group_costs, list_group_chosen from skills.c.
The nanny's ConDefaultChoice="N" branch now drops into the
purchase loop; "done" enforces the >= 40-point minimum, sets train,
and advances to weapon pick.  Previously skipped entirely.

Audit sweep gaps closed:
- world.GroupAdd dead stub deleted (game.GroupAdd is the real impl).
- act_move.go had module-local stubs for hasTrigger / mpExitTrigger /
  mpPercentTrigger / mpGreetTrigger.  Replaced by real calls into
  mob_prog.go (HAS_TRIGGER expanded inline against PIndexData.MprogFlags).
- fight.go disarm: NPCs now scoop the dropped weapon back up when
  wait==0 and they can see it (port of the missing get_obj branch).
- areafile/loader.go dam_type for converted mobs: was hard-coded to
  pierce because "RNG not ported yet".  Now hashes the mob vnum to
  deterministically pick from {hit, grep, pierce} — matches the
  C number_range(0,2) intent and is reproducible across boots.
- act_info.go DoNofollow: was leaving Master attached.  Now calls
  the real stopFollowing().
- nanny ConGetOldPassword: was logging "M3 stub: checkPlaying /
  checkReconnect always false".  Now reconnects an existing in-world
  ghost via tryReconnect (port of comm.c check_reconnect): detaches
  the dead descriptor, transfers the live char + room context to the
  new descriptor, and prints "Reconnecting. Type replay to see
  missed tells."

New struct fields:
- world.PCData.ReplayBuffer string — accumulates tells for DoReplay.
- world.SongTable / world.SongData — placeholder for jukebox audio.

Boot: cmd/romd/main.go now calls game.RefreshBoards() so the SQLite
note tables are seeded with the default board set on first run.

Tests: full suite green including 32 s parity diff against the C
oracle.  No regressions in net (real-WebSocket nanny flow still
walks ANSI → Name → Confirm).

This commit is the M5/M6 follow-up the project plan called out but
never delivered: every Do* in interp.go is now backed by a real
implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the last three "deliberate divergences" from the M5/M6 audit:
do_copyover, do_qmconfig, and the bug + clantalk dispatch entries.

internal/game/copyover.go
- DoCopyover hands off to a hook the net package registers at boot.
  No hook (e.g. unit tests) → "Copyover is not available" + return.
- DoQmconfig — full port of act_wiz.c:4685.  Toggles AnsiPrompt /
  AnsiColor / TelnetGA globals stored in game.QMConfig, supports
  "show" + "read" subcommands.  Settings are runtime-only in this
  build (no qmconfig.rc file); "read" reports that.

internal/net/copyover.go
- Server.Copyover() — port of act_wiz.c:4498.  Save every PC, dup
  each connected *net.TCPConn so the FD survives execve (File()
  clears CLOEXEC), dump (fd, name, host) tuples to
  /opt/rom24/data/copyover.dat, also dup the listener + HTTP
  listener so both ports stay bound across the exec, then
  syscall.Exec the current binary with --copyover-recover /
  --copyover-listen-fd / --copyover-http-fd flags.  WebSocket
  descriptors can't survive an exec (the protocol state lives in
  coder/websocket.Conn user-space objects, not the kernel) — they
  get a "rebooting, reconnect" banner and a close; web/client.js
  handles the reconnect.
- Server.AdoptListener / AdoptHTTPListener — wrap inherited FDs via
  net.FileListener so the new process picks up the same bound ports
  without a TIME_WAIT gap.
- Server.RecoverFromCopyover — read the state file, for each entry
  rebuild a net.Conn via os.NewFile + net.FileConn, load the char
  by name from SQLite, place them back in their saved room, and
  start the read goroutine.  Materialises with "$n materializes!"
  to anyone else in the room.
- Refactored ws.ListenHTTP to record the listener on the Server so
  Copyover can dup it.  serveHTTPOnListener is the adopt path that
  takes an existing listener.

cmd/romd/main.go
- Three new flags: --copyover-recover, --copyover-listen-fd,
  --copyover-http-fd.  When set, skip net.Listen and adopt the
  inherited FDs instead.  game.SetCopyoverHook wired right after
  NewServer so do_copyover dispatch works as soon as the server is
  created.

web/client.js
- Auto-reconnect with exponential backoff (500ms → 8s cap).  On
  ws.onclose (server restart, copyover dropping the WS side,
  network blip), shows "reconnecting…" status, clears the line
  buffer + echo flag, dials a fresh socket.  beforeunload sets
  intentionalClose so closing the tab doesn't try to reconnect.
- wsSend() guards against send-on-null / send-on-closed.

internal/game/interp.go
- New dispatch rows: copyover, qmconfig (immortal-only, POS_DEAD).
- New rows: bug → DoBug, clantalk → DoClan.  C interp.c has bug
  commented out and only ships "clan" for the clan-channel
  function, but the user explicitly asked for both to be reachable
  by name; the handlers were already implemented.

Tests
- internal/game/copyover_test.go covers QMConfig show / no-arg
  usage / ansicolor toggle and the no-hook DoCopyover path.  Full
  suite green including the 32 s C-oracle parity diff.

Behavioural notes
- copyover preserves: any player connected via raw telnet (port 4000).
- copyover drops + auto-reconnects: any player connected via the
  browser (https://...). They miss < 2 seconds of game time on a
  copyover-driven deploy; in-game session state (character, room,
  affects, equipment) is all preserved via the autosave + LoadChar
  path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
First subcommand: 'outfit <name>' — runs the same OutfitNewChar code
the nanny does at character creation, then saves.  Lets you retroactively
equip the school kit on a character whose creation predated the
OutfitNewChar wiring.

Used today to give Xajkil his banner / vest / shield / sword (his
character was made before the do_outfit port; the rest of the player
table predated proper baseline vitals too — that one's already fixed
via newChar()).

Run via SSM with rom24.service stopped (no WAL contention).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old equipment output listed only worn slots and printed "Nothing." when
the player wore zero items, which made it hard to see what slots even
exist.  Reworked to match the DoScore card style:

- Yellow-bordered header banner with the "Equipment" title.
- All 19 wear slots from whereName are shown every time, in order.
- Each slot label in cyan; the worn item (if any) follows on the same
  line via formatObjToChar.
- Empty slots show "{D<nothing>{x" (dim grey) so the player can see at
  a glance which slots are open.
- (Invis)/(Red Aura)/(Blue Aura)/(Magical)/(Glowing)/(Humming)
  decorations from formatObjToChar still apply.
- Footer line summarising "Worn: N of MaxWear slots".

Tests in equipment_test.go:
- TestDoEquipment_ShowsAllSlots — every slot label present;
  <nothing> count equals MaxWear (19) when the char wears nothing.
- TestDoEquipment_ShowsWornItems — equipping a sword reduces the
  <nothing> count by one and the sword's short_descr appears; no
  raw {X colour codes leak after ColourConv.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pressing Enter on an empty line re-runs the previous command, echoing
it to the terminal so the player sees what got submitted.  Suppressed
during password entry (when the server has sent IAC WILL ECHO and
localEcho is false) so a stray Enter on the password prompt doesn't
dump the password back out as a command.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds shell-style command recall to the browser client.

- Up arrow walks back through the last 100 commands.
- Down arrow walks forward; reaching the bottom restores whatever the
  user was typing before they hit Up (savedLine).
- Consecutive duplicates are dedup'd so spamming Enter on a repeated
  command doesn't bury the rest of the history.
- Empty Enter repeats history[-1] (existing behaviour preserved).
- History capture is suppressed during password entry — when the
  server has sent IAC WILL ECHO and localEcho is false — so passwords
  never enter the history array.
- Ctrl-C clears the current line AND resets historyIdx to the bottom.
- Other CSI escapes (left/right arrow, home, end, etc.) are dropped
  cleanly so they don't pollute lineBuf with stray ESC [ X bytes.

The visible line is redrawn by writing \b \b lineBuf.length times to
erase, then writing the recalled string.  Password mode renders '*'
characters for the replacement too, matching the normal input path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two small fixes that go together.

web/client.js
- Accept BOTH the normal cursor-key form (ESC [ A / ESC [ B) AND the
  application cursor-key form (ESC O A / ESC O B) for up/down arrows.
  Some xterm.js setups end up in application mode depending on the
  terminal capabilities the server claims, and the first ship only
  honoured the normal form.
- Expose history / historyIdx / lineBuf on window.__rom24 for in-
  browser debugging (open DevTools console: __rom24.history).

internal/net/ws.go
- Wrap http.FileServer with noCacheStatic so every response for the
  browser client (index.html, client.js) gets Cache-Control: no-store
  + Pragma: no-cache + Expires: 0.  Without this, browsers will keep
  an old client.js cached for hours after a deploy, which is how the
  up-arrow history fix "didn't seem to work at all" the first time
  it shipped — the browser was still running yesterday's JS.

Applies to both the live-Listen path and the copyover-adopted path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fleeing combat dropped the player into the DEAD position despite full
HP because StopFighting assigned ch.Position = ch.DefaultPos for every
char, and DefaultPos is zero-initialised on PCs (== PosDead).  The
visible symptom was "You flee from combat!" followed by every command
returning "Lie still; you are DEAD." with the prompt still showing
healthy HP.

C ROM fight.c stop_fighting branches on IS_NPC: NPCs return to their
configured default_pos, PCs always return to POS_STANDING.  Match
that exactly.

Regression coverage:
- TestStopFighting_PCResetsToStanding — PC with DefaultPos=PosDead
  ends up PosStanding (the failing case the user hit).
- TestStopFighting_NPCKeepsDefaultPos — NPC with DefaultPos=PosResting
  still wakes up resting, confirming the NPC branch is intact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RdHamilton and others added 30 commits May 24, 2026 22:31
Room 3700 (Entrance) south shortcut pointed to 3744 (North Wall of Arena)
instead of 3722 (South Wall), dropping players in the wrong part of the
arena.  Fixed destination to 3722.

Room 3722 (South Wall of Arena) had no south exit back to 3721 (End of
Mud School), making the arena entrance one-way.  Added D2→3721 so
players can return to the school corridor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Combat additions:
- Dual wield (skill): off-hand secondary weapon slot with 25% damage
  reduction; weight must be <= 1/2 primary; conflicts with shield/hold.
  Adds WearSecondary slot, GsnDualWield, DoSecond command.
- Critical strike (skill): when weapon skill is 100%, ~25% chance for
  a 1.4x damage hit; messages color the weapon name and target.
- Circle (skill): backstab variant usable mid-fight (thieves 15).
- PK counters: PkKills/PkDeaths on PCData, incremented in RawKill,
  shown on score sheet, persisted via two new SQLite columns.

Inventory:
- get/put/drop/give support N.item syntax (get 3.cigar, put 2.bread bag).
- DoPut handles in/on prepositions, weight checks, pit timers, CONT_PUT_ON.
- DoDrop adds coin dropping with floor consolidation and ITEM_MELT_DROP.
- DoGive adds coin transfer with changer NPC support.
- DoGet adds 'from' preposition strip and pit-greedy guard.
- MultArgument now accepts '.' separator alongside legacy '*'.

Misc:
- Hidden items: ItemHidden extra flag, hide <item> stash, DoSearch reveal.
- Scan: now blocked by closed exits.
Three latent bugs that were silently breaking skill rolls:

1. internal/game/act_move.go: the lowercase getSkill / numberPercent /
   checkImprove / gainExp / stopFighting / affectStrip / affectToChar
   were stubs returning 0 / 50 / no-op.  Every caller that took the
   lowercase path (sneak, hide, pick lock, peek, recall) silently
   used those broken returns: sneak always failed, hide always failed,
   pick lock always failed, etc.  Replaced the stubs with thin
   delegates to the real GetSkill / NumberPercent / etc. in
   handler.go / rng.go / fight.go.  DoRecall's stopFighting call now
   passes the World argument through.

2. internal/world/types.go: ExitData.ExitInfo bumped from int16 to
   int32.  The existing exit-flag set (ExIsDoor..ExNoLock) sums to
   exactly 32767 — adding any further flag (ExWedge / ExWedgeproof /
   ExBashed are coming with the bank snippet port) would overflow
   the signed int16.

3. internal/game/reset.go: room reset no longer needs the
   `int16(pExit.RsFlags)` truncation cast — RsFlags is int32 and
   ExitInfo is now int32 too.

No behavior change for the C-faithful path except that those skill
checks now actually consult the skill % and the per-pulse RNG.
…n_loot + 42 tests

Tier-2 mud/ snippet ports plus a catch-up test sweep for tier-1 features
that shipped without coverage in ad96883.

New systems:

- bank.c: ROOM_BANK + ACT_BANKER NPCs; balance/deposit/withdraw in gold.
  EX_WEDGE / EX_WEDGEPROOF / EX_BASHED exit flags; wedge + pry skills
  (thief 12, warrior 15) jam/unjam closed doors on both sides.  MoveChar
  and DoOpen now respect EX_WEDGE.  Beep rings the bell character on a
  target across the world.  Bank persists via new SQLite column with
  idempotent ALTER migration.

- autosteal.txt: PLR_AUTOSTEAL toggle, runs in CharUpdate for level-11+
  thieves with the steal skill.  Race-bonus block dropped (we don't have
  Kender/Silvanesti/etc).  Shared stealCheck mirrors Maslin's failure
  path including PlrThief flagging across the L7 PK gap.

- hunt.c: GSN_HUNT tracking skill — BFS pathfinder over the room graph
  returns the first-step direction to a target.  Pure Go map replaces
  the C custom hash table.  Top trust crosses areas; lower trust is
  area-scoped via getCharArea.

- fa_exp.c: XpCompute bonuses for victims with combat-significant
  powers (sanctuary +30%, haste +20%, area/backstab/fast +20%,
  dodge/parry +10%).

- fa_drink.c potion-in-container: DoPour now accepts a potion as source
  and stores its vnum in Value[4], one dose per pour, no liquid mixing.
  DoDrink auto-quaffs stored potions through a new shared autoQuaff()
  helper that also handles alignment burn and level cap.

- can_loot gate: CanLootCorpse() — immortals and owner always pass;
  online owner needs PlrCanLoot or a shared group; offline owner
  defaults to allow.  Wired into DoGet's ItemCorpsePC branch.

Tests (42 new):

- dual_wield_test.go (11): DoSecond gates (skill, primary, shield,
  overweight); critical-strike requires 100% weapon mastery; DoCircle
  requires fighting + wield.
- multi_item_test.go (11): multArg / MultArgument parsers (both
  separators), N.item paths through get/drop/put/give, coin
  consolidation on drop, RawKill PK counter rules.
- hidden_test.go (6): CanSeeObj hides ItemHidden from normal viewers
  but shows to holy-light; DoHide stash path with NoDrop refusal;
  DoSearch reveal with and without skill.
- bank_test.go (14): balance + deposit + withdraw round trip + caps;
  beep both-sides + NPC refusal; wedge/pry door state including
  far-side sync; wedged exit blocks both DoOpen and MoveChar.
- hunt_test.go (8): adjacent + multi-hop BFS, branching pick,
  unreachable, sameAreaOnly gate, DoHunt no-arg + level + same-room.
- testmain_test.go: runs InitGsns once per test binary so skills
  resolve against the right table indices.

Bugs caught (and fixed) by the test sweep:

1. InitGsns was missing the seven new GSNs (dual wield, critical,
   circle, search, wedge, pry, hunt) — all references resolved to
   slot 0.  Added bindings.
2. findHuntPath's sameAreaOnly check ran on the current node instead
   of the candidate neighbour, so cross-area paths leaked through.
   Moved the area check to the edge target.
Adds 20 more tier-2 tests, one file per snippet:

- fa_exp_test.go (7): XpCompute baseline + each affect/offflag bonus
  individually (sanctuary/haste/area/backstab/fast/dodge/parry) and
  stacking.  RNG seeded so the fuzz step is deterministic.
- can_loot_test.go (7): CanLootCorpse for immortal, owner, offline
  owner, online owner default-deny, PlrCanLoot consent, same-group
  pass; plus an integration test that DoGet on a denied PC corpse
  hits the gate.
- autosteal_test.go (6): stealCheck immortal-victim and
  immortal-attacker paths; DoAutosteal toggle round-trip + NPC
  no-op; Autosteal short-circuits in RoomSafe and across >20 level
  gap.
- potion_test.go (7): DoPour stores potion vnum in Value[4]; refuses
  mixing two potions, liquid into potion, potion into liquid;
  pour-out clears Value[4]; autoQuaff enforces level cap and
  alignment burn.

All 62 game-package tests pass (33 new this commit, 29 carried over).
Erwin S. Andreasen's auction system on top of the existing chat
channel.  One auction at a time; PulseAuction=10s drives the
going-once → going-twice → SOLD state machine via UpdateHandler.

Commands (subcommands shadow the chat channel):
  auction                — show current item or toggle channel
  auction sell <item>    — post your item to the block
  auction bid <amt>      — bid; alias "bet"
  auction stop           — immortal kill switch
  auction info|stats     — current item + seller + bid
  auction <free text>    — falls through to channel chat

advAtoi parses "5k" / "2k500" / "3m250" shorthand.  parseBet adds
relative raises: "+25" (+25%), "+" (+25% default), "x2" (double),
"x" (double default), "%50" (half of current).

DoQuit / DoRent now block via PlayerInAuction so the seller or
current high bidder can't disappear mid-auction (their extraction
would leave a dangling pointer in AuctionState).

Item type whitelist matches the C snippet: weapon, armor, staff,
wand, scroll, treasure.  Min bid raise is +100 gold.  Refunds the
previous high bidder when out-bid.

Tests (11):
- TestAdvAtoi / TestParseBet — parser table tests
- Sell posts the item + leaves seller's inventory; rejects junk
  item types
- Bid below the +100 minimum is refused; refunds the previous
  bidder on a new high
- AuctionUpdate walks three full cadences to SOLD with both gold
  and item transfer; no-bid path returns the item to the seller
- Immortal stop confiscates; mortal stop is refused
- PlayerInAuction true for seller and current buyer; false for
  uninvolved players

All 73 game-package tests pass.
Post-M7: dual wield, multi-item, mud/ snippet ports (tiers 1+2)
Port of Robert Schultz (Sembiance)'s Game Code v2 for ROM.

Adds two mini-games hidden behind one new "game" verb:

  game slots <machine>     — find an ItemSlotMachine in the room,
                             pay its per-spin cost, roll N bars
                             (3 or 5), collect winnings + jackpot
                             based on matches.  Partial-winnings
                             mode pays for pairs; frozen mode
                             prevents jackpot drift.
  game highdice <bet>      — find an ActDealer NPC, each side rolls
                             2d6, dealer wins ties.  Min 10, max 1000.

Adds:
- ItemSlotMachine = 35 item type (Value[0] cost, [1] jackpot,
  [2] bars 3|5, [3] partial-mode flag, [4] frozen flag)
- ActDealer = BitL mob act flag
- internal/game/games.go: DoGame / DoSlots / DoHighDice with
  colored bar strip rendering

Tests (15):
- DoGame routing (NPC reject, empty-arg help, unknown-game)
- Slots: missing machine, syntax, broken machine, bad bar count,
  cannot-afford gate, normal spin charges cost + renders reels
- HighDice: syntax, min-bet, max-bet, missing-dealer, cannot-afford,
  resolved roll moves gold by the bet amount in either direction
Port of mlkesl@stthomas.edu's automap.  'map' walks the room graph
from the player's current room and renders a colored ASCII grid of
the surrounding area.

  map         — 21x21 default
  map <N>     — 2N+1 grid (clamped 3..30)

Sector-to-glyph mapping in one sectorGlyph() helper (the C version
repeated the switch three times).  Six sector types from the C
snippet that don't exist in our ROM 2.4 port (ROCK_MOUNTAIN,
SNOW_MOUNTAIN, ROAD, ENTER, JUNGLE, SWAMP, RUINS) were collapsed.

Walker stops at:
- Closed doors (renders the side we're on, not the other)
- One-way exits / mazes (marked '?' but not traversed)
- Opaque sectors — city, indoors, mountain, hills (rendered, not
  traversed past)

Tests (7):
- 3-room east/west chain visits all three
- Closed door blocks the walker
- Opaque sector renders but doesn't propagate
- One-way exit is marked as a maze tile
- sectorGlyph table cases
- Centre renders as '*' player marker on the right line
- DoMap clamps out-of-range radii without panicking
tier-3 start: slot machines + high-dice (game_code_v2)
Port of Vassago's Quest Code v2.03 from Moongate (1996).  Players
visit a questmaster to receive a randomly-generated kill-or-recover
quest with a 10-30 minute realtime countdown; turn-in pays quest
points spendable on rewards plus gold and occasional practices.

Adapted to ROM 2.4 from the C original:

- Questmasters identified by ActQuestmaster mob ACT flag (BitM)
  rather than a spec_questmaster special procedure
- PCData gains QuestPoints, NextQuest, Countdown, QuestObj, QuestMob,
  QuestGiver fields.  QuestPoints + NextQuest persist via two new
  SQLite columns with idempotent ALTER migration
- Reward + quest-token vnums in const.go so admins can retarget
- Level-difficulty bands rescaled from Moongate's 350 levels to
  ROM 2.4's 60 (newbie / low / mid / high)
- Reward table is a slice instead of a long if/else chain — same
  set of items: comfy chair 1000qp, sword 850qp, amulet/shield
  750qp, decanter 550qp, gold 500qp, practices 500qp

Commands:
  quest                — subcommand help
  quest points         — show QP (anywhere)
  quest info           — current quest target (anywhere)
  quest time           — cooldown or countdown (anywhere)
  quest list           — reward catalog (questmaster required)
  quest buy <item>     — purchase reward (questmaster required)
  quest request        — ask for a new quest (questmaster required)
  quest complete       — turn in current quest (questmaster required)

Hooks:
- GroupGain calls QuestNoteKill when a PC kills a mob — matching
  quest mob sets QuestMob=-1 so the player must return to the
  questmaster
- AreaUpdate calls QuestUpdate per pulse_area to tick down
  countdown / nextquest, fire hurry warning at <6 min, expire the
  quest at 0
- showCharToChar0 prefixes the active quest mob's description with
  {R[TARGET]{x

Tests (22):
- DoQuest dispatch (help, points, time variants)
- Questmaster gating (missing, cooldown, already-on-quest)
- Buy paths (insufficient points, practices, gold, unknown item)
- questLevelDiff bands (newbie/low/mid/high)
- QuestUpdate cooldown decrement + end notice
- QuestUpdate countdown expiry resets quest + sets 10-min cooldown
- QuestUpdate hurry warning at countdown<6
- QuestNoteKill matching + non-matching + off-quest cases
- showCharToChar0 [TARGET] tag toggles correctly
tier-3 quest: automated quest system (quest-2.03.c port)
…ather/)

Port of Thomas J Whiting's expanded weather snippet on top of ROM 2.4's
basic time/sky loop.  Replaces the bare hourly-sunlight WeatherUpdate
with a 10-state Markov sky machine, per-player auto-broadcast, a
player-facing forecast, hazard events that damage outdoor PCs, and
a control-weather spell that walks the sky discretely.

New sky states (added to existing Cloudless/Cloudy/Raining/Lightning):
- Foggy, Snowing, Hailstorm, Thunderstorm, Icestorm, Blizzard

New player command:
  weather       — show sky + date (outdoor only)
  autoweather   — toggle whether room-look prints weather lines

New admin command (L4+):
  wset <cond>   — snap the global sky to a specific state for
                  testing / events.  Bypasses the state machine.

Hazard events (fire only when their sky is active):
- lightning  — strikes outdoor PCs >L17, scaled damage by level band,
               immunity/resistance/vulnerability respected, 40-pulse
               wait state
- blizzard   — outdoor effect ladder: warning -> trip -> tree collision
               -> freezing limbs (5/15/30 hp tiers)
- ice        — same shape as blizzard, ice-themed messages
- hail       — golfball-sized stones (5/30 hp tiers)
- fog        — fog/hole/tree/river effects (5/15/30 hp tiers)

Movement gate (port of act_move.weather):
- During Blizzard or Hailstorm, players can't step from indoors out
  unless they have Flying / Haste / Infrared
- Flying characters get a 25%-per-step chance of being grounded;
  Move pool halves and the step cancels
- Race-specific wing-size table from the snippet was dropped — we
  don't have Pixie/Kenku/Draconian etc.

control_weather spell rewritten (was a basic mmhg-nudge stub):
- "better" walks sky one step toward Cloudless via a transition table
- "worse" walks sky one step toward Blizzard
- Also nudges WeatherInfo.Change so the basic drift still respects intent

Adaptations from the C source:
- The if-chain `number_chance(N)` ladders for sky transitions become
  a single NumberPercent() roll inspected against cumulative thresholds
- Hazard event chance ladders (number_range(0,2000) <= N) become
  the equivalent percentile buckets
- Lightning's three #defines (all typo'd LI1 in the source) become
  natural MaxLevel/3 and 2*MaxLevel/3 bands
- Unknown sky values reset to Cloudless rather than spinning forever

Tests (23):
- Hour rollover (++, day, month)
- Sunrise/sunset hour transitions
- Unknown sky resets
- numberChance bounds (0% never, 100% always)
- isOutdoors: SectInside + RoomIndoors flag + clean field
- DoWeather: indoor refusal + outdoor forecast
- DoAutoweather toggle round-trip
- ShowWeather sky→line table (10 cases)
- DoWset: syntax, snap to sky, unknown refused
- controlWeatherBetter / controlWeatherWorse transition tables
- MoveChar: blizzard/hail block indoor→outdoor; flying bypasses;
  cloudless sky doesn't restrict
tier-3 weather: sky state machine + hazards + control_weather
Port of Jair's deity system: players can pick a god, sacrifice
flavor text uses the player's god instead of "Mota", and a pray
channel reaches everyone who worships the same god.

Adaptations from the C source:
- Nanny CON_GET_GOD creation step deferred — would break the
  parity test transcripts.  Players default to god 0 (Mota) and
  pick a god voluntarily via the new `setgod` command.
- GodTable lives in internal/world/tables.go alongside other static
  data (LiqTable, AttackTable, etc.) rather than as a C global.
- The $g act-code substitution from the snippet is replaced with
  PlayerGodName(ch) at the four call sites in DoSacrifice — easier
  to follow and avoids touching the Act() formatter.
- Class indexes assume our 4-class set: Mage=0, Cleric=1,
  Thief=2, Warrior=3.  Snippet had the same.

New constants:
- world.PlrQuestor was already added in PR #6; God field on PCData
  is int16 (no flag needed)
- world.CommNoPray = BitK — mutes the pray channel
- world.GodType + world.GodTable with 7 sample gods (Mota,
  Aleera, Selune, Tempus, Mask, Talos, Cyric)

Persistence:
- One new SQLite column `god INTEGER NOT NULL DEFAULT 0` with the
  idempotent ALTER migration.  Round-trips through the existing
  upsert + load path.

Commands:
- `setgod`         — list eligible gods + current
- `setgod <name>`  — pick a god (alignment + class gated; immortals
                     bypass eligibility)
- `pray`           — toggle the channel
- `pray <msg>`     — broadcast to fellow worshippers; muted
                     listeners (CommNoPray) and quiet listeners
                     (CommQuiet) are skipped.  Speaking on the
                     channel auto-unmutes.

Sacrifice routing:
- DoSacrifice's "Mota gives you X silver" lines now interpolate
  PlayerGodName(ch); NPCs and ungod'd PCs still see "Mota"

Score:
- New "You worship X" line under the PK stats block in DoScore

Tests (16):
- PlayerGodName: default / NPC / out-of-range fallback
- godLookup: exact + prefix + miss + empty
- playerCanWorship: Mota always; alignment gate; class gate
- DoSetGod: list shows current, bad name refused, eligibility
  refused, success path, immortal bypass
- DoPray: toggle, broadcast filtered by god + mute + quiet, auto
  un-mute on speak
- DoScore mentions the player's god
Port of Tch (Feudal Realms 1997-99) bow-and-arrow snippet.  Adds
ranged combat with three new commands and per-quiver scaling
arrows that lodge in the victim's body parts.

New constants:
- ItemQuiver=36, ItemArrow=37 item types
- WeaponBow=9 weapon class (bow weapon type)
- WearShoulder=20 + WearLodgeLeg/Arm/Rib=21-23 wear slots
- MaxWear bumped 20 → 24
- ItemWearShoulder=BitR wear flag (quivers attach to shoulder)
- ItemLodged=BitAA extra flag (arrow currently lodged)
- ObjVnumArrow=1208 default arrow vnum (builders can override)
- GsnBow skill (thieves L25, warriors L10)

New commands:
- `draw`     — pull arrow from worn quiver into the held slot,
               needs at least one free hand.  Decrements quiver
               arrow count; extracts the quiver when empty
- `fire`     — shoot the held arrow at a target.  Same room or
               one open exit away (same area only).  Miss → arrow
               lands in target's room.  Hit → d10 picks lodge
               slot (1-6 leg, 7-9 arm 3/2x dmg, 10 chest 2x dmg)
- `dislodge` — yank a lodged arrow out, deals damage scaled
               by the arrow's stored dice and which body part

Wear-slot wiring:
- wearObj now handles ItemWearShoulder → WearShoulder
- whereName extended with the shoulder slot + three lodge labels
  ("(lodged in a leg)" etc, in red so they stand out on EQ)

Adaptations from the C source:
- The C mutated `obj.WearFlags` to magic integer literals
  (8388609 / 16777217 / 33554433) to push the arrow through
  wear_obj into specific lodge slots.  We call EquipChar
  directly with the slot constant — cleaner, doesn't touch
  wear_flags
- The C snippet hardcoded 134217728 for ITEM_LODGED; we use
  the named ItemLodged constant
- OLC integration deferred — area builders create arrows and
  quivers via .are files as usual

Tests (12):
- DoDraw no-quiver, hands-full, empty-quiver paths
- DoFire no-bow, non-bow wielded, no-arrow held, non-arrow held,
  self-target, target-not-found refusals
- DoDislodge no-arg, nothing-lodged, lodged-arrow removal
  (lodged flag cleared, arrow unequipped, HP reduced)
- wearObj routes quiver to WearShoulder slot
tier-3 bowfire: ranged combat (bowfire.c port)
tier-3 deity: pick-a-god worship system (deity.c port)
…onger needed

Removes src/ (the original QuickMUD/ROM 2.4b6 C source) and the
built area/rom binary.  These were the parity oracle for M0–M5:
test/parity/diff/parity_test.go launched both servers on free
ports with a shared Mitchell-Moore seed and diffed transcripts
byte-for-byte.  M5 closed with zero diff a long time ago, so the
oracle isn't doing day-to-day work anymore.

The full pre-deletion master is preserved at tag c-oracle-archive
(also pushed to origin), so the C source is always recoverable
for spot checks or further reference.

Kept:
- doc/license.doc + doc/license.txt — diku/merc/ROM attribution
  the licenses require us to keep with any derived work
- test/parity/diff/parity_test.go — already auto-skips with
  "C oracle not built (area/rom missing); run 'cd src && make'
  first", so leaving it in place doesn't hurt; if anyone ever
  needs to re-run parity they checkout the archive tag

65 files deleted, no build/test impact.
chore: archive ROM 2.4b6 C source — port is parity-clean
Replaces:
- README.md       (old top-level, Docker-focused, pointed at the
                  other READMEs for the rest)
- README.merc     (1993 Merc 2.1 installation instructions)
- README.quickmud (QuickMUD feature list, mostly defunct help URLs)
- README.rom      (ROM 2.4 notes, broken FAQ link, defunct mailing
                  list)
- README.version  (one-line "ROM 2.4b6, May 29, 1998")

The new README is honest about what the project is today (a Go port
of QuickMUD, live at mud.rhamiltoneng.com), then preserves the full
Diku → Merc → ROM → QuickMUD → rom24 attribution chain that the
Diku and Merc licenses require us to keep visible.

Also adds:
- Quick-start instructions for the Go binary (the existing README
  only had Docker instructions for the upstream C image)
- Repository layout table
- Build / test / deploy commands
- A "Scope" section explaining what the Go port keeps vs replaces
  (SQLite player store, no IMC2, no copyover, systemd instead of
  csh startup, Go GC instead of recycle pools)
- Snippet sweep summary (dual wield through bowfire)
- Pointer to the c-oracle-archive tag for anyone who wants the
  original C source back

CHANGELOG.quickmud is kept as-is in the repo root — it's a
historical artifact of the upstream C codebase and shouldn't be
folded into the new README.

The full license texts in doc/license.doc (Diku) and doc/license.txt
(Merc) are also kept as-is — the README now points to them as the
canonical source.
docs: consolidate 5 top-level READMEs into one
`who` rendered "Xajkilthe Warrior" when a character leveled, because
AdvanceLevel built `"the " + lookupTitle(ch)` and stored it raw via
SetTitle, bypassing the space-prepending logic that DoTitle was
carrying locally.

The C ROM convention (src/act_info.c set_title) is that the *set*
function — not its callers — owns the space rule: a title that
doesn't start with sentence punctuation (".", ",", "!", "?") gets a
leading space so name+title concatenates cleanly.

Changes:
- Move the space-prepend logic from DoTitle into SetTitle so both
  the player command and the level-up auto-title path share it.
- DoTitle is now a thin wrapper that just calls SetTitle.
- persist.LoadCharacter applies the same one-shot heal to legacy
  saves so existing characters with broken titles render correctly
  the next time they log in, without needing a separate migration.

Tests:
- internal/game/title_test.go covers SetTitle's punctuation matrix,
  the NPC no-op path, and reproduces the original level-up bug.
- internal/persist/title_heal_test.go is an integration test against
  a real SQLite store that pins the legacy-title heal (and confirms
  it's idempotent on already-healed titles).
fix(title): prepend space in SetTitle and heal legacy saves
Every command and skill ported during the snippet sweep was reachable
in-game but invisible to `help` — players couldn't discover them
without reading source.  This adds 23 new help entries to
area/help.are and updates the SACRIFICE entry to mention the new
`sacrifice all` form.

New entries (each grouped under its natural aliases):

  Commands
  - MAP / AUTOMAP                ASCII automapper
  - HUNT                         tracker skill
  - CIRCLE                       thief flanking attack
  - SEARCH                       hidden-exit / hidden-object reveal
  - WEDGE / PRY                  jam and un-jam doors
  - FIRE / DRAW / DISLODGE       bowfire ranged combat
  - QUEST                        7-subcommand quest system
  - PRAY                         deity channel
  - SETGOD / WORSHIP / DEITY     pick a god to worship
  - BANK / BALANCE / DEPOSIT     banking
  - AUCTION / SELL / BID         item auction channel
  - GAME / SLOT / HIGHDICE       casino
  - OUTFIT                       reissue the school starter kit
  - COPYOVER                     hot-restart (now systemd-backed)

  Auto-flags
  - AUTOLOOT, AUTOSAC, AUTOGOLD, AUTOSPLIT
  - AUTOEXIT, AUTOSTEAL, AUTOWEATHER
  - AUTOFLAGS                    index page linking the rest

  Skill aliases
  - DUAL WIELD, CRITICAL STRIKE  (passive combat skills)

Also adds internal/game/help_coverage_test.go, which loads the real
area dir and verifies every probe a player would type (`help map`,
`help dual_wield`, `help autosteal`, …) resolves to some help entry
via the same IsName matcher DoHelp uses in production.  This locks
in the coverage — adding a new command without a help entry will
fail the test.

Caught while writing this: the MAP help originally drew a glyph
legend that included a literal `~` character.  ROM `~` is the
string terminator in .are files, so the loader truncated the entry
mid-body and silently dropped the rest of help.are.  Rewrote the
legend in prose.
docs(help): add help entries for snippet-port commands and skills
A 3x3 ASCII minimap is now rendered under the room description
on every DoLook, gated by a new PlrAutoMap flag (off by default).

Matches the existing autoexit / autoloot / autoweather pattern:
type 'automap' alone to toggle, current state shows up in the
config / score-flag list.

Changes:
- world/const.go: PlrAutoMap = BitL (next free bit after
  PlrAutoWeather).
- game/map.go:
  - mapRadMin lowered from 3 to 1 so 'map 1' produces an actual
    3x3 grid (it was silently clamping to 7x7).  The interactive
    'map' command keeps the same default (radius 10) and the same
    upper cap (30).
  - RenderMiniMap(ch, radius): exported render-only helper —
    walks the room graph and prints the grid without re-printing
    the room name, so DoLook doesn't render a duplicate title.
- game/act_info.go:
  - DoLook prints the minimap right under the description,
    before autoexit, when PlrAutoMap is set.
  - DoAutomap toggle handler.
  - config / score line shows the new flag.
- game/interp.go: 'automap' command-table entry (PosDead, level 0).
- area/help.are: standalone AUTOMAP entry.  The AUTOFLAGS index
  page added in PR #13 doesn't exist on master yet; once #13
  merges, a small follow-up will add AUTOMAP to that index.

Tests (internal/game/automap_test.go):
- Toggle on/off flips PlrAutoMap and emits the right messages.
- NPC short-circuit (mob ACT bits overlap PLR bits — must not flip).
- RenderMiniMap at radius 1 produces a 3x3 grid (the regression
  surface for the mapRadMin change).
- End-to-end: DoLook with PlrAutoMap off never shows the centre
  marker; with it on, the marker appears after the description.
feat: AUTOMAP — opt-in minimap under every room description
Three bugs fixed:

1. Shop items always shown at level 0 (reset.go)
   applyResetGive was missing the else branch for new-format objects.
   Old-format objects derived olevel from item type heuristics; new-format
   objects carry their level explicitly in pObjIndex.Level.  Port the C
   ROM else branch: olevel = pObjIndex.Level.

2. Skill indices corrupted when bow was inserted at sn=113 (persist)
   Skills were saved/loaded by integer index.  Inserting bow at sn=113
   shifted bash (113→114) and second attack (125→126), so saved characters
   loaded their practiced skills into the wrong slots.

   Fix:
   - world.SkillByName() — exact-match lookup by name in SkillTable
   - saveSkills now writes skill_name alongside sn
   - loadSkills resolves the current sn from skill_name when present,
     falling back to the stored sn for pre-migration rows
   - schema.sql migration: ALTER TABLE adds skill_name column; one-time
     UPDATE bumps sn >= 113 WHERE skill_name = '' to repair displaced rows
     on the next server start (idempotent — the WHERE guard prevents
     double-shifting)
   - Two new persist tests pin the round-trip and the migration

3. Automap radius too small (act_info.go)
   Inline automap under DoLook was hardcoded at radius 1 (3×3 grid).
   Increased to radius 3 (7×7 grid) for useful context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant