diff --git a/README.md b/README.md index 776ba43..fa2a862 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ numbers: records have ids, flags have names, and what stands in a room is written on the room. This needs PyYAML (`pip install area-reader[authoring]`). ``` -area-reader build SRC [-o OUT.are] [--set DIR ...] [--show-defaults] +area-reader build SRC [-o OUT] [--format rom|json] [--set DIR ...] [--show-defaults] area-reader unbuild AREA.are -o SRC ``` @@ -237,6 +237,44 @@ objects: such as `rooms.gatehouse.exits.east.key`. `build` parses its own output with the ROM reader and writes nothing unless the result equals what it compiled. +### Beyond ROM: `--format json` + +`build --format json` writes the area as JSON in place of the `.are` file: the +same JSON `area-reader AREA.are` prints for the built area, so a program that +reads one reads the other. The JSON can also carry what a ROM area file has no +place for, for a world that is not a ROM server: + +```yaml +area: + name: The Castle + vnums: {first: 12000, size: 100} + properties: {gravity: 0.9} + +rooms: + landing: + name: The Upper Landing + kind: echoing # what the room is, to the world that loads it + with: {decay: 3} # the kind's parameters + properties: {ceiling: 12} # set on the room as written + exits: + north: {to: corridor, kind: guarded, with: {phrase: open sesame}} + hatch: {to: loft, keyword: hatch, kind: guarded} +``` + +- **`kind`, `with`, `properties`** may be written on the header, a room, a mob, + an object and an exit. `kind` is a slug; `with` needs a `kind`. They appear + under the same names on the record in the JSON. `build` does not know which + kinds a world has: any slug is accepted. +- **Named exits.** An exit whose name is not one of the six directions is any + slug. It has one side and takes `to`, `keyword`, `description` and the three + keys above, with no door or key: what stands in its way is its kind's + business. In the JSON it is one more entry of the room's `exits`, with the + name as its `door`. +- The ROM part of the area is still rendered, parsed back and compared before + anything is written. `--format rom` refuses a source that uses any of this, + naming the first place it does. `lint`, `walk` and `unbuild` read area + files, not this JSON. + `unbuild` writes any ROM area the reader parses in this form: ids slugged from names, every vnum pinned, two-way exits collapsed, resets hung on their rooms. For the 48 stock areas with rooms, building the unbuilt directory gives back diff --git a/agent-notes/feat-mongoose-json.md b/agent-notes/feat-mongoose-json.md new file mode 100644 index 0000000..c0b4837 --- /dev/null +++ b/agent-notes/feat-mongoose-json.md @@ -0,0 +1,56 @@ +# feat/mongoose-json: a JSON output from `build` that carries what ROM cannot hold + +## Goal + +Q, 2026-10-12: the area source format gets extended beyond ROM ("we can always enhance the format we own +everything"), and the infrastructure must not be specific to one setting. This branch is the first piece: `build` +can write the reader's own JSON directly, so a source directory can say things a ROM `.are` file has no place for, +and a world that loads the JSON (Mongoose's `$area_reader` / `$mud_world`) receives them. + +## State (2026-10-12) + +Branch from `origin/master` `5e64b9c`, worktree `C:\Users\Q\code\area_reader-mongoose-json`. + +Written in `area_reader/authoring/builder.py`: + +- Source keys `kind` (a slug), `with` (a mapping; needs `kind`) and `properties` (a mapping with text keys) on + the area header, rooms, mobs, objects and exits written as mappings. +- An exit whose name is not one of the six compass words is a named exit: any slug, one side only, keys `to`, + `keyword`, `description` and the three extension keys. +- `build(..., output_format="json")` / `area-reader build --format json`: renders the ROM part, parses it back + and checks it as before, then writes `RomAreaFile.as_dict()` of the parsed area with the extension keys laid + over each record and the named exits appended to their rooms' `exits` (with `door` set to the name). Default + output name is the header's filename with `.json`. +- `--format rom` (the default) raises a `SourceError` at the first extension key or named exit. + +## Decisions and why + +- The JSON is the reader's JSON, not a new schema: the Mongoose loader already consumes `as_dict()` output, so a + plain source built to JSON is exactly what the server's wrapper prints for the same area's `.are`. +- The pass-through block is called `properties`, not after any one game: area_reader is a general tool. +- No version field in the JSON. +- Named exits take no door, key or reverse side: ROM's door resets address exits by direction number, and what + stands in the way of a named exit is the kind's business. +- `rom` refuses rather than drops: silently losing a kind would build an area that looks right and is not. + +## Not done + +- `lint`, `walk` and `atlas` read `.are` files only; they do not see the JSON or check kind names. +- No registry of kinds: any slug is accepted. +- `unbuild` does not read the JSON back. +- Text in a source file must still be Latin-1 without `~` everywhere, including inside `properties`. +- A typo in a compass name (`nroth`) is now a named exit, caught only by `--format rom`. + +## Checked (2026-10-12) + +- Whole suite: 1722 passed (11 new tests in `test_authoring.py`); `ruff check .` clean. `ruff format --check` + flags only code blocks in `PROJECT.md`, which this branch does not touch. +- A copy of `mongoose-areas/areas/saltworks` (30 rooms, 11 mobs, 18 objects) built with `--format json` equals, + as parsed JSON, what `area-reader saltworks.are` prints for the same source built to ROM. +- Not checked: any load of this JSON by a MOO. The Mongoose server wrapper and `$area_reader:load` take `.are` + files only today. + +## Next step + +In Mongoose, outside this repo: the server wrapper accepts a `.json` file; the loader reads `kind` to choose a +class and applies `properties`. diff --git a/area_reader/authoring/builder.py b/area_reader/authoring/builder.py index 8493ab2..21f1556 100644 --- a/area_reader/authoring/builder.py +++ b/area_reader/authoring/builder.py @@ -1,6 +1,7 @@ """Compile an area source directory to a ROM 2.4 area.""" import argparse +import json import re import tempfile from pathlib import Path @@ -11,6 +12,7 @@ from area_reader.authoring import tables from area_reader.authoring.source import ( FAMILIES, + ID, LOCK_NAME, SINGULAR, At, @@ -33,6 +35,7 @@ RomMobprog, ) from area_reader.native import render_document +from area_reader.serialization import EnumNameConverter MISSING = object() UNREPRESENTABLE = object() @@ -50,7 +53,24 @@ NO_KEY = -1 MAX_LEVEL = 60 -AREA_KEYS = ("name", "filename", "credits", "levels", "builders", "vnums", "security", "zone", "header") +FORMATS = ("rom", "json") +# What a ROM area file cannot hold. A record's kind names what it is to the world that loads the area, `with` +# gives the kind its parameters, and `properties` are set on the thing as written. +EXTENSION_KEYS = ("kind", "with", "properties") +NAMED_EXIT_KEYS = ("to", "keyword", "description", *EXTENSION_KEYS) + +AREA_KEYS = ( + "name", + "filename", + "credits", + "levels", + "builders", + "vnums", + "security", + "zone", + "header", + *EXTENSION_KEYS, +) ROOM_KEYS = ( "vnum", "name", @@ -67,8 +87,21 @@ "mobs", "objects", "random_exits", + *EXTENSION_KEYS, +) +EXIT_KEYS = ( + "to", + "look", + "door", + "keyword", + "description", + "key", + "pickproof", + "nopass", + "oneway", + "back", + *EXTENSION_KEYS, ) -EXIT_KEYS = ("to", "look", "door", "keyword", "description", "key", "pickproof", "nopass", "oneway", "back") BACK_KEYS = ("keyword", "description", "pickproof", "nopass") MOB_PLACEMENT_KEYS = ("mob", "count", "max_in_room", "max_in_world", "wears", "carries") MOB_KEYS = ( @@ -103,6 +136,7 @@ "special", "shop", "programs", + *EXTENSION_KEYS, ) OBJECT_KEYS = ( "vnum", @@ -120,6 +154,7 @@ "values", "affects", "extras", + *EXTENSION_KEYS, ) SHOP_KEYS = ("buys", "profit_buy", "profit_sell", "hours") PROGRAM_KEYS = ("trigger", "phrase", "code", "file", "vnum") @@ -175,6 +210,10 @@ class Build: defaults = attr(default=Factory(list)) door_states = attr(default=Factory(dict)) lock = attr(default=Factory(dict)) + # What the source says that the ROM model cannot hold, as (target, values, at). A target is ("area",), + # (family, vnum), ("exits", room vnum, direction number) or, for an exit ROM has no direction for, + # ("named_exits", room vnum, name); the values of that last one are the whole exit. + extensions = attr(default=Factory(list)) # Values @@ -222,6 +261,41 @@ def paragraph(value, at): return value if not value or value.endswith("\n") else value + "\n" +def slug(value, at): + if not isinstance(value, str) or not ID.fullmatch(value): + raise at.error(f"expected a lowercase slug (a letter, then letters, digits, '_' or '-'), got {value!r}") + return value + + +def mapping(value, at): + if not isinstance(value, dict): + raise at.error(f"expected a mapping, got {value!r}") + for key in value: + if not isinstance(key, str): + raise (at / key).error(f"expected a text key, got {key!r}") + return value + + +def extension(record, at): + """Return the extension keys a record writes: its kind, the kind's parameters, and plain properties.""" + written = {} + if record.get("kind") is not None: + written["kind"] = take(record, at, "kind", slug) + if record.get("with") is not None: + if "kind" not in written: + raise (at / "with").error("`with` gives a kind its parameters; name the `kind`") + written["with"] = take(record, at, "with", mapping) + if record.get("properties") is not None: + written["properties"] = take(record, at, "properties", mapping) + return written + + +def note_extension(build, target, record, at): + written = extension(record, at) + if written: + build.extensions.append((target, written, at / next(key for key in EXTENSION_KEYS if key in written))) + + def one_of(options, what): def convert(value, at): if not isinstance(value, str) or value.lower() not in options: @@ -503,6 +577,7 @@ def raw_values(item_type, value, at): def compile_header(build): header, at = build.source.area, build.source.area_at known_keys(header, at, AREA_KEYS) + note_extension(build, ("area",), header, at) area = build.area area.name = take(header, at, "name", string) area.original_filename = take(header, at, "filename", string, f"{build.source.name}.are") @@ -814,6 +889,7 @@ def compile_records(build): compiled = COMPILERS[family](build, identifier, record, at) compiled.vnum = build.numbers.vnums[family][identifier] collection[compiled.vnum] = compiled + note_extension(build, (family, compiled.vnum), record, at) # Exits @@ -823,6 +899,7 @@ def written_door(build, room, direction, value, at): if not isinstance(value, dict): return Door(room, direction, build.numbers.resolve(value, at, "rooms"), at=at) known_keys(value, at, EXIT_KEYS) + note_extension(build, ("exits", room, direction), value, at) if value.get("look") is not None: if value.get("to") is not None or value.get("description") is not None: raise (at / "look").error("a look-only exit leads nowhere and `look` is its description") @@ -870,13 +947,32 @@ def implied_door(door): ) +def named_exit(build, room, name, value, at): + """Note an exit ROM has no direction for. It has one side, and what stands in its way is its kind's business.""" + if not isinstance(value, dict): + value = {"to": value} + known_keys(value, at, NAMED_EXIT_KEYS) + exit = area_reader.model.Exit( + keyword=take(value, at, "keyword", string, ""), + description=take(value, at, "description", paragraph, ""), + key=NO_KEY, + destination=build.numbers.resolve(take(value, at, "to", lambda item, where: item), at / "to", "rooms"), + ) + written = {**EnumNameConverter().unstructure(exit), "door": name, **extension(value, at)} + build.extensions.append((("named_exits", room, name), written, at)) + + def compile_exits(build): written = {} for identifier, (record, at) in build.source.rooms.items(): room = build.numbers.vnums["rooms"][identifier] exits = record.get("exits") or {} - known_keys(exits, at / "exits", tables.DIRECTIONS) + if not isinstance(exits, dict): + raise (at / "exits").error(f"expected a mapping, got {exits!r}") for name, value in exits.items(): + if name not in tables.DIRECTIONS: + named_exit(build, room, slug(name, at / "exits" / name), value, at / "exits" / name) + continue direction = tables.DIRECTIONS.index(name) written[room, direction] = written_door(build, room, direction, value, at / "exits" / name) @@ -1259,6 +1355,22 @@ def first_difference(built, parsed, path="area"): return None +def document(compiled, parsed): + """Return the area as the reader prints it, with what the source says beyond ROM laid over it.""" + result = parsed.as_dict() + for target, written, _at in compiled.extensions: + if target[0] == "area": + result.update(written) + elif target[0] == "named_exits": + result["rooms"][target[1]]["exits"].append(written) + elif target[0] == "exits": + doors = [exit.door.value for exit in compiled.area.rooms[target[1]].exits] + result["rooms"][target[1]]["exits"][doors.index(target[2])].update(written) + else: + result[target[0]][target[1]].update(written) + return result + + @attributes class Built: area = attr() @@ -1266,6 +1378,7 @@ class Built: output = attr() defaults = attr() lock = attr() + document = attr(default=None) def lock_text(lock): @@ -1276,28 +1389,46 @@ def lock_text(lock): ) -def build(directory, output=None, sets=()): - """Compile ``directory`` to a ROM area file and return what was built. +def build(directory, output=None, sets=(), output_format="rom"): + """Compile ``directory`` and return what was built. - The rendered text is parsed back with the ROM reader, and nothing is written unless the parsed area - equals the compiled one. + The rendered ROM text is parsed back with the ROM reader, and nothing is written unless the parsed area + equals the compiled one. ``output_format`` "rom" writes that text, and refuses a source that says something a + ROM area file cannot hold. "json" writes the parsed area as the reader prints it, with each record's + ``kind``, ``with`` and ``properties`` and the named exits added. """ + if output_format not in FORMATS: + raise ValueError(f"unknown format {output_format!r}; expected one of {', '.join(FORMATS)}") source = load_source(directory) compiled = compile_source(source, [load_source(other) for other in sets]) + if output_format == "rom" and compiled.extensions: + target, _written, at = compiled.extensions[0] + what = "an exit that is not one of ROM's six directions" if target[0] == "named_exits" else "this" + raise at.error(f"a ROM area file cannot hold {what}; build with --format json") text = render(compiled.area) parsed = parse(text) difference = first_difference(compiled.area, parsed.area) if difference is not None or parsed.area != compiled.area: raise SourceError(label(directory), "", f"the built area does not read back as it was written ({difference})") - output = Path(output) if output is not None else Path(compiled.area.original_filename) - write_area(output, text) + built = None + if output_format == "json": + built = document(compiled, parsed) + text = json.dumps(built, indent=2, ensure_ascii=False) + "\n" + output = Path(output) if output is not None else Path(compiled.area.original_filename).with_suffix(".json") + with open(output, "w", encoding="utf-8", newline="\n") as json_file: + json_file.write(text) + else: + output = Path(output) if output is not None else Path(compiled.area.original_filename) + write_area(output, text) if any(compiled.lock[family] for family in FAMILIES): lock_path = source.directory / LOCK_NAME text_of_lock = lock_text(compiled.lock) if not lock_path.is_file() or read_text(lock_path) != text_of_lock: with open(lock_path, "w", encoding="utf-8", newline="\n") as lock_file: lock_file.write(text_of_lock) - return Built(area=compiled.area, text=text, output=output, defaults=compiled.defaults, lock=compiled.lock) + return Built( + area=compiled.area, text=text, output=output, defaults=compiled.defaults, lock=compiled.lock, document=built + ) def defaults_report(defaults): @@ -1319,6 +1450,13 @@ def main(argv=None): ) parser.add_argument("source", help="the source directory (holds area.yaml)") parser.add_argument("-o", "--output", help="the area file to write (default: the header's filename, here)") + parser.add_argument( + "--format", + choices=FORMATS, + default="rom", + help="rom: a ROM 2.4 area file (default). json: the area as area-reader prints it, with what ROM cannot " + "hold (kinds, properties, named exits) added", + ) parser.add_argument( "--set", dest="sets", @@ -1331,7 +1469,7 @@ def main(argv=None): "--show-defaults", action="store_true", help="print every mob value that was filled in because it was omitted" ) arguments = parser.parse_args(argv) - built = build(arguments.source, arguments.output, arguments.sets) + built = build(arguments.source, arguments.output, arguments.sets, arguments.format) area = built.area print( f"{built.output}: {len(area.rooms)} rooms, {len(area.mobs)} mobs, {len(area.objects)} objects, " diff --git a/test_authoring.py b/test_authoring.py index 985a544..32f403d 100644 --- a/test_authoring.py +++ b/test_authoring.py @@ -1,5 +1,6 @@ import collections import functools +import json import shutil from pathlib import Path @@ -1034,3 +1035,124 @@ def test_build_fails_when_the_reader_parses_something_else_back(tmp_path: Path) with pytest.raises(SourceError, match="does not read back as it was written"): authoring.build(source, tmp_path / "out.are") assert not (tmp_path / "out.are").exists() + + +# The JSON output: the reader's own JSON, and what a ROM area file cannot hold. + +EXTENDED = { + "area.yaml": HEADER + " kind: castle\n properties: {gravity: 0.9}\n", + "rooms.yaml": "rooms:\n" + " landing:\n" + " name: The Landing\n" + " kind: echoing\n" + " with: {decay: 3}\n" + " properties: {ceiling: 12, banners: [red, gold]}\n" + " exits:\n" + " north: {to: hall, kind: guarded, with: {phrase: open sesame}}\n" + " portrait-hole: {to: hall, keyword: portrait, description: A round hole., kind: guarded}\n" + " trapdoor: hall\n" + " mobs: [porter]\n" + " objects: [lamp]\n" + " hall:\n" + " name: The Hall\n", + "mobs.yaml": RAT.replace("rat", "porter") + " kind: routine\n with: {rounds: [landing, hall]}\n", + "objects.yaml": "objects:\n lamp:\n keywords: lamp\n short: a lamp\n type: light\n" + " light: {hours: -1}\n properties: {colour: green}\n", +} + + +def test_json_of_a_plain_source_is_what_the_reader_prints_for_its_area_file(tmp_path: Path) -> None: + source = copy_example(tmp_path) + authoring.build(source, tmp_path / "saltworks.are") + + built = authoring.build(source, tmp_path / "saltworks.json", output_format="json") + + printed = json.loads(area_reader.cli.load_area(tmp_path / "saltworks.are").as_json()) + written = json.loads((tmp_path / "saltworks.json").read_text(encoding="utf-8")) + assert written == printed + assert written == json.loads(json.dumps(built.document)) + assert len(written["rooms"]) == 10 + + +def test_json_carries_kinds_properties_and_named_exits(tmp_path: Path) -> None: + source = write_source(tmp_path / "source", EXTENDED) + + built = authoring.build(source, tmp_path / "out.json", output_format="json") + + area = json.loads((tmp_path / "out.json").read_text(encoding="utf-8")) + assert (area["kind"], area["properties"]) == ("castle", {"gravity": 0.9}) + assert "with" not in area + landing = area["rooms"][str(vnum(built, "rooms", "landing"))] + hall = vnum(built, "rooms", "hall") + assert (landing["kind"], landing["with"]) == ("echoing", {"decay": 3}) + assert landing["properties"] == {"ceiling": 12, "banners": ["red", "gold"]} + north, portrait_hole, trapdoor = landing["exits"] + assert north["door"] == "EXIT_DIRECTIONS.NORTH" + assert (north["kind"], north["with"]) == ("guarded", {"phrase": "open sesame"}) + assert portrait_hole == { + "keyword": "portrait", + "description": "A round hole.\n", + "door": "portrait-hole", + "exit_info": "EXIT_FLAGS.NONE", + "rs_flags": 0, + "key": -1, + "destination": hall, + "kind": "guarded", + } + assert (trapdoor["door"], trapdoor["destination"]) == ("trapdoor", hall) + # A compass exit still makes its other side; a named exit has one side, and neither side copies a kind. + (south,) = area["rooms"][str(hall)]["exits"] + assert (south["door"], south["destination"]) == ("EXIT_DIRECTIONS.SOUTH", 100) + assert "kind" not in south + assert "with" not in south + porter = area["mobs"][str(vnum(built, "mobs", "porter"))] + assert (porter["kind"], porter["with"]) == ("routine", {"rounds": ["landing", "hall"]}) + assert area["objects"][str(vnum(built, "objects", "lamp"))]["properties"] == {"colour": "green"} + # The ROM part is untouched by the extensions: the model holds two rooms and the two compass exits. + assert [len(room.exits) for room in built.area.rooms.values()] == [1, 1] + + +def test_rom_format_refuses_what_a_rom_area_file_cannot_hold(tmp_path: Path) -> None: + kind = build_error(tmp_path / "kind", {"rooms.yaml": "rooms:\n hall:\n name: Hall\n kind: echoing\n"}) + named = build_error( + tmp_path / "named", {"rooms.yaml": "rooms:\n hall:\n name: Hall\n exits: {trapdoor: hall}\n"} + ) + + assert kind.key == "rooms.hall.kind" + assert "--format json" in kind.reason + assert named.key == "rooms.hall.exits.trapdoor" + assert "six directions" in named.reason + assert not (tmp_path / "kind" / "out.are").exists() + + +@pytest.mark.parametrize( + ("room", "key", "reason"), + [ + ("with: {decay: 3}", "rooms.hall.with", "name the `kind`"), + ("kind: Echoing Hall", "rooms.hall.kind", "lowercase slug"), + ("kind: echoing\n with: [3]", "rooms.hall.with", "expected a mapping"), + ("properties: {1: one}", "rooms.hall.properties.1", "text key"), + ("exits: {trapdoor: {to: hall, door: closed}}", "rooms.hall.exits.trapdoor.door", "unknown key"), + ("exits: {Trap Door: hall}", "rooms.hall.exits.Trap Door", "lowercase slug"), + ("exits: {trapdoor: {keyword: trap}}", "rooms.hall.exits.trapdoor.to", "is required"), + ], +) +def test_error_in_an_extension(tmp_path: Path, room: str, key: str, reason: str) -> None: + source = write_source(tmp_path / "source", {"rooms.yaml": f"rooms:\n hall:\n name: Hall\n {room}\n"}) + + with pytest.raises(SourceError) as caught: + authoring.build(source, tmp_path / "out.json", output_format="json") + + assert caught.value.key == key + assert reason in caught.value.reason + assert not (tmp_path / "out.json").exists() + + +def test_build_command_writes_json_beside_the_header_filename(tmp_path: Path, capsys, monkeypatch) -> None: + source = write_source(tmp_path / "source", EXTENDED) + monkeypatch.chdir(tmp_path) + + assert area_reader.cli.main(["build", str(source), "--format", "json"]) == 0 + + assert "source.json: 2 rooms, 1 mobs, 1 objects" in capsys.readouterr().out + assert json.loads((tmp_path / "source.json").read_text(encoding="utf-8"))["rooms"]["100"]["kind"] == "echoing"