From fa2fad323341301c3a6eabe300e88a4f9300bf2f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 23 Sep 2026 19:45:29 -0600 Subject: [PATCH 1/5] Add ACK!MUD area support Read and write ACK!MUD areas: the letter-keyed #AREA header (K/L/N/I/V/X/F/U/O/R/W plus text flag lines), two-number rooms with raw exit bits, "!"-line mobiles with inline MOBprograms, four-value objects with A/E/L records, and #OBJFUNS. Detected by an #AREA name string followed by a "K keyword~" line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsTyuYeZHogKAnGhufs9gN --- README.md | 1 + area_reader/__init__.py | 2 + area_reader/cli.py | 5 + area_reader/dialects/ack.py | 406 ++++++++++++++++++++++++++++++++++++ test/ack/ack_sample.are | 116 +++++++++++ test_ack.py | 80 +++++++ 6 files changed, 610 insertions(+) create mode 100644 area_reader/dialects/ack.py create mode 100644 test/ack/ack_sample.are create mode 100644 test_ack.py diff --git a/README.md b/README.md index d072c02..27d9335 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ very easy to do things like render the entire tree of objects out as JSON or sim | ROM | `RomAreaFile` | `dumps()` / `write()` | single tilde-delimited `.are` file | | Merc | `MercAreaFile` | `dumps()` / `write()` | single tilde-delimited area file | | GodWars Deluxe | `GodWarsAreaFile` | `dumps()` / `write()` | single `.are` file (`#AREA` or `#AREADATA`) | +| ACK!MUD | `AckAreaFile` | `dumps()` / `write()` | single `.are` file with a letter-keyed `#AREA` header (`K`/`L`/`N`/`I`/`V`...) | | SMAUG | `SmaugAreaFile` | `dumps()` / `write()` | single tilde-delimited area file | | SWR / FUSS | `SwrAreaFile` | `dumps()` / `write()` | single tilde-delimited area file | | CircleMUD | `CircleAreaFile` | `dumps()` / `write()` | indexed world tree (`wld`/`mob`/`obj`/`zon`/`shp`) directory | diff --git a/area_reader/__init__.py b/area_reader/__init__.py index 641b460..030d085 100644 --- a/area_reader/__init__.py +++ b/area_reader/__init__.py @@ -1,5 +1,6 @@ """Readers for supported MUD area formats.""" +from area_reader.dialects.ack import AckAreaFile from area_reader.dialects.circle import CircleAreaFile from area_reader.dialects.coffeemud import CoffeeMudAreaFile from area_reader.dialects.godwars import GodWarsAreaFile @@ -11,6 +12,7 @@ from area_reader.dialects.tba import TbaAreaFile __all__ = ( + "AckAreaFile", "CircleAreaFile", "CoffeeMudAreaFile", "GodWarsAreaFile", diff --git a/area_reader/cli.py b/area_reader/cli.py index 725813d..92abc1d 100644 --- a/area_reader/cli.py +++ b/area_reader/cli.py @@ -4,6 +4,7 @@ import sys from pathlib import Path +import area_reader.dialects.ack import area_reader.dialects.circle import area_reader.dialects.coffeemud import area_reader.dialects.godwars @@ -21,6 +22,8 @@ AREA_SECTION = re.compile(r"(?m)^[ \t]*#AREA\b", re.IGNORECASE) NEXT_NAMED_SECTION = re.compile(r"(?m)^[ \t]*#[A-Z$]+\b", re.IGNORECASE) GODWARS_RECORD = re.compile(r"(?m)^[ \t]*[QT][ \t]*$") +# ACK!MUD: the area name string is followed by letter-keyed lines, starting with "K keyword~". +ACK_HEADER = re.compile(r"\A[^~]*~\s*K[ \t][^\n~]*~") MEDIEVIA_COMPONENTS = frozenset({"medievia.zon", "medievia.mob", "medievia.obj", "medievia.shp"}) SMAUG_SECTIONS = frozenset( { @@ -107,6 +110,8 @@ def detect_area_type(area_file_path): next_section = NEXT_NAMED_SECTION.search(area_metadata) if next_section is not None: area_metadata = area_metadata[: next_section.start()] + if ACK_HEADER.match(area_metadata): + return area_reader.dialects.ack.AckAreaFile string_count = area_metadata.count("~") if string_count >= 3: return area_reader.dialects.rom.RomAreaFile diff --git a/area_reader/dialects/ack.py b/area_reader/dialects/ack.py new file mode 100644 index 0000000..600c67f --- /dev/null +++ b/area_reader/dialects/ack.py @@ -0,0 +1,406 @@ +"""ACK!MUD area models, codecs, and reader.""" + +import logging +from collections import OrderedDict +from operator import setitem + +from attr import Factory, attr, attributes + +import area_reader.dialects.merc +import area_reader.model +import area_reader.parser +import area_reader.schema +import area_reader.values +from area_reader.native import NativeField, NativeSection, NativeWriteError, render_document +from area_reader.native import number as native_number +from area_reader.native import records as native_records +from area_reader.native import tilde_string as native_tilde_string +from area_reader.native import word as native_word + +logger = logging.getLogger("area_reader") + +ACK_TEXT_FLAGS = frozenset("BMPST") +ACK_MOB_EXTRA_FIELDS = 7 + + +def native_numbers(value, owner): + return " ".join(native_number(item, owner) for item in value) + + +def native_text_line(value, owner): + del owner + text = str(value) + if "\n" in text: + raise NativeWriteError("ACK flag text cannot span lines") + return text + + +@attributes +class AckAreaFlag: + """A letter-keyed flag line in an ACK ``#AREA`` header, such as ``T`` (teleport allowed).""" + + letter = area_reader.schema.field(default="", type=str, native=NativeField(1, native_word, suffix=" ")) + text = area_reader.schema.field(default="", type=str, native=NativeField(2, native_text_line)) + + +@attributes +class AckExit(area_reader.model.Exit): + exit_info = area_reader.schema.field(default=0, type=int, native=NativeField(4, native_number, suffix=" ")) + key = area_reader.schema.field(default=0, type=int, native=NativeField(5, native_number, suffix=" ")) + + @classmethod + def read(cls, reader, **kwargs): + door = reader.read_number() + description = reader.read_string() + keyword = reader.read_string() + exit_info = reader.read_number() + key = reader.read_number() + destination = reader.read_number() + return cls( + door=door, + description=description, + keyword=keyword, + exit_info=exit_info, + key=key, + destination=destination, + ) + + +def native_room_flags_suffix(owner): + return "\n" if owner.sector_type is None else " " + + +@attributes +class AckRoom(area_reader.model.Room): + room_flags = area_reader.schema.field( + default=0, type=int, native=NativeField(4, native_number, suffix=native_room_flags_suffix) + ) + # Some shipped ACK rooms omit the sector number entirely; keep that absence rather than inventing one. + sector_type = area_reader.schema.field( + default=0, + type=int | None, + native=NativeField(5, native_number, when=lambda owner: owner.sector_type is not None), + ) + area_number = attr(default=0, type=int) + exits = area_reader.schema.field( + default=Factory(list), type=list[AckExit], native=NativeField(8, native_records, suffix="") + ) + + @classmethod + def read(cls, reader, vnum): + logger.debug("Reading room with vnum %d", vnum) + room = cls( + vnum=vnum, + name=reader.read_string(), + description=reader.read_string(), + room_flags=reader.read_number(), + ) + reader.skip_whitespace() + room.sector_type = reader.read_number() if reader.current_char in "+-0123456789" else None + while True: + letter = reader.read_letter() + if letter == "S": + return room + if letter == "D": + room.exits.append(AckExit.read(reader=reader)) + elif letter == "E": + room.extra_descriptions.append(reader.read_object(area_reader.model.ExtraDescription)) + else: + reader.parse_fail(f"room {vnum} has record {letter!r}, expected D, E, or S") + + +@attributes +class AckMobprog: + """An inline Merc 2.2-style MOBprogram: ``>trigger arguments~`` followed by its command list.""" + + trigger = area_reader.schema.field( + default="", type=area_reader.values.Word, native=NativeField(1, native_word, prefix=">", suffix=" ") + ) + arguments = area_reader.schema.field(default="", type=str, native=NativeField(2, native_tilde_string)) + commands = area_reader.schema.field(default="", type=str, native=NativeField(3, native_tilde_string)) + + +def native_mobprogs(value, owner): + return native_records(value, owner) + "|" + + +@attributes +class AckMob: + vnum = area_reader.schema.field( + default=0, type=area_reader.values.VNum, read=False, native=NativeField(0, native_number, prefix="#") + ) + name = area_reader.schema.field(default="", type=str, native=NativeField(1, native_tilde_string)) + short_desc = area_reader.schema.field(default="", type=str, native=NativeField(2, native_tilde_string)) + long_desc = area_reader.schema.field(default="", type=str, native=NativeField(3, native_tilde_string)) + description = area_reader.schema.field(default="", type=str, native=NativeField(4, native_tilde_string)) + act = area_reader.schema.field(default=0, type=int, native=NativeField(5, native_number, suffix=" ")) + affected_by = area_reader.schema.field(default=0, type=int, native=NativeField(6, native_number, suffix=" ")) + alignment = area_reader.schema.field(default=0, type=int, native=NativeField(7, native_number, suffix=" S\n")) + level = area_reader.schema.field(default=0, type=int, native=NativeField(8, native_number, suffix=" ")) + sex = area_reader.schema.field(default=0, type=int, native=NativeField(9, native_number)) + ac_mod = area_reader.schema.field(default=0, type=int, native=NativeField(10, native_number, suffix=" ")) + hr_mod = area_reader.schema.field(default=0, type=int, native=NativeField(11, native_number, suffix=" ")) + dr_mod = area_reader.schema.field(default=0, type=int, native=NativeField(12, native_number)) + player_class = area_reader.schema.field( + default=0, type=int, native=NativeField(13, native_number, prefix="! ", suffix=" ") + ) + clan = area_reader.schema.field(default=0, type=int, native=NativeField(14, native_number, suffix=" ")) + race = area_reader.schema.field(default=0, type=int, native=NativeField(15, native_number, suffix=" ")) + position = area_reader.schema.field(default=0, type=int, native=NativeField(16, native_number, suffix=" ")) + skills = area_reader.schema.field(default=0, type=int, native=NativeField(17, native_number, suffix=" ")) + cast = area_reader.schema.field(default=0, type=int, native=NativeField(18, native_number, suffix=" ")) + defense = area_reader.schema.field(default=0, type=int, native=NativeField(19, native_number)) + mobprogs = area_reader.schema.field( + default=Factory(list), + type=list[AckMobprog], + native=NativeField(20, native_mobprogs, when=lambda owner: bool(owner.mobprogs)), + ) + + @classmethod + def read(cls, reader, vnum): + logger.debug("Reading mob %d", vnum) + name = reader.read_string() + short_desc = reader.read_string() + long_desc = reader.read_string() + description = reader.read_string() + act = reader.read_number() + affected_by = reader.read_number() + alignment = reader.read_number() + reader.read_and_verify_letter("S") + mob = cls( + vnum=vnum, + name=name, + short_desc=short_desc, + long_desc=long_desc, + description=description, + act=act, + affected_by=affected_by, + alignment=alignment, + level=reader.read_number(), + sex=reader.read_number(), + ac_mod=reader.read_number(), + hr_mod=reader.read_number(), + dr_mod=reader.read_number(), + ) + reader.read_and_verify_letter("!") + ( + mob.player_class, + mob.clan, + mob.race, + mob.position, + mob.skills, + mob.cast, + mob.defense, + ) = (reader.read_number() for _ in range(ACK_MOB_EXTRA_FIELDS)) + reader.skip_whitespace() + if reader.current_char == ">": + while reader.read_letter() == ">": + mob.mobprogs.append( + AckMobprog( + trigger=reader.read_word(), arguments=reader.read_string(), commands=reader.read_string() + ) + ) + reader.index -= 1 + reader.read_and_verify_letter("|") + return mob + + +@attributes +class AckItem: + vnum = area_reader.schema.field( + default=0, type=area_reader.values.VNum, read=False, native=NativeField(0, native_number, prefix="#") + ) + name = area_reader.schema.field(default="", type=str, native=NativeField(1, native_tilde_string)) + short_desc = area_reader.schema.field(default="", type=str, native=NativeField(2, native_tilde_string)) + description = area_reader.schema.field(default="", type=str, native=NativeField(3, native_tilde_string)) + item_type = area_reader.schema.field(default=0, type=int, native=NativeField(4, native_number, suffix=" ")) + extra_flags = area_reader.schema.field(default=0, type=int, native=NativeField(5, native_number, suffix=" ")) + wear_flags = area_reader.schema.field(default=0, type=int, native=NativeField(6, native_number, suffix=" ")) + item_apply = area_reader.schema.field(default=0, type=int, native=NativeField(7, native_number)) + value = area_reader.schema.field(default=Factory(list), type=list, native=NativeField(8, native_numbers)) + weight = area_reader.schema.field(default=0, type=int, native=NativeField(9, native_number)) + affected = area_reader.schema.field( + default=Factory(list), + type=list[area_reader.dialects.merc.MercAffectData], + native=NativeField(10, native_records, suffix=""), + ) + extra_descriptions = area_reader.schema.field( + default=Factory(list), + type=list[area_reader.model.ExtraDescription], + native=NativeField(11, native_records, suffix=""), + ) + level = area_reader.schema.field( + default=None, + type=int | None, + native=NativeField(12, native_number, prefix="L\n", when=lambda owner: owner.level is not None), + ) + + @classmethod + def read(cls, reader, vnum): + logger.debug("Reading object %d", vnum) + item = cls( + vnum=vnum, + name=reader.read_string(), + short_desc=reader.read_string(), + description=reader.read_string(), + item_type=reader.read_number(), + extra_flags=reader.read_number(), + wear_flags=reader.read_number(), + item_apply=reader.read_number(), + value=[reader.read_number() for _ in range(4)], + weight=reader.read_number(), + ) + while True: + letter = reader.read_letter() + if letter == "A": + item.affected.append( + area_reader.dialects.merc.MercAffectData( + location=reader.read_number(), modifier=reader.read_number() + ) + ) + elif letter == "E": + item.extra_descriptions.append(reader.read_object(area_reader.model.ExtraDescription)) + elif letter == "L": + item.level = reader.read_number() + else: + reader.index -= 1 + return item + + +@attributes +class AckArea: + NATIVE_SECTIONS = ( + NativeSection("AREA", owner_section="area"), + NativeSection("HELPS", collection="helps", end="0 $~\n", when=lambda area: bool(area.helps)), + NativeSection("ROOMS", collection="rooms", end="#0\n", mapping=True), + NativeSection("MOBILES", collection="mobs", end="#0\n", mapping=True), + NativeSection("OBJECTS", collection="objects", end="#0\n", mapping=True), + NativeSection("SHOPS", collection="shops", end="0\n"), + NativeSection("RESETS", collection="resets", end="S\n"), + NativeSection("SPECIALS", collection="specials", end="S\n"), + NativeSection("OBJFUNS", collection="objfuns", end="S\n"), + ) + + name = area_reader.schema.field(default="", type=str, native=NativeField(1, native_tilde_string, section="area")) + keyword = area_reader.schema.field( + default="", type=str, native=NativeField(2, native_tilde_string, prefix="K ", section="area") + ) + level_label = area_reader.schema.field( + default="", type=str, native=NativeField(3, native_tilde_string, prefix="L ", section="area") + ) + area_number = area_reader.schema.field( + default=0, type=int, native=NativeField(4, native_number, prefix="N ", section="area") + ) + min_level = area_reader.schema.field( + default=0, type=int, native=NativeField(5, native_number, prefix="I ", suffix=" ", section="area") + ) + max_level = area_reader.schema.field(default=0, type=int, native=NativeField(6, native_number, section="area")) + first_vnum = area_reader.schema.field( + default=0, type=int, native=NativeField(7, native_number, prefix="V ", suffix=" ", section="area") + ) + last_vnum = area_reader.schema.field(default=0, type=int, native=NativeField(8, native_number, section="area")) + offset = area_reader.schema.field( + default=0, type=int, native=NativeField(9, native_number, prefix="X ", section="area") + ) + reset_rate = area_reader.schema.field( + default=0, type=int, native=NativeField(10, native_number, prefix="F ", section="area") + ) + reset_message = area_reader.schema.field( + default="", type=str, native=NativeField(11, native_tilde_string, prefix="U ", section="area") + ) + owner = area_reader.schema.field( + default="", type=str, native=NativeField(12, native_tilde_string, prefix="O ", section="area") + ) + can_read = area_reader.schema.field( + default="", type=str, native=NativeField(13, native_tilde_string, prefix="R ", section="area") + ) + can_write = area_reader.schema.field( + default="", type=str, native=NativeField(14, native_tilde_string, prefix="W ", section="area") + ) + flags = area_reader.schema.field( + default=Factory(list), + type=list[AckAreaFlag], + native=NativeField(15, native_records, suffix="", section="area"), + ) + helps = attr(default=Factory(list), type=list[area_reader.model.Help]) + rooms = attr(default=Factory(OrderedDict), type=dict[int, AckRoom]) + mobs = attr(default=Factory(OrderedDict), type=dict[int, AckMob]) + objects = attr(default=Factory(OrderedDict), type=dict[int, AckItem]) + resets = attr(default=Factory(list), type=list[area_reader.dialects.merc.MercReset]) + shops = attr(default=Factory(list), type=list[area_reader.model.RomShop]) + specials = attr(default=Factory(list), type=list[area_reader.model.Special]) + objfuns = attr(default=Factory(list), type=list[area_reader.model.Special]) + mobprogs = attr(default=Factory(OrderedDict)) + + +class AckAreaFile(area_reader.parser.AreaFile): + def create_area(self): + return AckArea() + + def dumps(self): + return render_document(self.area, self.area.NATIVE_SECTIONS, self.skipped_sections) + + def write(self, path): + with open(path, mode="wt", encoding="latin-1", newline="\n") as area_file: + area_file.write(self.dumps()) + + def section_readers(self): + readers = super().section_readers() + readers["objfuns"] = self.load_objfuns + return readers + + def read_area_metadata(self): + self.area.name = self.read_string() + while True: + self.skip_whitespace() + if self.current_char in ("#", "\0"): + return + letter = self.read_letter() + if letter == "K": + self.area.keyword = self.read_string() + elif letter == "L": + self.area.level_label = self.read_string() + elif letter == "N": + self.area.area_number = self.read_number() + elif letter == "I": + self.area.min_level = self.read_number() + self.area.max_level = self.read_number() + elif letter == "V": + self.area.first_vnum = self.read_number() + self.area.last_vnum = self.read_number() + elif letter == "X": + self.area.offset = self.read_number() + elif letter == "F": + self.area.reset_rate = self.read_number() + elif letter == "U": + self.area.reset_message = self.read_string() + elif letter == "O": + self.area.owner = self.read_string() + elif letter == "R": + self.area.can_read = self.read_string() + elif letter == "W": + self.area.can_write = self.read_string() + elif letter in ACK_TEXT_FLAGS: + self.area.flags.append(AckAreaFlag(letter=letter, text=self.read_to_eol().strip())) + else: + self.parse_fail(f"Unknown ACK area header key {letter!r}") + + def load_mobiles(self): + for mob in self.load_vnum_section(AckMob): + setitem(self.area.mobs, mob.vnum, mob) + + def load_objects(self): + for item in self.load_vnum_section(AckItem): + setitem(self.area.objects, item.vnum, item) + + def load_rooms(self): + for room in self.load_vnum_section(AckRoom): + setitem(self.area.rooms, room.vnum, room) + + def load_resets(self): + for reset in self.read_flat_section(area_reader.dialects.merc.MercReset): + self.area.resets.append(reset) + + def load_objfuns(self): + for objfun in self.read_flat_section(area_reader.model.Special): + self.area.objfuns.append(objfun) diff --git a/test/ack/ack_sample.are b/test/ack/ack_sample.are new file mode 100644 index 0000000..ba3d24f --- /dev/null +++ b/test/ack/ack_sample.are @@ -0,0 +1,116 @@ +#AREA +@@GForest @@Wof @@dConfusion@@N~ +K forestconfusion~ +L @@W(@@r10 60@@W}@@N~ +N 13 +I 10 60 +V 9600 9799 +X 32200 +F 15 +U @@Wyou hear a @@btwig @@Wsnap behind you!!@@N~ +O stephen~ +R stephen jer~ +W stephen jer~ +T You can teleport into here +#ROOMS +#16001 +The Secretary's Room~ +A very old and rotten wooden desk has been left here. +~ +8 0 +D1 +~ +~ +0 -1 16000 +D3 +~ +door~ +225 16010 16002 +E +desk~ +Searching the desk, you notice nothing of any value. +~ +S +#11300 +The Hidden Passage~ +The halls open further to reveal a long, narrow passage. +~ +1392760 +D2 +~ +~ +0 -1 8488 +S +#0 +#MOBILES +#16003 +inmate~ +An imprisoned Inmate~ +An inmate of the Asylum lies here looking miserable. +~ +~ +36700163 65536 250 S +57 1 +-160 120 100 +! 0 0 0 7 6158 0 0 +#9623 +ymmas~ +the evil Ymmas~ +The Evil priest Ymmas stands here, looking pissed off. +~ +Ymass is truely an evil priest. +~ +3145751 2176 -900 S +83 1 +-100 90 100 +! 0 0 0 7 24568 1081344 16 +>act_prog p opens the trapdoor.~ +growl + close trapdoor +~ +>death_prog 100~ +MPECHO Ymmas crashes to the ground, and errupts into a mass of spiders! + MPMLOAD 9624 +~ +| +#0 +#OBJECTS +#16000 +ring turquoise~ +A Turquoise Ring~ +A small turquoise ring glitters here in the light. +~ +9 0 3 1 +0 0 0 0 +1 +A +13 60 +A +17 -50 +E +ring~ +It is turquoise. +~ +L +60 +#16001 +cleaver~ +A Viscious cleaver~ +A razor-edged cleaver lies here. +~ +5 2 8193 257 +0 0 0 3 +10 +#0 +#RESETS +M 0 16003 1 16001 (null) +E 0 16001 0 16 (null) +G 0 16000 0 +S +#SPECIALS +M 16003 spec_cast_mage +S +#OBJFUNS +O 16001 objfun_cast_fight +S +#$ diff --git a/test_ack.py b/test_ack.py new file mode 100644 index 0000000..96f6ce2 --- /dev/null +++ b/test_ack.py @@ -0,0 +1,80 @@ +from pathlib import Path + +import area_reader +import area_reader.cli +import area_reader.dialects.ack + +ACK_SAMPLE = Path("test/ack/ack_sample.are") + + +def load_ack(path: Path) -> area_reader.dialects.ack.AckAreaFile: + area_file = area_reader.dialects.ack.AckAreaFile(path) + area_file.load_sections() + return area_file + + +def test_ack_letter_keyed_area_header_is_detected() -> None: + assert area_reader.AckAreaFile is area_reader.dialects.ack.AckAreaFile + assert area_reader.cli.detect_area_type(ACK_SAMPLE) is area_reader.dialects.ack.AckAreaFile + + +def test_ack_area_header_fields() -> None: + area = load_ack(ACK_SAMPLE).area + + assert area.name == "@@GForest @@Wof @@dConfusion@@N" + assert area.keyword == "forestconfusion" + assert area.level_label == "@@W(@@r10 60@@W}@@N" + assert area.area_number == 13 + assert (area.min_level, area.max_level) == (10, 60) + assert (area.first_vnum, area.last_vnum) == (9600, 9799) + assert (area.offset, area.reset_rate) == (32200, 15) + assert area.owner == "stephen" + assert (area.can_read, area.can_write) == ("stephen jer", "stephen jer") + assert [(flag.letter, flag.text) for flag in area.flags] == [("T", "You can teleport into here")] + + +def test_ack_rooms_mobs_objects_and_resets() -> None: + area_file = load_ack(ACK_SAMPLE) + area = area_file.area + + assert not area_file.skipped_sections + assert list(area.rooms) == [16001, 11300] + secretary = area.rooms[16001] + assert (secretary.room_flags, secretary.sector_type) == (8, 0) + assert [(exit.door.value, exit.exit_info, exit.key, exit.destination) for exit in secretary.exits] == [ + (1, 0, -1, 16000), + (3, 225, 16010, 16002), + ] + assert area.rooms[11300].sector_type is None + + inmate = area.mobs[16003] + assert (inmate.act, inmate.affected_by, inmate.alignment) == (36700163, 65536, 250) + assert (inmate.level, inmate.sex, inmate.ac_mod, inmate.hr_mod, inmate.dr_mod) == (57, 1, -160, 120, 100) + assert (inmate.position, inmate.skills) == (7, 6158) + assert [prog.trigger for prog in area.mobs[9623].mobprogs] == ["act_prog", "death_prog"] + assert area.mobs[9623].mobprogs[0].arguments == "p opens the trapdoor." + + ring = area.objects[16000] + assert (ring.item_type, ring.wear_flags, ring.item_apply, ring.value, ring.weight) == (9, 3, 1, [0, 0, 0, 0], 1) + assert [(affect.location, affect.modifier) for affect in ring.affected] == [(13, 60), (17, -50)] + assert ring.level == 60 + assert area.objects[16001].level is None + + assert [(reset.command, reset.arg1, reset.arg3) for reset in area.resets] == [ + ("M", 16003, 16001), + ("E", 16001, 16), + ("G", 16000, 0), + ] + assert [(objfun.arg1, objfun.arg2) for objfun in area.objfuns] == [(16001, "objfun_cast_fight")] + + +def test_ack_native_writer_round_trips(tmp_path: Path) -> None: + source = load_ack(ACK_SAMPLE) + source.area.mobs[9623].mobprogs[1].commands = "MPECHO changed\n" + output = tmp_path / "ack.are" + + source.write(output) + reparsed = load_ack(output) + + assert reparsed.area == source.area + assert reparsed.dumps() == output.read_text(encoding="latin-1") From adc8790fda7ce3972a08faf5f7ef2a9d4c2bfc46 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 23 Sep 2026 19:54:42 -0600 Subject: [PATCH 2/5] Read ROM 2.3 areas with Merc-style headers ROM 2.3-era areas keep Merc's one-string "#AREA {levels} Author Name~" header, write a literal "S" where ROM 2.4 writes the mob group, and end M/P resets after the third argument. Detect them from the race string on the first mobile, record the header and group layouts so the native writer reproduces them, and read an M/P fourth argument only when it is present on the line. Dice also accept a flat number (e.g. mana "100") and a "+-" negative bonus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsTyuYeZHogKAnGhufs9gN --- area_reader/cli.py | 18 ++++++++++ area_reader/dialects/rom.py | 45 +++++++++++++++++++---- area_reader/model.py | 30 +++++++++++++--- test/rom23/rom23_sample.are | 71 +++++++++++++++++++++++++++++++++++++ test_rom23.py | 51 ++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 test/rom23/rom23_sample.are create mode 100644 test_rom23.py diff --git a/area_reader/cli.py b/area_reader/cli.py index 92abc1d..4c5d030 100644 --- a/area_reader/cli.py +++ b/area_reader/cli.py @@ -24,6 +24,7 @@ GODWARS_RECORD = re.compile(r"(?m)^[ \t]*[QT][ \t]*$") # ACK!MUD: the area name string is followed by letter-keyed lines, starting with "K keyword~". ACK_HEADER = re.compile(r"\A[^~]*~\s*K[ \t][^\n~]*~") +MOBILES_RECORD = re.compile(r"(?m)^[ \t]*#MOBILES\b[^\n]*\n\s*#[1-9][0-9]*[^\n]*\n", re.IGNORECASE) MEDIEVIA_COMPONENTS = frozenset({"medievia.zon", "medievia.mob", "medievia.obj", "medievia.shp"}) SMAUG_SECTIONS = frozenset( { @@ -65,6 +66,21 @@ def _looks_like_medievia_room(data): return True +def _first_mobile_has_race(data): + """Return whether the first #MOBILES record has ROM's fifth (race) string after Merc's four.""" + mobiles = MOBILES_RECORD.search(data) + if mobiles is None: + return False + cursor = mobiles.end() + for _ in range(4): + cursor = data.find("~", cursor) + if cursor == -1: + return False + cursor += 1 + following = next((line.strip() for line in data[cursor:].splitlines() if line.strip()), "") + return following.endswith("~") + + def detect_area_type(area_file_path): path = Path(area_file_path) if path.is_dir(): @@ -118,6 +134,8 @@ def detect_area_type(area_file_path): if string_count == 1: if GODWARS_RECORD.search(data): return area_reader.dialects.godwars.GodWarsAreaFile + if _first_mobile_has_race(data): + return area_reader.dialects.rom.RomAreaFile return area_reader.dialects.merc.MercAreaFile raise ValueError(f"Could not detect area type for {path}") diff --git a/area_reader/dialects/rom.py b/area_reader/dialects/rom.py index 14e456e..bc91d8a 100644 --- a/area_reader/dialects/rom.py +++ b/area_reader/dialects/rom.py @@ -50,7 +50,15 @@ def load_objects(self): setitem(self.area.objects, item.vnum, item) def read_area_metadata(self): - self.area.original_filename = self.read_string() + first = self.read_string() + self.skip_whitespace() + if self.current_char == "#": + # ROM 2.3-era areas often kept Merc's one-string header: #AREA {levels} Author Name~ + self.area.header_format = "merc" + self.area.metadata = first + self.area.name = first + return + self.area.original_filename = first self.area.name = self.read_string() self.area.metadata = self.read_string() self.area.first_vnum = self.read_number() @@ -108,6 +116,10 @@ def native_item_values(value, owner): return " ".join(encoded) +def native_group(value, owner): + return "S" if value is None else native_number(value, owner) + + def native_affect_prefix(owner): if owner.where == "TO_OBJECT": return "A\n" @@ -377,7 +389,8 @@ class RomMob(RomCharacter): default=0, type=AFFECTED_BY, converter=AFFECTED_BY, native=NativeField(7, native_flag) ) alignment = area_reader.schema.field(default=0, type=int, native=NativeField(8, native_number)) - group = area_reader.schema.field(default=0, type=int, native=NativeField(9, native_number)) + # ROM 2.3 writes a literal "S" where ROM 2.4 writes the mob group; None records that layout. + group = area_reader.schema.field(default=0, type=int | None, native=NativeField(9, native_group)) level = area_reader.schema.field(default=0, type=int, native=NativeField(10, native_number)) hitroll = area_reader.schema.field(default=0, type=int, native=NativeField(11, native_number)) hit = area_reader.schema.field( @@ -433,7 +446,12 @@ def read(cls, reader, vnum, **kwargs): act = ROM_ACT_TYPES(reader.read_flag()) | ROM_ACT_TYPES.IS_NPC affected_by = reader.read_flag() alignment = reader.read_number() - group = reader.read_number() + reader.skip_whitespace() + if reader.current_char == "S": + reader.advance() + group = None + else: + group = reader.read_number() level = reader.read_number() hitroll = reader.read_number() hit = area_reader.model.Dice.read(reader=reader) @@ -516,6 +534,10 @@ def read(cls, reader, vnum, **kwargs): ) +def rom_area_header(area): + return area.header_format == "rom" + + @attributes class RomArea: NATIVE_SECTIONS = ( @@ -529,11 +551,20 @@ class RomArea: NativeSection("SPECIALS", collection="specials", end="S\n"), ) - name = area_reader.schema.field(default="", native=NativeField(2, native_tilde_string, section="area")) + header_format = attr(default="rom", type=str) + name = area_reader.schema.field( + default="", native=NativeField(2, native_tilde_string, section="area", when=rom_area_header) + ) metadata = area_reader.schema.field(default="", native=NativeField(3, native_tilde_string, section="area")) - original_filename = area_reader.schema.field(default="", native=NativeField(1, native_tilde_string, section="area")) - first_vnum = area_reader.schema.field(default=-1, native=NativeField(4, native_number, section="area")) - last_vnum = area_reader.schema.field(default=-1, native=NativeField(5, native_number, section="area")) + original_filename = area_reader.schema.field( + default="", native=NativeField(1, native_tilde_string, section="area", when=rom_area_header) + ) + first_vnum = area_reader.schema.field( + default=-1, native=NativeField(4, native_number, section="area", when=rom_area_header) + ) + last_vnum = area_reader.schema.field( + default=-1, native=NativeField(5, native_number, section="area", when=rom_area_header) + ) helps = attr(default=Factory(list), type=list[area_reader.model.Help]) rooms = attr(default=Factory(OrderedDict), type=dict[int, area_reader.model.Room]) mobs = attr(default=Factory(OrderedDict)) diff --git a/area_reader/model.py b/area_reader/model.py index 2a3d4dd..54ccd7b 100644 --- a/area_reader/model.py +++ b/area_reader/model.py @@ -29,8 +29,22 @@ def native_trade_types(value, owner): logger = logging.getLogger("area_reader") +def reset_has_arg4(owner): + return owner.command in ("P", "M") and owner.arg4 is not None + + def native_reset_arg3_suffix(owner): - return " " if owner.command in ("P", "M") else "" + return " " if reset_has_arg4(owner) else "" + + +def number_on_current_line(reader): + """Return whether another number follows on the current line, without consuming anything.""" + index = reader.index + while index < len(reader.data) and reader.data[index] in " \t": + index += 1 + if index < len(reader.data) and reader.data[index] in "+-": + index += 1 + return index < len(reader.data) and reader.data[index].isdigit() def native_reset_arg2_suffix(owner): @@ -104,8 +118,13 @@ class Dice: @classmethod def read(cls, reader, **kwargs): number = reader.read_number() - reader.read_letter() # D + if reader.current_char not in "dD": + # Some ROM 2.3 builders wrote a flat number where dice belong (for example mana "100"). + return cls(number=0, sides=0, bonus=number, **kwargs) + reader.advance() sides = reader.read_number() + if reader.data.startswith("+-", reader.index): + reader.advance() # "1d5+-1": OLC printf("%d+%d") of a negative bonus bonus = reader.read_number() return cls(number=number, sides=sides, bonus=bonus, **kwargs) @@ -221,7 +240,7 @@ class Reset: ) arg4 = field( default=None, - native=NativeField(6, native_number, suffix="", when=lambda owner: owner.command in ("P", "M")), + native=NativeField(6, native_number, suffix="", when=reset_has_arg4), ) comment = field(default=None, type=str, native=NativeField(7, native_comment)) @@ -232,7 +251,10 @@ def read(cls, reader, letter): arg1 = reader.read_number() arg2 = reader.read_number() arg3 = 0 if letter in ("G", "R") else reader.read_number() - arg4 = reader.read_number() if letter in ("P", "M") else 0 + arg4 = 0 + if letter in ("P", "M"): + # ROM 2.4 added a fourth M/P argument; ROM 2.3 resets end after the third. + arg4 = reader.read_number() if number_on_current_line(reader) else None comment = reader.read_to_eol() return cls( command=command, diff --git a/test/rom23/rom23_sample.are b/test/rom23/rom23_sample.are new file mode 100644 index 0000000..c8ef5b3 --- /dev/null +++ b/test/rom23/rom23_sample.are @@ -0,0 +1,71 @@ +#AREA { 5 15} Paulanka Sesame Street~ + +#MOBILES +#715 +oscar grouch~ +Oscar the Grouch~ +Oscar the Grouch is here, complaining about his living conditions. +~ +~ +muppet~ +BGT N -1000 S +5 0 2d10+40 0d0+0 1d10+10 1 +50 50 50 75 +DFK 0 0 H +8 8 1 200 +AM ABCDEFGHIJK 2 0 +#716 +bird aarakocra~ +the drunk bird~ +A drunk bird staggers around here. +~ +~ +human~ +BGL 0 -100 S +2 0 1d12+23 100 1d5+-1 1 +-5 -5 -5 0 +0 0 0 0 +8 8 1 230 +0 0 2 0 +#0 + +#OBJECTS +#725 +ducky rubber~ +Rubber Ducky~ +Ernies precious little Rubber Ducky has been dropped here.~ +rubber~ +11 G AO +5 5 5 0 0 +1 2 200 G +A +5 1 +#0 + +#ROOMS +#715 +On-ramp to Sesame Street~ +You are walking towards a quiet street. +~ +0 0 1 +D1 +To Kvasir Court. +~ +~ +0 0 716 +S +#716 +Sesame Street~ +The main drag on Sesame Street. +~ +0 0 1 +S +#0 + +#RESETS +M 0 715 1 715 Oscar +G 1 725 2 +M 0 716 1 716 1 +S + +#$ diff --git a/test_rom23.py b/test_rom23.py new file mode 100644 index 0000000..5741ad3 --- /dev/null +++ b/test_rom23.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import area_reader.cli +import area_reader.dialects.rom + +ROM23_SAMPLE = Path("test/rom23/rom23_sample.are") + + +def load_rom(path: Path) -> area_reader.dialects.rom.RomAreaFile: + area_file = area_reader.dialects.rom.RomAreaFile(path) + area_file.load_sections() + return area_file + + +def test_merc_header_with_rom_mobiles_is_detected_as_rom() -> None: + assert area_reader.cli.detect_area_type(ROM23_SAMPLE) is area_reader.dialects.rom.RomAreaFile + + +def test_rom23_merc_header_s_marker_and_short_resets() -> None: + area = load_rom(ROM23_SAMPLE).area + + assert area.header_format == "merc" + assert area.name == area.metadata == "{ 5 15} Paulanka Sesame Street" + oscar = area.mobs[715] + assert oscar.group is None + assert (oscar.level, oscar.race, oscar.damtype, oscar.start_pos, oscar.sex) == (5, "muppet", "1", "8", "1") + assert [(reset.command, reset.arg3, reset.arg4) for reset in area.resets] == [ + ("M", 715, None), + ("G", 0, 0), + ("M", 716, 1), + ] + + +def test_rom_dice_tolerate_flat_numbers_and_negative_bonus_after_plus() -> None: + bird = load_rom(ROM23_SAMPLE).area.mobs[716] + + assert (bird.mana.number, bird.mana.sides, bird.mana.bonus) == (0, 0, 100) + assert (bird.damage.number, bird.damage.sides, bird.damage.bonus) == (1, 5, -1) + + +def test_rom23_native_writer_round_trips(tmp_path: Path) -> None: + source = load_rom(ROM23_SAMPLE) + output = tmp_path / "rom23.are" + + source.write(output) + rendered = output.read_text(encoding="latin-1") + reparsed = load_rom(output) + + assert rendered.startswith("#AREA\n{ 5 15} Paulanka Sesame Street~\n#") + assert reparsed.area == source.area + assert reparsed.dumps() == rendered From 4a95adac6d4b31a9baf1d85d794e50435c800af8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 23 Sep 2026 20:03:49 -0600 Subject: [PATCH 3/5] Read ROM OLC and ROT #AREADATA areas as ROM ROM OLC and ROT write a keyed #AREADATA header (Name, Filename, Builders, VNUMs, Credits, Security, Zone) over ROM bodies. Detection sent these to GodWars, which rejected Credits; route #AREADATA files with a Credits line or a race string on the first mobile to the ROM reader, which now reads and writes the header itself. ROT mobiles carry a shielded_by flag between affected_by and alignment; read it when the line holds the extra word and keep that line together on write. The ROM writer also emits #MOBPROGS instead of dropping them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsTyuYeZHogKAnGhufs9gN --- area_reader/cli.py | 4 ++ area_reader/dialects/rom.py | 101 ++++++++++++++++++++++++++++++++++-- test/rot/rot_sample.are | 79 ++++++++++++++++++++++++++++ test_rom_areadata.py | 49 +++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 test/rot/rot_sample.are create mode 100644 test_rom_areadata.py diff --git a/area_reader/cli.py b/area_reader/cli.py index 4c5d030..ae4870e 100644 --- a/area_reader/cli.py +++ b/area_reader/cli.py @@ -24,6 +24,7 @@ GODWARS_RECORD = re.compile(r"(?m)^[ \t]*[QT][ \t]*$") # ACK!MUD: the area name string is followed by letter-keyed lines, starting with "K keyword~". ACK_HEADER = re.compile(r"\A[^~]*~\s*K[ \t][^\n~]*~") +ROM_AREADATA_CREDITS = re.compile(r"(?m)^[ \t]*Credits[ \t]") MOBILES_RECORD = re.compile(r"(?m)^[ \t]*#MOBILES\b[^\n]*\n\s*#[1-9][0-9]*[^\n]*\n", re.IGNORECASE) MEDIEVIA_COMPONENTS = frozenset({"medievia.zon", "medievia.mob", "medievia.obj", "medievia.shp"}) SMAUG_SECTIONS = frozenset( @@ -111,6 +112,9 @@ def detect_area_type(area_file_path): if "FUSSAREA" in sections: return area_reader.dialects.swr.SwrAreaFile if "AREADATA" in sections: + # ROM OLC (and ROT) also write #AREADATA; their bodies are ROM, with a race string on each mobile. + if ROM_AREADATA_CREDITS.search(data) or _first_mobile_has_race(data): + return area_reader.dialects.rom.RomAreaFile godwars_fields = all( re.search(rf"(?mi)^[ \t]*{field}\b", data) for field in ("Builders", "VNUMs", "Security", "End") ) diff --git a/area_reader/dialects/rom.py b/area_reader/dialects/rom.py index bc91d8a..5b4e0a9 100644 --- a/area_reader/dialects/rom.py +++ b/area_reader/dialects/rom.py @@ -64,6 +64,36 @@ def read_area_metadata(self): self.area.first_vnum = self.read_number() self.area.last_vnum = self.read_number() + def section_readers(self): + readers = super().section_readers() + readers["areadata"] = self.read_area_data + return readers + + def read_area_data(self): + """Read the keyed #AREADATA header written by ROM OLC (and ROT).""" + self.area.header_format = "areadata" + while True: + field = self.read_word().lower() + if field == "end": + return + if field == "name": + self.area.name = self.read_string() + elif field == "filename": + self.area.original_filename = self.read_string() + elif field == "builders": + self.area.builders = self.read_string() + elif field == "vnums": + self.area.first_vnum = self.read_number() + self.area.last_vnum = self.read_number() + elif field == "credits": + self.area.metadata = self.read_string() + elif field == "security": + self.area.security = self.read_number() + elif field == "zone": + self.area.zone = self.read_number() + else: + self.parse_fail(f"Unknown ROM #AREADATA field {field!r}") + logger = logging.getLogger("area_reader") @@ -116,6 +146,19 @@ def native_item_values(value, owner): return " ".join(encoded) +def native_shielded_by_prefix(owner): + return "" if owner.shielded_by is None else f"{native_flag(owner.shielded_by, owner)} " + + +def native_shielded_line_suffix(owner): + return "\n" if owner.shielded_by is None else " " + + +def tokens_left_on_line(reader): + end = reader.data.find("\n", reader.index) + return len(reader.data[reader.index : None if end == -1 else end].split()) + + def native_group(value, owner): return "S" if value is None else native_number(value, owner) @@ -386,9 +429,19 @@ class RomMob(RomCharacter): default=0, type=ROM_ACT_TYPES, converter=ROM_ACT_TYPES, native=NativeField(6, native_flag) ) affected_by = area_reader.schema.field( - default=0, type=AFFECTED_BY, converter=AFFECTED_BY, native=NativeField(7, native_flag) + default=0, + type=AFFECTED_BY, + converter=AFFECTED_BY, + native=NativeField(7, native_flag, suffix=native_shielded_line_suffix), + ) + # ROT inserts a shielded_by flag word between affected_by and alignment; None when absent. + # The reader recognizes it by the token count on that line, so the writer keeps the line together. + shielded_by = attr(default=None, type=int | None) + alignment = area_reader.schema.field( + default=0, + type=int, + native=NativeField(8, native_number, prefix=native_shielded_by_prefix, suffix=native_shielded_line_suffix), ) - alignment = area_reader.schema.field(default=0, type=int, native=NativeField(8, native_number)) # ROM 2.3 writes a literal "S" where ROM 2.4 writes the mob group; None records that layout. group = area_reader.schema.field(default=0, type=int | None, native=NativeField(9, native_group)) level = area_reader.schema.field(default=0, type=int, native=NativeField(10, native_number)) @@ -445,6 +498,7 @@ def read(cls, reader, vnum, **kwargs): race = reader.read_string() act = ROM_ACT_TYPES(reader.read_flag()) | ROM_ACT_TYPES.IS_NPC affected_by = reader.read_flag() + shielded_by = reader.read_flag() if tokens_left_on_line(reader) >= 3 else None alignment = reader.read_number() reader.skip_whitespace() if reader.current_char == "S": @@ -509,6 +563,7 @@ def read(cls, reader, vnum, **kwargs): race=race, act=act, affected_by=affected_by, + shielded_by=shielded_by, alignment=alignment, group=group, level=level, @@ -538,10 +593,38 @@ def rom_area_header(area): return area.header_format == "rom" +def native_mobprog_code(value, owner): + return "".join( + f"#{native_number(vnum, owner)}\n{native_tilde_string(code, owner)}\n" for vnum, code in value.items() + ) + + +def native_rom_areadata(value, owner): + del value + lines = [f"Name {native_tilde_string(owner.name, owner)}"] + if owner.original_filename: + lines.append(f"Filename {native_tilde_string(owner.original_filename, owner)}") + if owner.zone is not None: + lines.append(f"Zone {native_number(owner.zone, owner)}") + lines.append(f"Builders {native_tilde_string(owner.builders, owner)}") + lines.append(f"VNUMs {native_number(owner.first_vnum, owner)} {native_number(owner.last_vnum, owner)}") + if owner.metadata: + lines.append(f"Credits {native_tilde_string(owner.metadata, owner)}") + if owner.security is not None: + lines.append(f"Security {native_number(owner.security, owner)}") + return "\n".join(lines) + + @attributes class RomArea: NATIVE_SECTIONS = ( - NativeSection("AREA", owner_section="area"), + NativeSection("AREA", owner_section="area", when=lambda area: area.header_format != "areadata"), + NativeSection( + "AREADATA", + owner_section="areadata", + end="End\n", + when=lambda area: area.header_format == "areadata", + ), NativeSection("HELPS", collection="helps", end="0 $~\n"), NativeSection("MOBILES", collection="mobs", end="#0\n", mapping=True), NativeSection("OBJECTS", collection="objects", end="#0\n", mapping=True), @@ -549,9 +632,15 @@ class RomArea: NativeSection("RESETS", collection="resets", end="S\n"), NativeSection("SHOPS", collection="shops", end="0\n"), NativeSection("SPECIALS", collection="specials", end="S\n"), + NativeSection("MOBPROGS", owner_section="mobprogs", end="#0\n", when=lambda area: bool(area.mobprogs)), ) - header_format = attr(default="rom", type=str) + header_format = area_reader.schema.field( + default="rom", type=str, native=NativeField(1, native_rom_areadata, section="areadata") + ) + builders = attr(default="", type=str) + security = attr(default=None, type=int | None) + zone = attr(default=None, type=int | None) name = area_reader.schema.field( default="", native=NativeField(2, native_tilde_string, section="area", when=rom_area_header) ) @@ -572,4 +661,6 @@ class RomArea: resets = attr(default=Factory(list), type=list[area_reader.model.Reset]) specials = attr(default=Factory(list), type=list[area_reader.model.Special]) shops = attr(default=Factory(list)) - mobprogs = attr(default=Factory(OrderedDict)) + mobprogs = area_reader.schema.field( + default=Factory(OrderedDict), native=NativeField(1, native_mobprog_code, suffix="", section="mobprogs") + ) diff --git a/test/rot/rot_sample.are b/test/rot/rot_sample.are new file mode 100644 index 0000000..d7c1997 --- /dev/null +++ b/test/rot/rot_sample.are @@ -0,0 +1,79 @@ +#AREADATA +Name Glen Dhoo~ +Builders None~ +VNUMs 30800 30899 +Credits [ 20 55] Kandahar Glen Dhoo~ +Security 9 +End + + + +#MOBILES +#30800 +imp~ +an imp~ +An {mimp{x grins broadly. +~ +This imp has obviously nothing better to do than to poke his nose into +other people's businesses. +~ +elf~ +AGU J 0 -500 0 +30 60 9d61+416 1d1+999 5d6+15 claw +-9 -9 -9 -9 +0 0 B Z +stand stand male 1500 +AHMV ABCDEFGHIJK small unknown +M RANDOM 30800 15~ +#0 + + + +#OBJECTS +#30800 +water culvert~ +a culvert~ +{CWater{W gushes{x into the culvert here.~ +stone~ +fountain 0 P +-1 -1 'water' 0 0 +0 0 0 P +#0 + + + +#ROOMS +#30800 +{GA {DShady {GFootpath{x~ +{gThis is a shady footpath at the beginning of the {Ddappled{g forest.{x +~ +0 4 3 +D0 +~ +~ +0 0 30801 +S +#30801 +A Culvert~ +Water gushes into the culvert here. +~ +0 0 3 +S +#0 + + + +#RESETS +M 0 30800 1 30800 1 +O 0 30800 0 30801 +S + + + +#MOBPROGS +#30800 +mob echo An {mimp{x follows you. +~ +#0 + +#$ diff --git a/test_rom_areadata.py b/test_rom_areadata.py new file mode 100644 index 0000000..1df52dc --- /dev/null +++ b/test_rom_areadata.py @@ -0,0 +1,49 @@ +from pathlib import Path + +import area_reader.cli +import area_reader.dialects.rom + +ROT_SAMPLE = Path("test/rot/rot_sample.are") + + +def load_rom(path: Path) -> area_reader.dialects.rom.RomAreaFile: + area_file = area_reader.dialects.rom.RomAreaFile(path) + area_file.load_sections() + return area_file + + +def test_rom_olc_areadata_with_rom_mobiles_is_detected_as_rom() -> None: + assert area_reader.cli.detect_area_type(ROT_SAMPLE) is area_reader.dialects.rom.RomAreaFile + + +def test_areadata_header_and_rot_shielded_mobile() -> None: + area_file = load_rom(ROT_SAMPLE) + area = area_file.area + + assert not area_file.skipped_sections + assert area.header_format == "areadata" + assert area.name == "Glen Dhoo" + assert area.builders == "None" + assert (area.first_vnum, area.last_vnum, area.security) == (30800, 30899, 9) + assert area.metadata == "[ 20 55] Kandahar Glen Dhoo" + imp = area.mobs[30800] + assert (imp.shielded_by, imp.alignment, imp.group) == (0, -500, 0) + assert int(imp.affected_by) == 1 << 9 # J + assert imp.level == 30 + assert area.mobprogs == {30800: "mob echo An {mimp{x follows you.\n"} + + +def test_areadata_native_writer_round_trips_header_shield_and_mobprogs(tmp_path: Path) -> None: + source = load_rom(ROT_SAMPLE) + source.area.mobs[30800].shielded_by = 4 + output = tmp_path / "rot.are" + + source.write(output) + rendered = output.read_text(encoding="latin-1") + reparsed = load_rom(output) + + assert rendered.startswith("#AREADATA\nName Glen Dhoo~\n") + assert "#MOBPROGS\n#30800\n" in rendered + assert reparsed.area == source.area + assert reparsed.area.mobs[30800].shielded_by == 4 + assert reparsed.dumps() == rendered From 55ba5b063df5e99f028ca41a83496ab7f1b1ead0 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 23 Sep 2026 20:08:29 -0600 Subject: [PATCH 4/5] Tolerate unknown room sectors and object conditions Keep room sector numbers outside SECTOR_TYPES as raw ints instead of failing, since several dialects extend the sector table. Treat unknown ROM object condition letters as perfect (100), as ROM's loader does, and consume a numeric condition whole so the record stays aligned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsTyuYeZHogKAnGhufs9gN --- area_reader/dialects/rom.py | 13 +++-- area_reader/model.py | 12 ++++- test_lenient_values.py | 96 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 test_lenient_values.py diff --git a/area_reader/dialects/rom.py b/area_reader/dialects/rom.py index 5b4e0a9..55325c9 100644 --- a/area_reader/dialects/rom.py +++ b/area_reader/dialects/rom.py @@ -208,7 +208,6 @@ class RomAffectData: class RomItem(area_reader.model.Item): @staticmethod def convert_condition(letter): - condition = -1 conditions = { "P": 100, "G": 90, @@ -218,8 +217,8 @@ def convert_condition(letter): "B": 10, "R": 0, } - condition = conditions[letter] - return condition + # ROM's load_objects falls through to 100 (perfect) for any other letter. + return conditions.get(letter, 100) vnum = area_reader.schema.field( default=0, type=area_reader.values.VNum, read=False, native=NativeField(0, native_number, prefix="#") @@ -312,7 +311,13 @@ def read(cls, reader, vnum=None, **kwargs): level = reader.read_number() weight = reader.read_number() cost = reader.read_number() - condition = cls.convert_condition(reader.read_letter()) + reader.skip_whitespace() + if reader.current_char.isdigit(): + # Some OLCs wrote a number here; consume all of it so the record stays aligned. + reader.read_number() + condition = cls.convert_condition(None) + else: + condition = cls.convert_condition(reader.read_letter()) affected = [] extra_descriptions = [] while True: diff --git a/area_reader/model.py b/area_reader/model.py index 54ccd7b..2775ee4 100644 --- a/area_reader/model.py +++ b/area_reader/model.py @@ -82,6 +82,14 @@ def native_exit_lock(value, owner): raise NativeWriteError(f"ROM exit flags {value!r} have no native lock code") +def sector_type_or_raw(value): + """Convert a sector number to SECTOR_TYPES, keeping numbers from dialect-specific sector tables as raw ints.""" + try: + return SECTOR_TYPES(value) + except ValueError: + return int(value) + + @attributes class ExtraDescription: keyword = field(default="", type=str, native=NativeField(1, native_tilde_string, prefix="E\n")) @@ -287,7 +295,9 @@ class Room(MudBase): area = attr(default=None) area_number = field(default=0, type=int, native=NativeField(3, native_number)) room_flags = field(default=0, type=ROM_ROOM_FLAGS, converter=ROM_ROOM_FLAGS, native=NativeField(4, native_flag)) - sector_type = field(default=0, type=SECTOR_TYPES, converter=SECTOR_TYPES, native=NativeField(5, native_number)) + sector_type = field( + default=0, type=SECTOR_TYPES | int, converter=sector_type_or_raw, native=NativeField(5, native_number) + ) heal_rate = field( default=100, type=int, diff --git a/test_lenient_values.py b/test_lenient_values.py new file mode 100644 index 0000000..95b1608 --- /dev/null +++ b/test_lenient_values.py @@ -0,0 +1,96 @@ +from pathlib import Path + +import area_reader.dialects.rom +from area_reader.constants import SECTOR_TYPES + +ROM_AREA_WITH_UNKNOWN_SECTOR = """#AREA +animal.are~ +Animal Kingdom~ +{ 1 20} Builder Animal Kingdom~ +100 199 +#ROOMS +#100 +A Burrow~ +A snug burrow. +~ +0 0 15 +S +#101 +A Field~ +An open field. +~ +0 0 2 +S +#0 +#$ +""" + + +ROM_AREA_WITH_ODD_CONDITIONS = """#AREA +isles.are~ +The Isles~ +{ 1 20} Builder The Isles~ +100 199 +#OBJECTS +#100 +fur coat~ +a fur coat~ +A fur coat lies here.~ +fur~ +armor 0 AK +26 26 26 20 0 +80 7 2000 p +#101 +silver ingot~ +a large silver ingot~ +A large bar of silver lies here.~ +silver~ +treasure 0 AO +0 0 0 0 0 +0 350 5000 10 +#102 +yew bow~ +a yew bow~ +A longbow lies here.~ +yew~ +5 0 AN +0 4 2 11 0 +0 5 250 W +#0 +#$ +""" + + +def test_unknown_object_condition_defaults_to_perfect_like_rom(tmp_path: Path) -> None: + objects = load_rom_text(tmp_path, ROM_AREA_WITH_ODD_CONDITIONS).area.objects + + assert [objects[vnum].condition for vnum in (100, 101, 102)] == [100, 100, 50] + assert objects[101].cost == 5000 + + +def load_rom_text(tmp_path: Path, text: str) -> area_reader.dialects.rom.RomAreaFile: + path = tmp_path / "area.are" + path.write_text(text, encoding="latin-1") + area_file = area_reader.dialects.rom.RomAreaFile(path) + area_file.load_sections() + return area_file + + +def test_unknown_room_sector_number_is_kept_raw(tmp_path: Path) -> None: + area_file = load_rom_text(tmp_path, ROM_AREA_WITH_UNKNOWN_SECTOR) + + assert area_file.area.rooms[100].sector_type == 15 + assert not isinstance(area_file.area.rooms[100].sector_type, SECTOR_TYPES) + assert area_file.area.rooms[101].sector_type is SECTOR_TYPES.FIELD + assert area_file.as_dict()["rooms"][100]["sector_type"] == 15 + + +def test_unknown_room_sector_number_round_trips(tmp_path: Path) -> None: + source = load_rom_text(tmp_path, ROM_AREA_WITH_UNKNOWN_SECTOR) + output = tmp_path / "written.are" + + source.write(output) + reparsed = area_reader.dialects.rom.RomAreaFile(output) + reparsed.load_sections() + + assert reparsed.area == source.area From 3b384a0d811737226d80eb573224633f282fe71b Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 23 Sep 2026 20:16:39 -0600 Subject: [PATCH 5/5] Add Devil's Silence (DSA Format) area support DSA is a ROM derivative marked by a "DSA Format~" string at the top of #AREA. Its header, mobiles, objects, rooms, and exits end in keyed lines up to "End", which are kept verbatim; the area help is bare text ended by "-1 $~"; and resets are "Reset" lines inside each room, read as typed DsaReset records. Mobiles reuse the ROM reader, including ROT's shielded_by word. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsTyuYeZHogKAnGhufs9gN --- README.md | 1 + area_reader/__init__.py | 2 + area_reader/cli.py | 4 + area_reader/dialects/dsa.py | 279 ++++++++++++++++++++++++++++++++++++ test/dsa/dsa_sample.are | 126 ++++++++++++++++ test_dsa.py | 79 ++++++++++ 6 files changed, 491 insertions(+) create mode 100644 area_reader/dialects/dsa.py create mode 100644 test/dsa/dsa_sample.are create mode 100644 test_dsa.py diff --git a/README.md b/README.md index 27d9335..f2407ee 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ very easy to do things like render the entire tree of objects out as JSON or sim | ROM | `RomAreaFile` | `dumps()` / `write()` | single tilde-delimited `.are` file | | Merc | `MercAreaFile` | `dumps()` / `write()` | single tilde-delimited area file | | GodWars Deluxe | `GodWarsAreaFile` | `dumps()` / `write()` | single `.are` file (`#AREA` or `#AREADATA`) | +| Devil's Silence (DSA) | `DsaAreaFile` | `dumps()` / `write()` | single `.are` file whose `#AREA` header starts `DSA Format~` | | ACK!MUD | `AckAreaFile` | `dumps()` / `write()` | single `.are` file with a letter-keyed `#AREA` header (`K`/`L`/`N`/`I`/`V`...) | | SMAUG | `SmaugAreaFile` | `dumps()` / `write()` | single tilde-delimited area file | | SWR / FUSS | `SwrAreaFile` | `dumps()` / `write()` | single tilde-delimited area file | diff --git a/area_reader/__init__.py b/area_reader/__init__.py index 030d085..bf1c74a 100644 --- a/area_reader/__init__.py +++ b/area_reader/__init__.py @@ -3,6 +3,7 @@ from area_reader.dialects.ack import AckAreaFile from area_reader.dialects.circle import CircleAreaFile from area_reader.dialects.coffeemud import CoffeeMudAreaFile +from area_reader.dialects.dsa import DsaAreaFile from area_reader.dialects.godwars import GodWarsAreaFile from area_reader.dialects.medievia import MedieviaAreaFile from area_reader.dialects.merc import MercAreaFile @@ -15,6 +16,7 @@ "AckAreaFile", "CircleAreaFile", "CoffeeMudAreaFile", + "DsaAreaFile", "GodWarsAreaFile", "MedieviaAreaFile", "MercAreaFile", diff --git a/area_reader/cli.py b/area_reader/cli.py index ae4870e..b5c399b 100644 --- a/area_reader/cli.py +++ b/area_reader/cli.py @@ -7,6 +7,7 @@ import area_reader.dialects.ack import area_reader.dialects.circle import area_reader.dialects.coffeemud +import area_reader.dialects.dsa import area_reader.dialects.godwars import area_reader.dialects.medievia import area_reader.dialects.merc @@ -24,6 +25,7 @@ GODWARS_RECORD = re.compile(r"(?m)^[ \t]*[QT][ \t]*$") # ACK!MUD: the area name string is followed by letter-keyed lines, starting with "K keyword~". ACK_HEADER = re.compile(r"\A[^~]*~\s*K[ \t][^\n~]*~") +DSA_HEADER = re.compile(r"\A\s*DSA Format~") ROM_AREADATA_CREDITS = re.compile(r"(?m)^[ \t]*Credits[ \t]") MOBILES_RECORD = re.compile(r"(?m)^[ \t]*#MOBILES\b[^\n]*\n\s*#[1-9][0-9]*[^\n]*\n", re.IGNORECASE) MEDIEVIA_COMPONENTS = frozenset({"medievia.zon", "medievia.mob", "medievia.obj", "medievia.shp"}) @@ -132,6 +134,8 @@ def detect_area_type(area_file_path): area_metadata = area_metadata[: next_section.start()] if ACK_HEADER.match(area_metadata): return area_reader.dialects.ack.AckAreaFile + if DSA_HEADER.match(area_metadata): + return area_reader.dialects.dsa.DsaAreaFile string_count = area_metadata.count("~") if string_count >= 3: return area_reader.dialects.rom.RomAreaFile diff --git a/area_reader/dialects/dsa.py b/area_reader/dialects/dsa.py new file mode 100644 index 0000000..c1688ba --- /dev/null +++ b/area_reader/dialects/dsa.py @@ -0,0 +1,279 @@ +"""Devil's Silence ("DSA Format") area models, codecs, and reader. + +DSA is a ROM derivative. Its #AREA header starts with a ``DSA Format~`` marker and ends in keyed +lines; mobiles, objects, rooms, and exits carry ROM bodies followed by keyed lines up to ``End``; +the area help is bare text ended by ``-1 $~``; and resets live inside rooms as ``Reset ...`` lines +instead of a #RESETS section. Keyed lines are kept verbatim because their meaning is DSA-specific. +""" + +import logging +from collections import OrderedDict +from operator import setitem + +from attr import Factory, attributes + +import area_reader.dialects.rom +import area_reader.model +import area_reader.schema +from area_reader.native import NativeField, NativeSection, NativeWriteError, render_document +from area_reader.native import number as native_number +from area_reader.native import records as native_records +from area_reader.native import tilde_string as native_tilde_string + +logger = logging.getLogger("area_reader") + +DSA_MARKER = "DSA Format" +DSA_HELP_END = "-1 $~" +DSA_RECORD_END = "End" +# Keyed lines whose value is a tilde-terminated string; every other key's value runs to end of line. +DSA_STRING_KEYS = frozenset({"Bldrs", "Descr", "Spec"}) +DSA_RESET_KEY = "Reset" + + +def native_key(value, owner): + del owner + text = str(value) + if not text or any(char.isspace() for char in text): + raise NativeWriteError(f"DSA key {text!r} must be one word") + return text + + +def native_keyed_value(value, owner): + if owner.key in DSA_STRING_KEYS: + return native_tilde_string(value, owner) + text = str(value) + if "\n" in text: + raise NativeWriteError(f"DSA {owner.key} value cannot span lines") + return text + + +def native_keyed_block(value, owner): + return native_records(value, owner) + DSA_RECORD_END + + +@attributes +class DsaKeyedLine: + """One ``Key value`` trailer line, such as ``MaxWorld 1``, ``Flags AB``, or ``Reset O 5412 1 100 0``.""" + + key = area_reader.schema.field(default="", type=str, native=NativeField(1, native_key, suffix=" ")) + value = area_reader.schema.field(default="", type=str, native=NativeField(2, native_keyed_value)) + + +def read_keyed_lines(reader): + lines = [] + while True: + key = reader.read_word() + if key == DSA_RECORD_END: + return lines + if not key: + reader.parse_fail("Unterminated DSA keyed block, expected End") + if key in DSA_STRING_KEYS: + value = reader.read_string() + else: + value = reader.read_to_eol().strip() + lines.append(DsaKeyedLine(key=key, value=value)) + + +@attributes +class DsaMob(area_reader.dialects.rom.RomMob): + trailer_text = area_reader.schema.field(default="", type=str, native=NativeField(30, native_tilde_string)) + keyed = area_reader.schema.field( + default=Factory(list), type=list[DsaKeyedLine], native=NativeField(31, native_keyed_block) + ) + + @classmethod + def read(cls, reader, vnum, **kwargs): + mob = super().read(reader, vnum, **kwargs) + mob.trailer_text = reader.read_string() + mob.keyed = read_keyed_lines(reader) + return mob + + +@attributes +class DsaItem(area_reader.dialects.rom.RomItem): + trailer_text = area_reader.schema.field(default="", type=str, native=NativeField(15, native_tilde_string)) + keyed = area_reader.schema.field( + default=Factory(list), type=list[DsaKeyedLine], native=NativeField(16, native_keyed_block) + ) + + @classmethod + def read(cls, reader, vnum=None, **kwargs): + item = super().read(reader, vnum=vnum, **kwargs) + item.trailer_text = reader.read_string() + item.keyed = read_keyed_lines(reader) + return item + + +@attributes +class DsaExit(area_reader.model.Exit): + keyed = area_reader.schema.field( + default=Factory(list), type=list[DsaKeyedLine], native=NativeField(7, native_keyed_block) + ) + + @classmethod + def read(cls, reader, **kwargs): + exit = super().read(reader, **kwargs) + exit.keyed = read_keyed_lines(reader) + return exit + + +def native_reset_comment(value, owner): + del owner + text = str(value) + if "\n" in text: + raise NativeWriteError("DSA reset comments cannot span lines") + return f" {text}" if text else "" + + +@attributes +class DsaReset: + """A room-embedded ``Reset `` line. + + DSA writes four numbers for every command (e.g. ``M 21904 1 100 0``, ``E 21916 16 100 0``); their meaning + past the vnum is DSA-specific, so they are kept positionally. + """ + + command = area_reader.schema.field(default="", type=str, native=NativeField(1, native_key, prefix="Reset ")) + arg1 = area_reader.schema.field(default=0, type=int, native=NativeField(2, native_number, prefix=" ", suffix="")) + arg2 = area_reader.schema.field(default=0, type=int, native=NativeField(3, native_number, prefix=" ", suffix="")) + arg3 = area_reader.schema.field(default=0, type=int, native=NativeField(4, native_number, prefix=" ", suffix="")) + arg4 = area_reader.schema.field(default=0, type=int, native=NativeField(5, native_number, prefix=" ", suffix="")) + comment = area_reader.schema.field(default="", type=str, native=NativeField(6, native_reset_comment)) + + +def native_dsa_room_suffix(owner): + return "S\n" + native_records(owner.resets, owner) + native_keyed_block(owner.keyed, owner) + "\n" + + +@attributes +class DsaRoom(area_reader.model.Room): + NATIVE_SUFFIX = staticmethod(native_dsa_room_suffix) + + exits = area_reader.schema.field( + default=Factory(list), type=list[DsaExit], native=NativeField(8, native_records, suffix="") + ) + resets = area_reader.schema.field(default=Factory(list), type=list[DsaReset]) + keyed = area_reader.schema.field(default=Factory(list), type=list[DsaKeyedLine]) + + def read_trailer(self, reader): + while True: + key = reader.read_word() + if key == DSA_RECORD_END: + return + if key == DSA_RESET_KEY: + self.resets.append( + DsaReset( + command=reader.read_word(), + arg1=reader.read_number(), + arg2=reader.read_number(), + arg3=reader.read_number(), + arg4=reader.read_number(), + comment=reader.read_to_eol().strip(), + ) + ) + elif not key: + reader.parse_fail("Unterminated DSA room, expected End") + else: + reader.index -= len(key) + self.keyed.extend(read_keyed_lines(reader)) + return + + def read_metadata(self, reader): + while True: + letter = reader.read_letter() + if letter == "S": + self.read_trailer(reader) + return + if letter == "D": + self.exits.append(DsaExit.read(reader=reader)) + elif letter == "E": + self.extra_descriptions.append(reader.read_object(area_reader.model.ExtraDescription)) + elif letter == "H": + self.heal_rate = reader.read_number() + elif letter == "M": + self.mana_rate = reader.read_number() + elif letter == "C": + self.clan = reader.read_string() + elif letter == "O": + self.owner = reader.read_string() + else: + reader.parse_fail(f"Don't know how to process DSA room attribute: {letter}") + + +def native_dsa_help_text(value, owner): + del owner + text = "".join(help.text for help in value) + if DSA_HELP_END in text: + raise NativeWriteError(f"DSA help text cannot contain {DSA_HELP_END!r}") + return text + + +@attributes +class DsaArea(area_reader.dialects.rom.RomArea): + NATIVE_SECTIONS = ( + NativeSection("AREA", owner_section="area"), + NativeSection("HELPS", owner_section="helps", end=DSA_HELP_END + "\n", when=lambda area: bool(area.helps)), + NativeSection("MOBILES", collection="mobs", end="#0\n", mapping=True), + NativeSection("OBJECTS", collection="objects", end="#0\n", mapping=True), + NativeSection("ROOMS", collection="rooms", end="#0\n", mapping=True), + NativeSection("RESETS", collection="resets", end="S\n", when=lambda area: bool(area.resets)), + NativeSection("SPECIALS", collection="specials", end="S\n"), + NativeSection("SHOPS", collection="shops", end="0\n"), + NativeSection("MOBPROGS", owner_section="mobprogs", end="#0\n", when=lambda area: bool(area.mobprogs)), + ) + + marker = area_reader.schema.field( + default=DSA_MARKER, type=str, native=NativeField(0, native_tilde_string, section="area") + ) + header_keyed = area_reader.schema.field( + default=Factory(list), type=list[DsaKeyedLine], native=NativeField(6, native_keyed_block, section="area") + ) + helps = area_reader.schema.field( + default=Factory(list), + type=list[area_reader.model.Help], + native=NativeField(1, native_dsa_help_text, suffix="", section="helps"), + ) + rooms = area_reader.schema.field(default=Factory(OrderedDict), type=dict[int, DsaRoom]) + mobs = area_reader.schema.field(default=Factory(OrderedDict), type=dict[int, DsaMob]) + objects = area_reader.schema.field(default=Factory(OrderedDict), type=dict[int, DsaItem]) + + +class DsaAreaFile(area_reader.dialects.rom.RomAreaFile): + def create_area(self): + return DsaArea() + + def dumps(self): + return render_document(self.area, self.area.NATIVE_SECTIONS, self.skipped_sections) + + def read_area_metadata(self): + marker = self.read_string() + if marker != DSA_MARKER: + self.parse_fail(f"Expected {DSA_MARKER!r} marker, got {marker!r}") + self.area.original_filename = self.read_string() + self.area.name = self.read_string() + self.area.metadata = self.read_string() + self.area.first_vnum = self.read_number() + self.area.last_vnum = self.read_number() + self.area.header_keyed = read_keyed_lines(self) + + def load_helps(self): + self.skip_whitespace() + end = self.data.find(DSA_HELP_END, self.index) + if end == -1: + self.parse_fail(f"Unterminated DSA help, expected {DSA_HELP_END!r}") + text = self.data[self.index : end] + self.index = end + len(DSA_HELP_END) + if text: + self.area.helps.append(area_reader.model.Help(level=0, keyword="", text=text)) + + def load_mobiles(self): + for mob in self.load_vnum_section(DsaMob): + setitem(self.area.mobs, mob.vnum, mob) + + def load_objects(self): + for item in self.load_vnum_section(DsaItem): + setitem(self.area.objects, item.vnum, item) + + def load_rooms(self): + for room in self.load_vnum_section(DsaRoom): + setitem(self.area.rooms, room.vnum, room) diff --git a/test/dsa/dsa_sample.are b/test/dsa/dsa_sample.are new file mode 100644 index 0000000..daee912 --- /dev/null +++ b/test/dsa/dsa_sample.are @@ -0,0 +1,126 @@ +#AREA +DSA Format~ +cloudkeep.are~ +Cloud Keep~ +[ 1 30] Monomach Cloud Keep~ +4700 4799 +Descr +No description. +~ +Bldrs Monomach~ +Sec 9 +Levels 1 30 +Flags A +End +#HELPS +Of all the giant races that exist in the realm, cloud giants are by far +the most civilized of the group. +-1 $~ + +#MOBILES +#4700 +child giant~ +a child giant~ +A giant child runs around playing tag with their siblings. +~ +A young giant child happily plays tag. +~ +unique~ +AGHW J 0 200 0 +25 0 5d30+208 15d10+100 3d5+8 pound +0 0 0 5 +0 J LP MQ +stand stand either 700 +AHMV ABCDEFGHIJK medium none +~ +End +#32200 +komodo dragon ito~ +a Komodo Dragon~ +The Komodo dragon of the Ito clan lies here. +~ +~ +dragon~ +ABFGTc HJTVac BCDEHOS -1000 0 +150 90 50d10+25000 10d50+12000 10d12+300 bite +-40 -40 -40 -30 +M AEL BEH I +stand stand male 0 +AHZ ACDEFGHIJKPQUVX huge none +F vul F +~ +Damroll 70 +MaxWorld 1 +Saves -40 +End +#0 + + + +#OBJECTS +#32200 +bhelliom~ +{$Bhellio{4m{0~ +Bhelliom lies here glowing with a blue aura.~ +steel~ +weapon ABMYd AN +hand 40 100 wrath ABCDEGHIJK +102 0 10000 P +A +19 1000 +E +bhelliom~ +It glows. +~ +~ +Spec flaming~ +MaxWorld 5 +End +#0 + + + +#ROOMS +#4700 +Front Gate~ +There is a gate here. It is huge, towering over you. +~ +0 4 2 +D2 +~ +gate~ +1 0 4701 +Flags AB +End +S +Reset M 4700 1 100 0 Load a child giant. +Reset E 32200 16 100 0 +End +#4701 +The Lawn~ +The lawn seems to stretch and stretch to the south. +~ +0 0 2 +D0 +~ +gate~ +1 0 4700 +End +S +End +#0 + + + +#SPECIALS +M 4700 spec_cast_mage +S + + + +#SHOPS +0 + + + +#$ diff --git a/test_dsa.py b/test_dsa.py new file mode 100644 index 0000000..9496eb4 --- /dev/null +++ b/test_dsa.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import area_reader +import area_reader.cli +import area_reader.dialects.dsa + +DSA_SAMPLE = Path("test/dsa/dsa_sample.are") + + +def load_dsa(path: Path) -> area_reader.dialects.dsa.DsaAreaFile: + area_file = area_reader.dialects.dsa.DsaAreaFile(path) + area_file.load_sections() + return area_file + + +def keyed(record) -> list[tuple[str, str]]: + return [(line.key, line.value) for line in record.keyed] + + +def test_dsa_format_marker_is_detected() -> None: + assert area_reader.DsaAreaFile is area_reader.dialects.dsa.DsaAreaFile + assert area_reader.cli.detect_area_type(DSA_SAMPLE) is area_reader.dialects.dsa.DsaAreaFile + + +def test_dsa_header_and_bare_help_text() -> None: + area_file = load_dsa(DSA_SAMPLE) + area = area_file.area + + assert not area_file.skipped_sections + assert (area.original_filename, area.name, area.metadata) == ( + "cloudkeep.are", + "Cloud Keep", + "[ 1 30] Monomach Cloud Keep", + ) + assert (area.first_vnum, area.last_vnum) == (4700, 4799) + assert [line.key for line in area.header_keyed] == ["Descr", "Bldrs", "Sec", "Levels", "Flags"] + assert area.header_keyed[0].value == "No description.\n" + assert area.header_keyed[3].value == "1 30" + assert [help.text for help in area.helps] == [ + "Of all the giant races that exist in the realm, cloud giants are by far\nthe most civilized of the group.\n" + ] + + +def test_dsa_mob_object_room_trailers_and_room_resets() -> None: + area = load_dsa(DSA_SAMPLE).area + + child = area.mobs[4700] + assert (child.race, child.shielded_by, child.alignment, child.level) == ("unique", 0, 200, 25) + assert keyed(child) == [] + assert keyed(area.mobs[32200]) == [("Damroll", "70"), ("MaxWorld", "1"), ("Saves", "-40")] + + bhelliom = area.objects[32200] + assert bhelliom.item_type == "weapon" + assert [extra.keyword for extra in bhelliom.extra_descriptions] == ["bhelliom"] + assert keyed(bhelliom) == [("Spec", "flaming"), ("MaxWorld", "5")] + + gate = area.rooms[4700] + assert [(exit.destination, keyed(exit)) for exit in gate.exits] == [(4701, [("Flags", "AB")])] + assert [ + (reset.command, reset.arg1, reset.arg2, reset.arg3, reset.arg4, reset.comment) for reset in gate.resets + ] == [ + ("M", 4700, 1, 100, 0, "Load a child giant."), + ("E", 32200, 16, 100, 0, ""), + ] + assert area.rooms[4701].resets == [] + + +def test_dsa_native_writer_round_trips(tmp_path: Path) -> None: + source = load_dsa(DSA_SAMPLE) + source.area.rooms[4701].resets.append(area_reader.dialects.dsa.DsaReset(command="O", arg1=32200, arg2=1)) + output = tmp_path / "dsa.are" + + source.write(output) + rendered = output.read_text(encoding="latin-1") + reparsed = load_dsa(output) + + assert rendered.startswith("#AREA\nDSA Format~\ncloudkeep.are~\n") + assert reparsed.area == source.area + assert reparsed.dumps() == rendered