diff --git a/README.md b/README.md index eac3a7c..4781dcd 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,476 @@ # Area Reader -A Python Library to parse MUD area files -This project reads area files from old MUDs and presents them as Python objects. -The returned objects all use the [Attrs](https://pypi.python.org/pypi/attrs) package so it is very easy to do stuff like render out the entire tree of objects as JSON or similar. -## Example Usage +A Python library to parse MUD area files into structured data. + +Reads area files from ROM, Merc, SMAUG, Envy, ROT, and CircleMUD and converts them to Python objects (attrs classes) that can be serialized to JSON. + +## Installation + +Requires Python 3.13+. + +```bash +uv sync +``` + +Or with pip: + +```bash +pip install attrs cattrs +``` + +## Quick Start + +```python +import area_reader + +# Load a ROM area file +af = area_reader.RomAreaFile('midgaard.are') +af.load_sections() + +# Access the parsed area +print(af.area.name) # "Midgaard" +print(af.area.rooms[3001].name) # "The Temple Of Mota" + +# Export to JSON +print(af.as_json(indent=2)) + +# Or get as a dict +data = af.as_dict() +``` + +## Supported Formats + +| Class | Format | Description | +|-------|--------|-------------| +| `RomAreaFile` | ROM 2.4 | Standard ROM format with 5 tilde-terminated strings for mobs | +| `MercAreaFile` | Merc 2.1 | Original Merc format with 4 tilde-terminated strings | +| `EnvyAreaFile` | Envy | Envy MUD format with level ranges in braces `{10 30}` | +| `RotAreaFile` | ROT | Realms of Thera format with extra mob flags | +| `SmaugAreaFile` | SMAUG | SMAUG format with MOBprogs and `|` end markers | +| `SmaugWdAreaFile` | SMAUG-WD | SMAUG variant with key-value area metadata | +| `CircleMudFile` | CircleMUD | Directory with `.wld`, `.mob`, `.obj`, `.zon`, `.shp` files | + +## Batch Conversion + +Convert all area files in a directory to JSON: + +```bash +# Basic conversion +python convert_all.py --areas-dir ../areas --output-dir ../json + +# With normalized output (unified schema across formats) +python convert_all.py --normalized --areas-dir ../areas --output-dir ../json + +# Tolerant mode (partial parsing for incompatible files) +python convert_all.py --tolerant --normalized --continue-on-error + +# Skip CircleMUD directories +python convert_all.py --skip-circlemud +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| `--areas-dir` | Directory containing `.are` files (default: `../areas`) | +| `--circlemud-dir` | Directory containing CircleMUD subdirectories (default: `../circleMUD`) | +| `--output-dir` | Output directory for JSON files (default: `../json`) | +| `--normalized`, `-n` | Output normalized JSON format | +| `--tolerant`, `-t` | Skip sections/items that fail to parse | +| `--skip-are` | Skip `.are` file conversion | +| `--skip-circlemud` | Skip CircleMUD directory conversion | +| `--continue-on-error` | Continue processing after errors | + +## Normalized Output + +The normalized format provides a unified schema across all MUD formats, making it easier to work with areas from different codebases. + +```python +# Get normalized output +af = area_reader.RomAreaFile('midgaard.are') +af.load_sections() + +# As dict +data = af.as_normalized_dict() + +# As JSON +json_str = af.as_normalized_json(indent=2) + +# Save to file +af.save_as_normalized_json('midgaard.normalized.json') +``` + +### Normalized vs Raw Output + +| Aspect | Raw (`as_dict()`) | Normalized (`as_normalized_dict()`) | +|--------|-------------------|-------------------------------------| +| Flags | Enum strings: `"ROM_ACT_TYPES.IS_NPC\|SENTINEL"` | List: `["npc", "sentinel"]` | +| AC | Format-specific (4 values or 1) | Always 4 values: `{pierce, bash, slash, exotic}` | +| Position | Format-specific (word or number) | Always word: `"standing"` | +| Sex | Format-specific (word or number) | Always word: `"male"` | +| Original data | Not preserved | Preserved in `original` field | + +### Normalized Mob Example + +```json +{ + "vnum": 3000, + "keywords": ["wizard"], + "short_desc": "the wizard", + "long_desc": "A wizard walks around behind the counter...", + "description": "The wizard looks old and senile...", + "level": 23, + "alignment": 900, + "sex": "male", + "race": "human", + "act_flags": ["npc", "sentinel", "nopurge"], + "affect_flags": ["invis"], + "hitroll": 0, + "ac": { + "pierce": -150, + "bash": -150, + "slash": -150, + "exotic": -150 + }, + "hit_dice": {"num": 1, "size": 1, "bonus": 999}, + "mana_dice": {"num": 1, "size": 1, "bonus": 999}, + "damage_dice": {"num": 1, "size": 8, "bonus": 32}, + "damage_type": "magic", + "gold": 750, + "position": {"default": "standing", "load": "standing"}, + "resistances": {"immune": ["summon", "charm"], "resist": [], "vuln": []}, + "body": {"form": [], "parts": [], "size": "medium"}, + "offense_flags": ["area_attack", "dodge"], + "programs": [], + "original": { ... } +} +``` + +## Tolerant Parsing + +Tolerant mode allows partial parsing when full parsing fails. This is useful for files with format variations or corruption. + +```python +af = area_reader.SmaugAreaFile('area.are') +af.load_sections(tolerant=True) + +# Check for parse errors +if af._parse_errors: + print(f"Warnings: {af._parse_errors}") +``` + +In tolerant mode: +- Failed sections are skipped with warnings logged +- Failed mobs/objects fall back to partial parsing (basic fields only) +- Parse errors are collected in `_parse_errors` list +- Partial objects have `_partial: true` in normalized output + +## CircleMUD Usage + +CircleMUD uses a split-file format where each zone has separate files for rooms, mobs, objects, etc. + ```python +from circlemud import CircleMudFile ->>> import area_reader ->>> area_file = area_reader.RomAreaFile('midgaard.are') ->>> area_file.load_area() ->>> area_file.area -RomArea(name='Midgaard', metadata='{ All } Diku Midgaard', original_filename='midgaard.are', first_vnum=3000, last_vnum=3399, ... ) +# Load a CircleMUD area directory +area = CircleMudFile('/path/to/zone_directory') +area.load_sections() +# Access the parsed area +print(area.area.name) +print(area.area.rooms['12001'].name) + +# Export to JSON (raw or normalized) +print(area.as_json(indent=2)) +print(area.as_normalized_json(indent=2)) +``` + +CircleMUD supports alphanumeric VNUMs (e.g., `QQ00`, `XX74`) which are preserved as strings. + +## Output Format (Raw) + +The `as_dict()` / `as_json()` output contains these top-level fields: + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Area name | +| `metadata` | string | Builder credits, level range | +| `original_filename` | string | Source filename | +| `first_vnum` | int | First vnum in range | +| `last_vnum` | int | Last vnum in range | +| `rooms` | dict | Room definitions keyed by vnum | +| `mobs` | dict | Mobile/NPC definitions keyed by vnum | +| `objects` | dict | Item definitions keyed by vnum | +| `resets` | array | Spawn instructions | +| `shops` | array | Shopkeeper definitions | +| `specials` | array | Special mob behaviors | +| `helps` | array | Help text entries | + +### Room Format + +```json +{ + "vnum": 3001, + "name": "The Temple Of Mota", + "description": "You are in the southern end of the temple...", + "room_flags": "ROM_ROOM_FLAGS.NO_MOB|INDOORS|LAW", + "sector_type": "SECTOR_TYPES.INSIDE", + "heal_rate": 100, + "mana_rate": 100, + "owner": null, + "exits": [ + { + "door": "EXIT_DIRECTIONS.NORTH", + "destination": 3054, + "description": "At the northern end...", + "keyword": "", + "exit_info": "EXIT_FLAGS.NONE", + "key": -1 + } + ], + "extra_descriptions": [ + { + "keyword": "plaque", + "description": "This entire world has been..." + } + ] +} +``` + +### Mobile (NPC) Format + +```json +{ + "vnum": 3000, + "name": "wizard", + "short_desc": "the wizard", + "long_desc": "A wizard walks around behind the counter...", + "description": "The wizard looks old and senile...", + "race": "human", + "level": 23, + "alignment": 900, + "sex": "male", + "size": "medium", + "hit": { "number": 1, "sides": 1, "bonus": 999 }, + "mana": { "number": 1, "sides": 1, "bonus": 999 }, + "damage": { "number": 1, "sides": 8, "bonus": 32 }, + "damtype": "magic", + "hitroll": 0, + "ac": { + "pierce": -150, + "bash": -150, + "slash": -150, + "exotic": -150 + }, + "act": "ROM_ACT_TYPES.IS_NPC|SENTINEL|NOPURGE", + "affected_by": "AFFECTED_BY.INVIS", + "off_flags": "OFFENSE.AREA_ATTACK|DODGE", + "imm_flags": "IMM_FLAGS.SUMMON|CHARM|MAGIC|WEAPON", + "res_flags": "IMM_FLAGS.NONE", + "vuln_flags": "IMM_FLAGS.NONE", + "form": "FORMS.NONE", + "parts": "PARTS.NONE", + "start_pos": "stand", + "default_pos": "stand", + "wealth": 750, + "material": "0", + "mprogs": [] +} +``` + +Dice rolls (hit, mana, damage) use `NdS+B` format: roll `number` dice with `sides` sides, add `bonus`. + +### Object (Item) Format + +```json +{ + "vnum": 3000, + "name": "barrel beer", + "short_desc": "a barrel of beer", + "description": "A beer barrel has been left here.", + "item_type": "drink", + "material": "wood", + "level": 0, + "weight": 160, + "cost": 75, + "condition": 100, + "wear_flags": "WEAR_FLAGS.TAKE", + "extra_flags": 0, + "value": [300, 300, "beer", 0, 0], + "affected": [], + "extra_descriptions": [] +} +``` + +The `value` array meaning depends on `item_type`: +- **weapon**: [weapon_class, num_dice, dice_sides, attack_type, special_flags] +- **container**: [capacity, flags, key_vnum, max_weight, weight_multiplier] +- **drink/fountain**: [current, max, liquid_type, poisoned, unused] +- **wand/staff**: [level, max_charges, charges_left, spell, unused] +- **potion/pill/scroll**: [level, spell1, spell2, spell3, spell4] +- **other**: [v0, v1, v2, v3, v4] (type-specific) + +### Reset Format + +Resets define what spawns where when the area repopulates. + +```json +{ + "command": "M", + "arg1": 3011, + "arg2": 1, + "arg3": 3001, + "arg4": 0, + "comment": "* Hassan" +} +``` + +| Command | Description | arg1 | arg2 | arg3 | arg4 | +|---------|-------------|------|------|------|------| +| M | Load mobile | mob_vnum | limit | room_vnum | - | +| O | Load object | obj_vnum | limit | room_vnum | - | +| P | Put obj in obj | obj_vnum | limit | container_vnum | - | +| G | Give obj to mob | obj_vnum | limit | - | - | +| E | Equip obj on mob | obj_vnum | limit | wear_location | - | +| D | Set door state | room_vnum | direction | state | - | +| R | Randomize exits | room_vnum | num_exits | - | - | + +### Shop Format + +```json +{ + "keeper": 3000, + "buy_type": [2, 3, 4, 10, 0], + "profit_buy": 105, + "profit_sell": 15, + "open_hour": 0, + "close_hour": 23 +} +``` + +- `keeper`: vnum of the shopkeeper mob +- `buy_type`: item types the shop will purchase (0 = slot unused) +- `profit_buy`: markup percentage when player buys +- `profit_sell`: percentage of value when player sells + +### Special Format + +Assigns special behavior functions to mobs. + +```json +{ + "command": "M", + "arg1": 3000, + "arg2": "spec_cast_mage", + "comment": "* the wizard" +} +``` + +Common spec functions: `spec_cast_mage`, `spec_cast_cleric`, `spec_thief`, `spec_executioner`, `spec_fido`, `spec_janitor`, `spec_poison`, `spec_breath_*` + +## CircleMUD Output Format + +CircleMUD areas have a slightly different structure: + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Zone name | +| `zone_vnum` | string | Zone identifier (may be alphanumeric) | +| `rooms` | dict | Room definitions keyed by vnum | +| `mobs` | dict | Mobile definitions keyed by vnum | +| `objects` | dict | Object definitions keyed by vnum | +| `zone` | object | Zone metadata and resets | +| `shops` | array | Shop definitions | + +### CircleMUD Mobile Format + +```json +{ + "vnum": "12000", + "aliases": ["judge", "adjudicator"], + "short_desc": "The adjudicator", + "long_desc": "The adjudicator is watching the games intently.", + "description": "The adjudicator is a retired gladiator...", + "action_flags": 2, + "affect_flags": 128, + "alignment": 100, + "mob_type": "S", + "level": 12, + "thac0": 9, + "ac": 2, + "hit_dice": { "number": 3, "sides": 7, "bonus": 143 }, + "damage_dice": { "number": 2, "sides": 7, "bonus": 1 }, + "gold": 1200, + "xp": 13000, + "load_position": 8, + "default_position": 8, + "sex": 1, + "extra_specs": {} +} +``` + +### CircleMUD Object Format + +```json +{ + "vnum": "12000", + "aliases": ["ring", "pewter"], + "short_desc": "a pewter ring", + "long_desc": "There is a pewter ring lying here.", + "item_type": 9, + "extra_flags": 96, + "wear_flags": 3, + "values": ["-3", "0", "0", "0", "0"], + "weight": 1, + "cost": 10000, + "rent": 5000, + "affects": [ + { "location": 13, "modifier": 10 }, + { "location": 2, "modifier": -1 } + ], + "extra_descriptions": [] +} +``` + +Key differences from ROM format: +- VNUMs are strings (supports alphanumeric like `QQ00`) +- Uses `thac0` instead of `hitroll` (lower is better) +- Single `ac` value instead of four damage types +- Objects have `rent` cost field +- Flags are numeric bitvectors instead of enum names + +## Flag Values + +Flags are serialized as pipe-separated enum names (e.g., `"ROM_ACT_TYPES.IS_NPC|SENTINEL|NOPURGE"`). + +See `constants.py` for all flag definitions including: +- `ROM_ACT_TYPES` / `MERC_ACT_TYPES` - mob behavior flags +- `AFFECTED_BY` - spell/status effects +- `WEAR_FLAGS` - equipment slots +- `ROM_ROOM_FLAGS` / `MERC_ROOM_FLAGS` - room properties +- `EXIT_FLAGS` - door states +- `OFFENSE` - combat behaviors +- `IMM_FLAGS` - immunities/resistances/vulnerabilities +- `FORMS` - body type +- `PARTS` - body parts + +## Running Tests + +```bash +uv run pytest +``` + +## Architecture + +``` +area_reader/ +├── area_reader.py # Main parser classes (RomAreaFile, MercAreaFile, etc.) +├── circlemud.py # CircleMUD parser +├── constants.py # Flag enums and constants +├── normalized.py # Normalized data classes +├── normalizer.py # Format-specific normalizers +├── convert_all.py # Batch conversion script +└── test/ # Test area files + ├── rom/ + └── merc/ ``` diff --git a/area_reader.py b/area_reader.py index 64685ff..520a9d8 100644 --- a/area_reader.py +++ b/area_reader.py @@ -48,6 +48,8 @@ def __init__(self, filename): self.file = io.open(filename, mode='rt', encoding='ascii') self.index = 0 self.data = self.file.read() + # Normalize Windows CRLF to Unix LF + self.data = self.data.replace('\r\n', '\n').replace('\r', '\n') self.filename = filename self.file.close() area_type = self.area_type or RomArea @@ -93,12 +95,26 @@ def read_number(self): self.skip_whitespace() while self.current_char.isspace(): self.advance() + # Handle quoted empty string ('' or "") as 0 - used for "no key" in containers + if self.current_char in ("'", '"'): + quote = self.current_char + self.advance() + # Skip any content and closing quote + while self.current_char != quote: + self.advance() + self.advance() # skip closing quote + return 0 number = 0 sign = False while self.current_char.isspace(): self.advance() + # Handle +, -, or +- (plus followed by minus) if self.current_char == '+': self.advance() + # Check for +- pattern (negative number after plus) + if self.current_char == '-': + sign = True + self.advance() elif self.current_char == '-': sign = True self.advance() @@ -112,11 +128,45 @@ def read_number(self): number += self.read_number() return number + def read_dice_or_number(self): + """Read either a dice expression (NdN+N) or a simple number. + Returns a Dice object. Simple numbers become Dice(0, 0, number).""" + self.skip_whitespace() + start_index = self.index + # Read the first number + number = self.read_number() + # Check if next char is 'd' or 'D' (dice expression) + if self.current_char.lower() == 'd': + self.advance() # consume 'd' + sides = self.read_number() + bonus = self.read_number() + return Dice(number=number, sides=sides, bonus=bonus) + else: + # Simple number - return as Dice(0, 0, number) + return Dice(number=0, sides=0, bonus=number) + def read_to_eol(self): return self.read_until('\n') + def read_to_blank_line(self): + """Read until a blank line (double newline). Used for multi-line descriptions.""" + lines = [] + while True: + line = self.read_to_eol() + if self.current_char == '\n': + self.advance() # Skip the newline + # Check if next line is blank (or we're at section marker) + if not line.strip() or self.current_char == '#' or self.current_char == '$': + break + lines.append(line) + return '\n'.join(lines) + def read_until(self, endchar): ahead = self.data.find(endchar, self.index) + if ahead == -1: + # Terminator not found - report error with context + context = self.data[self.index:self.index+50] + self.parse_fail(f"Missing '{endchar}' terminator, starting at: {context!r}") result = self.data[self.index:ahead] self.index = ahead return result @@ -135,6 +185,14 @@ def skip_whitespace(self): def read_flag(self): negative = False self.skip_whitespace() + # Handle quoted empty string as 0 + if self.current_char in ("'", '"'): + quote = self.current_char + self.advance() + while self.current_char != quote: + self.advance() + self.advance() # skip closing quote + return 0 if self.current_char == '+': self.advance() if self.current_char == '-': @@ -164,11 +222,67 @@ def read_and_verify_letter(self, verification): def load_vnum_section(self, section_object_type): + tolerant = getattr(self, '_tolerant', False) while True: vnum = self.read_vnum() if vnum == 0: break - yield self.read_object(section_object_type, vnum=vnum) + if tolerant: + start_index = self.index + try: + yield self.read_object(section_object_type, vnum=vnum) + except Exception as e: + logger.warning(f"Full parse failed for {section_object_type.__name__} vnum {vnum}: {e}") + # Try fallback partial parsing + self.index = start_index # Reset to start of this item + partial = self._try_partial_parse(section_object_type, vnum, str(e)) + if partial: + yield partial + else: + if hasattr(self, '_parse_errors'): + self._parse_errors.append(f"Skipped {section_object_type.__name__} {vnum}: {e}") + self._skip_to_next_vnum() + else: + yield self.read_object(section_object_type, vnum=vnum) + + def _try_partial_parse(self, section_object_type, vnum, error_msg): + """Try to parse basic fields (name, descriptions) when full parse fails.""" + try: + # All MUD formats start with tilde-terminated strings for names/descriptions + name = self.read_string() # keywords/name + short_desc = self.read_string() # short description + long_desc = self.read_string() # long description + description = self.read_string() # full description + + # Skip to next vnum + self._skip_to_next_vnum() + + # Return a partial object with what we could parse + return PartialMudObject( + vnum=vnum, + name=name, + short_desc=short_desc, + long_desc=long_desc, + description=description, + _parse_error=error_msg, + _object_type=section_object_type.__name__ + ) + except Exception as e2: + logger.warning(f"Partial parse also failed for vnum {vnum}: {e2}") + self._skip_to_next_vnum() + return None + + def _skip_to_next_vnum(self): + """Skip content until next #vnum or #0 (end of section).""" + while self.index < len(self.data) - 1: + if self.current_char == '#': + # Check if this is a vnum or section header + next_idx = self.index + 1 + if next_idx < len(self.data): + next_char = self.data[next_idx] + if next_char.isdigit() or next_char.isupper() or next_char == '$': + return # Found next item or section + self.advance() def read_object(self, object_type, **kwargs): if hasattr(object_type, 'read'): @@ -186,6 +300,9 @@ def read_object_by_fields(self, object_type, **kwargs): continue if issubclass(field_type, enum.IntFlag): field_type = enum.IntFlag + elif issubclass(field_type, int) and field_type is not int: + # Handle int subclasses like VNum + field_type = int reader = self.readers.get(field_type) if reader is None: self.parse_fail("Could not find a reader for field type %r" % field_type) @@ -200,6 +317,21 @@ def read_object_by_fields(self, object_type, **kwargs): def read_vnum(self): self.skip_whitespace() + tolerant = getattr(self, '_tolerant', False) + if tolerant and self.current_char != '#': + # In tolerant mode, skip forward to find the next # marker + self._skip_to_next_vnum() + if self.index >= len(self.data) - 1: + return 0 # End of file + # Peek ahead to check if this is a section header (e.g. #OBJECTS) + # Don't consume the # if it's followed by an uppercase letter + if self.current_char == '#': + next_idx = self.index + 1 + if next_idx < len(self.data): + next_char = self.data[next_idx] + if next_char.isupper() or next_char == '$': + # This is a section header or EOF, not a vnum + return 0 self.read_and_verify_letter('#') vnum = self.read_number() return vnum @@ -221,9 +353,17 @@ def read_area_metadata(self): def load_economy(self): raise NotImplementedError - def load_sections(self): + def load_sections(self, tolerant=False): + """Load all sections from the area file. + + Args: + tolerant: If True, skip sections that fail to parse and continue. + Partial data will be available. Errors are logged as warnings. + """ + self._tolerant = tolerant readers = { 'area': self.read_area_metadata, + 'areadata': self.load_areadata, 'mobiles': self.load_mobiles, 'rooms': self.load_rooms, 'objects': self.load_objects, @@ -232,7 +372,12 @@ def load_sections(self): 'shops': self.load_shops, 'specials': self.load_specials, 'economy': self.load_economy, + 'resetmsg': self.load_resetmsg, + 'author': self.load_author, + 'ranges': self.load_ranges, + 'flags': self.load_flags, } + self._parse_errors = [] # Track errors in tolerant mode while True: section_name = self.read_section_name() self.current_section_name = section_name @@ -245,12 +390,70 @@ def load_sections(self): logger.info("Processing section %s" % section_name) try: readers[section_name]() - except Exception: - self.parse_fail("Error reading section %r" % section_name) + except Exception as e: + if tolerant: + error_msg = f"Error reading section {section_name!r}: {e}" + logger.warning(error_msg) + self._parse_errors.append(error_msg) + self.skip_section(section_name) + else: + self.parse_fail("Error reading section %r" % section_name) + + def load_areadata(self): + """Default: skip areadata if not implemented.""" + self.skip_section('areadata') + + def load_resetmsg(self): + """Default: skip resetmsg if not implemented.""" + self.read_string() + + def load_author(self): + """Default: skip author if not implemented.""" + self.read_string() + + def load_ranges(self): + """Default: skip ranges if not implemented.""" + while True: + self.skip_whitespace() + if self.current_char == '$': + self.advance() + break + elif self.current_char == '#': + break + self.read_to_eol() + + def load_flags(self): + """Default: skip flags section - read until next section.""" + while True: + self.skip_whitespace() + if self.current_char == '#' or self.current_char == '$': + break + self.read_to_eol() def skip_section(self, section_name): + """Skip to the next section header (#SECTIONNAME or $ EOF marker). + + Handles VNUM sections (#1234) by continuing past them until a real + section header is found (identified by uppercase letters after #). + """ logger.debug("Skipping section %s", section_name) - self.read_until('#') + while self.index < len(self.data) - 1: + self.read_until('#') + if self.index >= len(self.data) - 1: + break + # Peek at what's after the # + next_char = self.data[self.index + 1] if self.index + 1 < len(self.data) else '' + if next_char == '$' or next_char.isupper(): + # This is a section header or EOF marker, stop here + break + elif next_char.isdigit() or next_char == '0': + # This is a VNUM (#1234), skip past it + self.advance() # skip the # + continue + else: + # Unknown, skip past it + self.advance() + continue def read_section_name(self): self.read_and_verify_letter('#') @@ -324,6 +527,27 @@ def save_as_json(self): with open(fname, 'w') as f: json.dump(self.as_dict(), f, indent=2) + def as_normalized(self): + """Return normalized area data.""" + from normalizer import AreaNormalizer + return AreaNormalizer(self).normalize() + + def as_normalized_dict(self): + """Return normalized area as dictionary.""" + from normalizer import NormalizedConverter + return NormalizedConverter().unstructure(self.as_normalized()) + + def as_normalized_json(self, indent=None): + """Return normalized area as JSON string.""" + return json.dumps(self.as_normalized_dict(), indent=indent) + + def save_as_normalized_json(self, filepath=None): + """Save normalized area to JSON file.""" + if filepath is None: + filepath = os.path.splitext(self.filename)[0] + '.normalized.json' + with open(filepath, 'w') as f: + json.dump(self.as_normalized_dict(), f, indent=2) + class RomAreaFile(AreaFile): def load_mobiles(self): @@ -354,6 +578,22 @@ class MudBase(object): description = field(default='', type=str) extra_descriptions = attr(default=Factory(list), type=List[ExtraDescription]) +@attributes +class PartialMudObject(object): + """Partially parsed mob/object when full parsing fails. + + Contains basic fields that are consistent across formats: + vnum, name, short_desc, long_desc, description. + """ + vnum = attr(default=0, type=int) + name = attr(default='', type=str) + short_desc = attr(default='', type=str) + long_desc = attr(default='', type=str) + description = attr(default='', type=str) + _parse_error = attr(default='', type=str) + _object_type = attr(default='', type=str) + _partial = attr(default=True, type=bool) # Flag to indicate this is partial data + @attributes class Item(MudBase): short_desc = field(default='', type=str) @@ -378,8 +618,8 @@ class MercAffectData(object): class RomItem(Item): @staticmethod - def convert_condition(letter): - condition = -1 + def convert_condition(value): + # Handle both letter conditions (P/G/A/W/D/B/R) and numeric conditions conditions = { 'P': 100, 'G': 90, @@ -389,8 +629,13 @@ def convert_condition(letter): 'B': 10, 'R': 0, } - condition = conditions[letter] - return condition + if value in conditions: + return conditions[value] + # Numeric condition - some variants use numbers directly + try: + return int(value) + except (ValueError, TypeError): + return 100 # Default to perfect condition material = field(default='', type=str) condition = field(default=100, type=int, original_type=Letter, on_read=convert_condition) @@ -420,7 +665,12 @@ 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()) + # Condition can be letter (P/G/A/W/D/B/R) or number in some variants + reader.skip_whitespace() + if reader.current_char.isdigit() or reader.current_char == '-': + condition = reader.read_number() + else: + condition = cls.convert_condition(reader.read_letter()) affected = [] extra_descriptions = [] while True: @@ -678,6 +928,9 @@ def read(cls, reader, vnum): sector_type = reader.read_number() if sector_type == -1: sector_type = 0 + # SMAUG rooms may have extra values on this line (tele_delay, tele_vnum, tunnel, etc.) + # Skip to end of line to ignore them + reader.read_to_eol() room = cls(vnum=vnum, name=name, description=description, area_number=area_number, room_flags=room_flags, sector_type=sector_type) room.read_metadata(reader) return room @@ -699,6 +952,10 @@ def read_metadata(self, reader): self.extra_descriptions.append(reader.read_object(ExtraDescription)) elif letter == 'O': self.owner = reader.read_string() + elif letter == '>': + # SMAUG room prog - skip until | terminator + reader.read_until('|') + reader.advance() # skip the | else: reader.parse_fail("Don't know how to process room attribute: %s" % letter) @@ -720,12 +977,14 @@ def read(cls, reader, letter): arg2 = reader.read_number() if letter == 'G' or letter == 'R': arg3 = 0 - else: - arg3 = reader.read_number() - if letter == 'P' or letter == 'M': arg4 = 0 else: - arg4 = reader.read_number() + arg3 = reader.read_number() + # M and P resets have a 4th argument (room_limit for M, container_limit for P) + if letter == 'M' or letter == 'P': + arg4 = reader.read_number() + else: + arg4 = 0 reader.index -= 1 comment = reader.read_to_eol() return cls(command=command, arg1=arg1, arg2=arg2, arg3=arg3, arg4=arg4, comment=comment) @@ -791,6 +1050,177 @@ class RomShop(object): class SmaugMob(RomMob): affected_by = attr(default=0, type=SMAUG_AFFECTED_BY, converter=SMAUG_AFFECTED_BY) + @classmethod + def read(cls, reader, vnum, **kwargs): + """Read SMAUG mob format (different from ROM format). + + SMAUG format: + - 4 tilde-terminated strings (no race string) + - 8 lines of numeric stats (no tildes) + - Optional MOBprogs (> type~code~) + - End marker: | + """ + logger.debug("Reading SMAUG mob %d" % vnum) + name = reader.read_string() + short_desc = reader.read_string() + long_desc = reader.read_string() + description = reader.read_string() + + # Line 1: act affected alignment mob_type + act = SMAUG_AFFECTED_BY(reader.read_flag()) + affected_by = reader.read_flag() + alignment = reader.read_number() + mob_type = reader.read_letter() # S, W, C, etc. + + # Line 2: level hitroll hitdice damdice + level = reader.read_number() + hitroll = reader.read_number() + # Read hitdice but we don't have a damage bonus in this format + reader.read_number() # thac0 or unused + hit = Dice.read(reader=reader) + damage = Dice.read(reader=reader) + + # Line 3: gold exp (or gold 0) + wealth = reader.read_number() + reader.read_number() # exp or unused + + # Line 4: start_pos default_pos sex + start_pos = reader.read_number() + default_pos = reader.read_number() + sex = reader.read_number() + + # Line 5: 7 AC values (or variations) + # Just read the rest of the line + reader.read_to_eol() + + # Lines 6-8: various flags and stats + reader.read_to_eol() # line 6 + reader.read_to_eol() # line 7 + reader.read_to_eol() # line 8 + + # Read MOBprogs until | marker + mprogs = [] + while True: + reader.skip_whitespace() + if reader.current_char == '|': + reader.advance() + break + if reader.current_char == '>': + reader.advance() + reader.skip_whitespace() + prog_type = reader.read_word() + prog_arg = reader.read_string() # args~ + prog_code = reader.read_string() # code~ + mprogs.append({'type': prog_type, 'arg': prog_arg, 'code': prog_code}) + elif reader.current_char == '#': + # Hit next vnum or section, stop here + break + else: + # Unknown content, skip line + reader.read_to_eol() + + # Convert numeric positions to words + pos_map = {0: 'dead', 1: 'mortallywounded', 2: 'incapacitated', + 3: 'stunned', 4: 'sleeping', 5: 'resting', 6: 'sitting', + 7: 'fighting', 8: 'standing'} + sex_map = {0: 'none', 1: 'male', 2: 'female'} + + return cls( + vnum=vnum, + name=name, + short_desc=short_desc, + long_desc=long_desc, + description=description, + race='', # SMAUG doesn't have race in same format + act=act, + affected_by=affected_by, + alignment=alignment, + level=level, + hitroll=hitroll, + hit=hit, + damage=damage, + wealth=wealth, + start_pos=pos_map.get(start_pos, 'standing'), + default_pos=pos_map.get(default_pos, 'standing'), + sex=sex_map.get(sex, 'none'), + mprogs=mprogs, + ) + + +@attributes +class RotMob(RomMob): + """ROT (Realms of Thera) mob format - has 5 values after race instead of 4.""" + extra_flag = attr(default=0, type=int) + + @classmethod + def read(cls, reader, vnum, **kwargs): + logger.debug("Reading ROT mob %d" % vnum) + name = reader.read_string() + short_desc = reader.read_string() + long_desc = reader.read_string() + description = reader.read_string() + race = reader.read_string() + # ROT format: act affected extra_flag alignment group (5 values) + act = ROM_ACT_TYPES(reader.read_flag()) | ROM_ACT_TYPES.IS_NPC + affected_by = reader.read_flag() + extra_flag = reader.read_flag() # Extra flag field in ROT + alignment = reader.read_number() + group = reader.read_number() + level = reader.read_number() + hitroll = reader.read_number() + hit = Dice.read(reader=reader) + mana = Dice.read(reader=reader) + damage = Dice.read(reader=reader) + damtype = reader.read_word() + ac = reader.read_object(RomArmorClass) + # ROT uses letter flags for off/imm/res/vuln + off_flags = reader.read_flag() + imm_flags = reader.read_flag() + res_flags = reader.read_flag() + vuln_flags = reader.read_flag() + start_pos = reader.read_word() + default_pos = reader.read_word() + sex = reader.read_word() + wealth = int(reader.read_number() / 20) + form = reader.read_flag() + parts = reader.read_flag() + size = reader.read_word() + material = reader.read_word() + mprogs = [] + while True: + letter = reader.read_letter() + if letter == 'F': + word = reader.read_word() + vector = reader.read_flag() + if word.startswith('act'): + act = remove_bit(act, vector) + elif word.startswith('aff'): + affected_by = remove_bit(affected_by, vector) + elif word.startswith('off'): + off_flags = remove_bit(off_flags, vector) + elif word.startswith('imm'): + imm_flags = remove_bit(imm_flags, vector) + elif word.startswith('res'): + res_flags = remove_bit(res_flags, vector) + elif word.startswith('vul'): + vuln_flags = remove_bit(vuln_flags, vector) + elif word.startswith('for'): + form = remove_bit(form, vector) + elif word.startswith('par'): + parts = remove_bit(parts, vector) + elif letter == 'M': + mprogs.append(reader.read_object_by_fields(RomMobprog)) + else: + reader.index -= 1 + break + return cls(vnum=vnum, name=name, short_desc=short_desc, long_desc=long_desc, description=description, + race=race, act=act, affected_by=affected_by, extra_flag=extra_flag, alignment=alignment, + group=group, level=level, hitroll=hitroll, hit=hit, mana=mana, damage=damage, damtype=damtype, + ac=ac, off_flags=off_flags, imm_flags=imm_flags, res_flags=res_flags, vuln_flags=vuln_flags, + start_pos=start_pos, default_pos=default_pos, sex=sex, wealth=wealth, form=form, parts=parts, + size=size, material=material, mprogs=mprogs) + + @attributes class SmaugArea(RomArea): resetmsg = attr(default='', type=str) @@ -825,13 +1255,16 @@ def read(cls, reader, letter): arg2 = reader.read_number() if letter == 'G' or letter == 'R': arg3 = 0 - else: - arg3 = reader.read_number() - if letter == 'P' or letter == 'M': arg4 = 0 + arg5 = 0 else: - arg4 = reader.read_number() - arg5 = reader.read_number() + arg3 = reader.read_number() + # M and P resets have a 4th argument + if letter == 'M' or letter == 'P': + arg4 = reader.read_number() + else: + arg4 = 0 + arg5 = 0 reader.index -= 1 comment = reader.read_to_eol() return cls(command=command, arg1=arg1, arg2=arg2, arg3=arg3, arg4=arg4, arg5=arg5, comment=comment) @@ -839,6 +1272,7 @@ def read(cls, reader, letter): @attributes class MercMob(RomMob): act = field(default=MERC_ACT_TYPES.IS_NPC.value, type=MERC_ACT_TYPES, converter=MERC_ACT_TYPES) + ac = attr(default=0, type=int) # Merc uses single int AC, not 4-value RomArmorClass @classmethod def read(cls, reader, vnum): @@ -918,6 +1352,299 @@ def load_resets(self): def read_area_metadata(self): self.area.metadata = self.read_string() + +class RotAreaFile(RomAreaFile): + """ROT (Realms of Thera) format - uses 5-value mob header.""" + + def load_mobiles(self): + for mob in self.load_vnum_section(RotMob): + setitem(self.area.mobs, mob.vnum, mob) + + +@attributes +class EnvyMob(RomMob): + """Envy MUD mob format - like ROM but with S mob type letter and numeric dam_type.""" + dam_type_num = attr(default=0, type=int) + + @classmethod + def read(cls, reader, vnum, **kwargs): + logger.debug("Reading Envy mob %d" % vnum) + name = reader.read_string() + short_desc = reader.read_string() + long_desc = reader.read_string() + description = reader.read_string() + race = reader.read_string() + # Envy: act affected alignment MOB_TYPE(S) or group_number + act = ROM_ACT_TYPES(reader.read_flag()) | ROM_ACT_TYPES.IS_NPC + affected_by = reader.read_flag() + alignment = reader.read_number() + # Some variants have S letter, others have group number + reader.skip_whitespace() + if reader.current_char == 'S': + reader.advance() # consume S + group = 0 + else: + group = reader.read_number() # read group number + # level hitroll hit_dice mana_or_bonus dam_dice dam_type_num + # Note: Some Envy files have 3 dice (hit, mana, dam), others have number instead of mana + level = reader.read_number() + hitroll = reader.read_number() + hit = reader.read_dice_or_number() + mana = reader.read_dice_or_number() # can be dice or number + damage = reader.read_dice_or_number() + dam_type_num = reader.read_number() + # 4-value AC + ac = reader.read_object(RomArmorClass) + # off imm res vuln flags + off_flags = reader.read_flag() + imm_flags = reader.read_flag() + res_flags = reader.read_flag() + vuln_flags = reader.read_flag() + # start_pos default_pos sex wealth + start_pos = reader.read_number() + default_pos = reader.read_number() + sex = reader.read_number() + # Wealth can sometimes be a dice expression in malformed files + reader.skip_whitespace() + if reader.current_char.isdigit() or reader.current_char == '-': + wealth_str = reader.read_word() + # Try to parse as number, otherwise take numeric prefix + try: + wealth = int(wealth_str) + except ValueError: + # Extract leading digits + import re + match = re.match(r'-?\d+', wealth_str) + wealth = int(match.group()) if match else 0 + else: + wealth = 0 + # form parts size material + form = reader.read_flag() + parts = reader.read_flag() + size = reader.read_word() + material = reader.read_word() + return cls(vnum=vnum, name=name, short_desc=short_desc, long_desc=long_desc, + description=description, race=race, act=act, affected_by=affected_by, + alignment=alignment, level=level, hitroll=hitroll, hit=hit, mana=mana, + damage=damage, dam_type_num=dam_type_num, ac=ac, off_flags=off_flags, + imm_flags=imm_flags, res_flags=res_flags, vuln_flags=vuln_flags, + start_pos=start_pos, default_pos=default_pos, sex=sex, wealth=wealth, + form=form, parts=parts, size=size) + + +@attributes +class EnvyItem(Item): + """Envy MUD object format.""" + material = attr(default='', type=str) + condition = attr(default='', type=str) + + @classmethod + def read(cls, reader, vnum): + logger.debug("Reading Envy object %d" % vnum) + name = reader.read_string() + short_desc = reader.read_string() + description = reader.read_string() + material = reader.read_string() + item_type = reader.read_number() + extra_flags = reader.read_flag() + wear_flags = reader.read_flag() + value = [reader.read_number() for _ in range(5)] + weight = reader.read_number() + level = reader.read_number() + cost = reader.read_number() + condition = reader.read_letter() + affected = [] + extra_descriptions = [] + while True: + letter = reader.read_letter() + if letter == 'A': + loc = reader.read_number() + mod = reader.read_number() + affected.append({'location': loc, 'modifier': mod}) + elif letter == 'E': + keyword = reader.read_string() + # Envy extra descriptions can be multi-line, ending at blank line + # Skip the newline after keyword before reading description + if reader.current_char == '\n': + reader.advance() + desc = reader.read_to_blank_line() + extra_descriptions.append(ExtraDescription(keyword=keyword, description=desc)) + elif letter == '#' or letter == '$': + reader.index -= 1 + break + else: + reader.index -= 1 + break + return cls(vnum=vnum, name=name, short_desc=short_desc, description=description, + material=material, item_type=item_type, extra_flags=extra_flags, + wear_flags=wear_flags, value=value, weight=weight, level=level, + cost=cost, condition=condition, affected=affected, + extra_descriptions=extra_descriptions) + + +@attributes +class EnvyRoom(Room): + """Envy MUD room format.""" + area_flags = attr(default=0, type=int) + + @classmethod + def read(cls, reader, vnum): + logger.debug("Reading Envy room %d" % vnum) + name = reader.read_string() + description = reader.read_string() + area_flags = reader.read_number() + room_flags = reader.read_flag() + sector_type = reader.read_number() + exits = OrderedDict() + extra_descriptions = [] + while True: + letter = reader.read_letter() + if letter == 'S': + break + elif letter == 'D': + door = reader.read_number() + exit_desc = reader.read_string() + keyword = reader.read_string() + exit_info = reader.read_number() + key = reader.read_number() + destination = reader.read_number() + exits[door] = Exit(door=door, description=exit_desc, + keyword=keyword, exit_info=exit_info, key=key, destination=destination) + elif letter == 'E': + keyword = reader.read_string() + # Room extra descriptions use standard ~ terminator + desc = reader.read_string() + extra_descriptions.append(ExtraDescription(keyword=keyword, description=desc)) + elif letter == 'S': + # S marks end of room + break + elif letter == '#' or letter == '$': + reader.index -= 1 + break + else: + reader.index -= 1 + break + return cls(vnum=vnum, name=name, description=description, area_flags=area_flags, + room_flags=room_flags, sector_type=sector_type, exits=list(exits.values()), + extra_descriptions=extra_descriptions) + + +class EnvyAreaFile(AreaFile): + """Envy MUD format - Merc derivative with extended mob/obj/room formats.""" + area_type = MercArea + + def read_area_metadata(self): + # Envy can have multiple AREA formats: + # 1. Single string: {levels} Author Name~ + # 2. ROM-style: filename~ name~ metadata~ first_vnum last_vnum + first_str = self.read_string() + self.skip_whitespace() + # Check if next char starts another tilde-string or is a digit/section marker + if self.current_char == '#' or self.current_char == '$': + # Single string format + self.area.metadata = first_str + elif self.current_char.isdigit(): + # Could be vnum range or next section - likely single string format + # Try to read two numbers for vnum range + self.area.metadata = first_str + try: + self.area.first_vnum = self.read_number() + self.area.last_vnum = self.read_number() + except: + pass # Not vnum range, just metadata + else: + # ROM-style multi-string format + self.area.original_filename = first_str + 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() + + def load_mobiles(self): + for mob in self.load_vnum_section(EnvyMob): + setitem(self.area.mobs, mob.vnum, mob) + + def load_objects(self): + for obj in self.load_vnum_section(EnvyItem): + setitem(self.area.objects, obj.vnum, obj) + + def load_rooms(self): + for room in self.load_vnum_section(EnvyRoom): + setitem(self.area.rooms, room.vnum, room) + + def load_resets(self): + while True: + letter = self.read_letter() + if letter == 'S' or letter == '$': + break + if letter == '*': + self.read_to_eol() + continue + if letter in ('M', 'O', 'P', 'G', 'E', 'D', 'R'): + reset = MercReset.read(self, letter) + self.area.resets.append(reset) + else: + self.read_to_eol() + + def load_shops(self): + while True: + self.skip_whitespace() + keeper = self.read_number() + if keeper == 0: + break + buy_types = [self.read_number() for _ in range(self.MAX_TRADES)] + profit_buy = self.read_number() + profit_sell = self.read_number() + open_hour = self.read_number() + close_hour = self.read_number() + self.read_to_eol() + self.area.shops.append({ + 'keeper': keeper, + 'buy_type': buy_types, + 'profit_buy': profit_buy, + 'profit_sell': profit_sell, + 'open_hour': open_hour, + 'close_hour': close_hour + }) + + def load_specials(self): + while True: + self.skip_whitespace() + if self.index >= len(self.data): + break + letter = self.read_letter() + if letter == 'S' or letter == '$' or letter == '#': + if letter == '#': + self.index -= 1 + break + if letter == '*': + self.read_to_eol() + continue + if letter == 'M': + vnum = self.read_number() + spec_fun = self.read_word() + self.area.specials.append({'mob_vnum': vnum, 'spec_fun': spec_fun}) + self.read_to_eol() + elif letter == 'D': + # Some files have Door resets in SPECIALS section + self.read_to_eol() + else: + self.read_to_eol() + + def load_economy(self): + # Skip economy section if present + self.read_to_eol() + + def load_helps(self): + while True: + level = self.read_number() + keyword = self.read_string() + if keyword.startswith('$'): + break + text = self.read_string() + self.area.helps.append(Help(level=level, keyword=keyword, text=text)) + + class SmaugAreaFile(RomAreaFile): area_type = SmaugArea @@ -932,6 +1659,109 @@ def load_economy(self): self.area.high_economy = self.read_number() self.area.low_economy = self.read_number() + def read_area_metadata(self): + """Read #AREA section - detect ROM style vs SMAUG-WD style.""" + # Read first string + first_str = self.read_string() + + # Check if next char is a letter (SMAUG-WD key-value format) + # or another string/number (ROM format) + self.skip_whitespace() + next_char = self.current_char + + if next_char.isupper() and self.data[self.index+1].isspace(): + # SMAUG-WD format: name~ followed by key-value pairs + self.area.name = first_str + self._read_smaug_wd_area_keys() + else: + # ROM format: filename~ name~ metadata~ first_vnum last_vnum + self.area.original_filename = first_str + 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() + + def _read_smaug_wd_area_keys(self): + """Read SMAUG-WD style key-value pairs after area name.""" + while True: + self.skip_whitespace() + if self.current_char == '#': + break # Next section + if self.current_char == '$': + break # End of file + + key = self.read_letter() + if key == '\n' or key == '\r': + continue + + # Read value based on key + if key in ('K', 'L', 'U', 'O', 'R', 'W'): + # String value + self.read_string() + elif key in ('N', 'X', 'F', 'S'): + # Number value + self.read_number() + elif key == 'I' or key == 'V': + # Two numbers (level range or vnum range) + n1 = self.read_number() + n2 = self.read_number() + if key == 'V': + self.area.first_vnum = n1 + self.area.last_vnum = n2 + else: + # Unknown key, skip to end of line + self.read_to_eol() + + def load_areadata(self): + """Parse SMAUG #AREADATA key-value format.""" + while True: + self.skip_whitespace() + word = self.read_word() + word_lower = word.lower() + if word_lower == 'end': + break + elif word_lower == 'name': + self.area.name = self.read_string() + elif word_lower == 'builders' or word_lower == 'author': + self.area.metadata = self.read_string() + elif word_lower == 'vnums': + self.area.first_vnum = self.read_number() + self.area.last_vnum = self.read_number() + elif word_lower == 'credits': + credits = self.read_string() + if credits and credits != '(null)': + self.area.metadata = credits + elif word_lower == 'security': + self.read_number() # Discard + elif word_lower == 'flags': + self.read_number() # Discard + elif word_lower == 'resetmsg': + self.area.resetmsg = self.read_string() + elif word_lower == 'resetfreq': + self.read_number() # Discard + else: + # Skip unknown key-value pairs + self.read_to_eol() + + def load_author(self): + self.area.metadata = self.read_string() + + def load_ranges(self): + # Skip ranges section - read until $ or next section + while True: + self.skip_whitespace() + if self.current_char == '$': + self.advance() + break + elif self.current_char == '#': + break + self.read_to_eol() + + def load_flags(self): + # SMAUG #FLAGS section has two numbers: area_flags and area_reset_flags + self.read_number() # Discard area flags + self.read_number() # Discard area reset flags + def load_room(self, vnum): logger.debug("Reading room %d" % vnum) room = Room(vnum=vnum) @@ -947,9 +1777,411 @@ def load_room(self, vnum): def read_line(self): return self.read_to_eol() +@attributes +class SmaugWdMob(object): + """SMAUG-WD format mob - simpler than ROM.""" + vnum = attr(default=0) + name = attr(default='') + short_desc = attr(default='') + long_desc = attr(default='') + description = attr(default='') + act = attr(default=0, type=int) + affected_by = attr(default=0, type=int) + alignment = attr(default=0, type=int) + mob_type = attr(default='S') + level = attr(default=1, type=int) + hitroll = attr(default=0, type=int) + ac = attr(default=Factory(list), type=list) # 3 AC values + # Extended data from ! line + extended = attr(default=Factory(list), type=list) + + @classmethod + def read(cls, reader, vnum, **kwargs): + logger.debug("Reading SMAUG-WD mob %d" % vnum) + name = reader.read_string() + short_desc = reader.read_string() + long_desc = reader.read_string() + description = reader.read_string() + # act affected alignment mob_type + act = reader.read_number() + affected_by = reader.read_number() + alignment = reader.read_number() + mob_type = reader.read_letter() + # level hitroll + level = reader.read_number() + hitroll = reader.read_number() + # 3 AC values + ac = [reader.read_number(), reader.read_number(), reader.read_number()] + # Extended data line starting with ! + extended = [] + reader.skip_whitespace() + if reader.current_char == '!': + reader.advance() + # Read extended values until newline or next section + while True: + reader.skip_whitespace() + if reader.current_char in ('\n', '\r', '#', '>'): + break + try: + extended.append(reader.read_number()) + except: + break + # Skip MOBprogs (start with >, end with |) + reader.skip_whitespace() + while reader.current_char == '>': + # Skip the MOBprog trigger line + reader.read_to_eol() + if reader.current_char == '\n': + reader.advance() + # Skip until | (end of MOBprog) + while reader.current_char != '|' and reader.index < len(reader.data) - 1: + reader.advance() + if reader.current_char == '|': + reader.advance() + reader.skip_whitespace() + return cls(vnum=vnum, name=name, short_desc=short_desc, long_desc=long_desc, + description=description, act=act, affected_by=affected_by, + alignment=alignment, mob_type=mob_type, level=level, hitroll=hitroll, + ac=ac, extended=extended) + + +@attributes +class SmaugWdItem(object): + """SMAUG-WD format item.""" + vnum = attr(default=0) + name = attr(default='') + short_desc = attr(default='') + description = attr(default='') + item_type = attr(default=0, type=int) + extra_flags = attr(default=0, type=int) + wear_flags = attr(default=0, type=int) + level = attr(default=0, type=int) + value = attr(default=Factory(list), type=list) + weight = attr(default=0, type=int) + affected = attr(default=Factory(list), type=list) + extra_descriptions = attr(default=Factory(list), type=list) + + @classmethod + def read(cls, reader, vnum=None, **kwargs): + logger.debug("Reading SMAUG-WD object %d" % vnum) + name = reader.read_string() + short_desc = reader.read_string() + description = reader.read_string() + # item_type extra_flags wear_flags level (4 numbers) + item_type = reader.read_number() + extra_flags = reader.read_number() + wear_flags = reader.read_number() + level_or_unknown = reader.read_number() + # 4 values + value = [reader.read_number(), reader.read_number(), reader.read_number(), reader.read_number()] + # weight + weight = reader.read_number() + # Parse affects, level, and extra descriptions + affected = [] + extra_descriptions = [] + obj_level = level_or_unknown # May be overwritten by L line + while True: + reader.skip_whitespace() + letter = reader.read_letter() + if letter == 'A': + # Affect: location modifier + loc = reader.read_number() + mod = reader.read_number() + affected.append({'location': loc, 'modifier': mod}) + elif letter == 'L': + # Level restriction + obj_level = reader.read_number() + elif letter == 'E': + # Extra description + keyword = reader.read_string() + desc = reader.read_string() + extra_descriptions.append(ExtraDescription(keyword=keyword, description=desc)) + elif letter == '#' or letter == '$': + # Next object or end of section + reader.index -= 1 + break + else: + # Unknown, probably end of object + reader.index -= 1 + break + return cls(vnum=vnum, name=name, short_desc=short_desc, description=description, + item_type=item_type, extra_flags=extra_flags, wear_flags=wear_flags, + level=obj_level, value=value, weight=weight, affected=affected, + extra_descriptions=extra_descriptions) + + +@attributes +class SmaugWdExit(object): + """SMAUG-WD format exit.""" + direction = attr(default=0, type=int) + description = attr(default='') + keyword = attr(default='') + exit_flags = attr(default=0, type=int) + key = attr(default=-1, type=int) + destination = attr(default=0, type=int) + + @classmethod + def read(cls, reader, direction): + description = reader.read_string() + keyword = reader.read_string() + exit_flags = reader.read_number() + key = reader.read_number() + destination = reader.read_number() + return cls(direction=direction, description=description, keyword=keyword, + exit_flags=exit_flags, key=key, destination=destination) + + +@attributes +class SmaugWdRoom(object): + """SMAUG-WD format room.""" + vnum = attr(default=0) + name = attr(default='') + description = attr(default='') + room_flags = attr(default=0, type=int) + sector_type = attr(default=0, type=int) + exits = attr(default=Factory(list), type=list) + extra_descriptions = attr(default=Factory(list), type=list) + + @classmethod + def read(cls, reader, vnum): + logger.debug("Reading SMAUG-WD room %d" % vnum) + name = reader.read_string() + description = reader.read_string() + # room_flags sector_type (2 numbers, no area_number) + room_flags = reader.read_number() + sector_type = reader.read_number() + exits = [] + extra_descriptions = [] + while True: + reader.skip_whitespace() + letter = reader.read_letter() + if letter == 'S': + break + elif letter == 'D': + # Direction is appended to D (e.g., D0, D1, D2...) + direction = reader.read_number() + exits.append(SmaugWdExit.read(reader, direction)) + elif letter == 'E': + keyword = reader.read_string() + desc = reader.read_string() + extra_descriptions.append(ExtraDescription(keyword=keyword, description=desc)) + elif letter == 'M': + # Mana rate - skip + reader.read_number() + elif letter == 'H': + # Heal rate - skip + reader.read_number() + else: + # Unknown, skip to next line or back up + reader.index -= 1 + reader.read_to_eol() + return cls(vnum=vnum, name=name, description=description, room_flags=room_flags, + sector_type=sector_type, exits=exits, extra_descriptions=extra_descriptions) + + +@attributes +class SmaugWdReset(object): + """SMAUG-WD format reset.""" + command = attr(default=None) + arg1 = attr(default=0) + arg2 = attr(default=0) + arg3 = attr(default=0) + arg4 = attr(default=0) + comment = attr(default='') + + @classmethod + def read(cls, reader, letter): + command = letter + reader.read_number() # if_flag (always 0) + arg1 = reader.read_number() + arg2 = reader.read_number() + if letter in ('M', 'O', 'P', 'E', 'D'): + arg3 = reader.read_number() + if letter == 'M': + arg4 = reader.read_number() + else: + arg4 = 0 + else: + arg3 = 0 + arg4 = 0 + # Read comment (usually "(null)" or mob/obj name) + comment = reader.read_to_eol().strip() + return cls(command=command, arg1=arg1, arg2=arg2, arg3=arg3, arg4=arg4, comment=comment) + + +@attributes +class SmaugWdArea(object): + """SMAUG-WD area data container.""" + name = attr(default="") + metadata = attr(default="") + original_filename = attr(default="") + first_vnum = attr(default=-1) + last_vnum = attr(default=-1) + helps = attr(default=Factory(list)) + rooms = attr(default=Factory(OrderedDict)) + mobs = attr(default=Factory(OrderedDict)) + objects = attr(default=Factory(OrderedDict)) + resets = attr(default=Factory(list)) + specials = attr(default=Factory(list)) + shops = attr(default=Factory(list)) + + +class SmaugWdAreaFile(AreaFile): + """Parser for SMAUG-WD format area files.""" + area_type = SmaugWdArea + + def read_section_name(self): + """Read section name - handle AREA without # prefix.""" + self.skip_whitespace() + if self.current_char == '#': + self.advance() + name = self.read_word() + return name.lower() + elif self.current_char == '$': + self.advance() + return '$' + else: + # Check for bare "AREA" section name (no # prefix) + name = self.read_word() + return name.lower() + + def load_mobiles(self): + while True: + self.skip_whitespace() + if self.current_char != '#': + break + self.advance() # Skip # + vnum = self.read_number() + if vnum == 0: + break + mob = SmaugWdMob.read(self, vnum) + setitem(self.area.mobs, mob.vnum, mob) + + def load_objects(self): + while True: + self.skip_whitespace() + if self.current_char != '#': + break + self.advance() # Skip # + vnum = self.read_number() + if vnum == 0: + break + obj = SmaugWdItem.read(self, vnum) + setitem(self.area.objects, obj.vnum, obj) + + def load_rooms(self): + while True: + self.skip_whitespace() + if self.current_char != '#': + break + self.advance() # Skip # + vnum = self.read_number() + if vnum == 0: + break + room = SmaugWdRoom.read(self, vnum) + setitem(self.area.rooms, room.vnum, room) + + def load_resets(self): + while True: + self.skip_whitespace() + letter = self.read_letter() + if letter == 'S' or letter == '$': + break + if letter == '*': + self.read_to_eol() + continue + if letter in ('M', 'O', 'P', 'G', 'E', 'D', 'R', 'T'): + reset = SmaugWdReset.read(self, letter) + self.area.resets.append(reset) + else: + self.read_to_eol() + + def load_shops(self): + while True: + self.skip_whitespace() + keeper = self.read_number() + if keeper == 0: + break + # SMAUG-WD shop format: keeper buy_types[5] profit_buy profit_sell open close + buy_types = [self.read_number() for _ in range(5)] + profit_buy = self.read_number() + profit_sell = self.read_number() + open_hour = self.read_number() + close_hour = self.read_number() + self.area.shops.append({ + 'keeper': keeper, + 'buy_type': buy_types, + 'profit_buy': profit_buy, + 'profit_sell': profit_sell, + 'open_hour': open_hour, + 'close_hour': close_hour + }) + + def load_specials(self): + while True: + self.skip_whitespace() + letter = self.read_letter() + if letter == 'S' or letter == '$': + break + if letter == '*': + self.read_to_eol() + continue + if letter == 'M': + mob_vnum = self.read_number() + spec_name = self.read_word() + comment = self.read_to_eol() + self.area.specials.append({ + 'command': 'M', + 'arg1': mob_vnum, + 'arg2': spec_name, + 'comment': comment + }) + else: + self.read_to_eol() + + def read_area_metadata(self): + """Read #AREA section - SMAUG-WD format with key-value pairs.""" + # First line is area name + self.area.name = self.read_string() + # Read key-value pairs + while True: + self.skip_whitespace() + if self.current_char == '#': + break + if self.current_char == '$': + break + key = self.read_letter() + if key in ('\n', '\r'): + continue + # Read value based on key + if key in ('K', 'L', 'U', 'O', 'R', 'W'): + # String value + val = self.read_string() + if key == 'O': + self.area.metadata = val + elif key in ('N', 'X', 'F', 'S'): + # Number value + self.read_number() + elif key == 'I' or key == 'V': + # Two numbers + n1 = self.read_number() + n2 = self.read_number() + if key == 'V': + self.area.first_vnum = n1 + self.area.last_vnum = n2 + else: + # Unknown key, skip to end of line + self.read_to_eol() + + class EnumNameConverter(converters.Converter): def _unstructure_enum(self, obj): - return obj.__class__.__name__ + "." + obj.name + # Handle IntFlags that may have undefined bits set + name = obj.name + if name is None: + # Fall back to string representation for undefined values + return str(obj) + return obj.__class__.__name__ + "." + name def print_area(area_file_path, area_type=RomAreaFile): diff --git a/circlemud.py b/circlemud.py new file mode 100644 index 0000000..c42b81c --- /dev/null +++ b/circlemud.py @@ -0,0 +1,783 @@ +""" +CircleMUD area file parser. + +Parses CircleMUD's split file format (.wld, .mob, .obj, .zon, .shp) into +structured Python objects compatible with area_reader's output format. +""" + +import logging +import re +import json +import os +from collections import OrderedDict +from typing import List, Dict, Optional +from attr import attr, attrs, Factory + +logger = logging.getLogger('circlemud') + +# CircleMUD constants +ROOM_FLAGS = { + 1: 'DARK', 2: 'DEATH', 4: 'NO_MOB', 8: 'INDOORS', 16: 'PEACEFUL', + 32: 'SOUNDPROOF', 64: 'NO_TRACK', 128: 'NO_MAGIC', 256: 'TUNNEL', + 512: 'PRIVATE', 1024: 'GODROOM', 2048: 'HOUSE', 4096: 'HOUSE_CRASH', + 8192: 'ATRIUM', 16384: 'OLC', 32768: 'BFS_MARK', +} + +SECTOR_TYPES = { + 0: 'INSIDE', 1: 'CITY', 2: 'FIELD', 3: 'FOREST', 4: 'HILLS', + 5: 'MOUNTAIN', 6: 'WATER_SWIM', 7: 'WATER_NOSWIM', 8: 'UNDERWATER', + 9: 'FLYING', +} + +DOOR_FLAGS = {0: 'NO_DOOR', 1: 'DOOR', 2: 'PICKPROOF'} + +EXIT_DIRS = {0: 'NORTH', 1: 'EAST', 2: 'SOUTH', 3: 'WEST', 4: 'UP', 5: 'DOWN'} + +MOB_ACTION_FLAGS = { + 1: 'SPEC', 2: 'SENTINEL', 4: 'SCAVENGER', 8: 'ISNPC', 16: 'AWARE', + 32: 'AGGRESSIVE', 64: 'STAY_ZONE', 128: 'WIMPY', 256: 'AGGR_EVIL', + 512: 'AGGR_GOOD', 1024: 'AGGR_NEUTRAL', 2048: 'MEMORY', 4096: 'HELPER', + 8192: 'NO_CHARM', 16384: 'NO_SUMMON', 32768: 'NO_SLEEP', 65536: 'NO_BASH', + 131072: 'NO_BLIND', +} + +MOB_AFFECT_FLAGS = { + 1: 'BLIND', 2: 'INVISIBLE', 4: 'DETECT_ALIGN', 8: 'DETECT_INVIS', + 16: 'DETECT_MAGIC', 32: 'SENSE_LIFE', 64: 'WATERWALK', 128: 'SANCTUARY', + 256: 'GROUP', 512: 'CURSE', 1024: 'INFRAVISION', 2048: 'POISON', + 4096: 'PROTECT_EVIL', 8192: 'PROTECT_GOOD', 16384: 'SLEEP', 32768: 'NO_TRACK', + 65536: 'FLYING', 262144: 'SNEAK', 524288: 'HIDE', 2097152: 'CHARM', +} + +POSITIONS = { + 0: 'DEAD', 1: 'MORTALLY_WOUNDED', 2: 'INCAPACITATED', 3: 'STUNNED', + 4: 'SLEEPING', 5: 'RESTING', 6: 'SITTING', 7: 'FIGHTING', 8: 'STANDING', +} + +GENDERS = {0: 'NEUTRAL', 1: 'MALE', 2: 'FEMALE'} + +ITEM_TYPES = { + 1: 'LIGHT', 2: 'SCROLL', 3: 'WAND', 4: 'STAFF', 5: 'WEAPON', + 6: 'FIRE_WEAPON', 7: 'MISSILE', 8: 'TREASURE', 9: 'ARMOR', 10: 'POTION', + 11: 'WORN', 12: 'OTHER', 13: 'TRASH', 14: 'TRAP', 15: 'CONTAINER', + 16: 'NOTE', 17: 'DRINKCON', 18: 'KEY', 19: 'FOOD', 20: 'MONEY', + 21: 'PEN', 22: 'BOAT', 23: 'FOUNTAIN', +} + +WEAR_FLAGS = { + 1: 'TAKE', 2: 'FINGER', 4: 'NECK', 8: 'BODY', 16: 'HEAD', 32: 'LEGS', + 64: 'FEET', 128: 'HANDS', 256: 'ARMS', 512: 'SHIELD', 1024: 'ABOUT', + 2048: 'WAIST', 4096: 'WRIST', 8192: 'WIELD', 16384: 'HOLD', +} + +EXTRA_FLAGS = { + 1: 'GLOW', 2: 'HUM', 4: 'NO_RENT', 8: 'NO_DONATE', 16: 'NO_INVIS', + 32: 'INVISIBLE', 64: 'MAGIC', 128: 'NO_DROP', 256: 'BLESS', + 512: 'ANTI_GOOD', 1024: 'ANTI_EVIL', 2048: 'ANTI_NEUTRAL', + 4096: 'ANTI_MAGIC_USER', 8192: 'ANTI_CLERIC', 16384: 'ANTI_THIEF', + 32768: 'ANTI_WARRIOR', 65536: 'NO_SELL', +} + +APPLY_TYPES = { + 0: 'NONE', 1: 'STR', 2: 'DEX', 3: 'INT', 4: 'WIS', 5: 'CON', 6: 'CHA', + 7: 'CLASS', 8: 'LEVEL', 9: 'AGE', 10: 'CHAR_WEIGHT', 11: 'CHAR_HEIGHT', + 12: 'MANA', 13: 'HIT', 14: 'MOVE', 15: 'GOLD', 16: 'EXP', 17: 'AC', + 18: 'HITROLL', 19: 'DAMROLL', 20: 'SAVING_PARA', 21: 'SAVING_ROD', + 22: 'SAVING_PETRI', 23: 'SAVING_BREATH', 24: 'SAVING_SPELL', +} + + +def bitvector_to_flags(value, flag_dict): + """Convert a numeric bitvector to list of flag names.""" + if value == 0: + return [] + flags = [] + for bit, name in flag_dict.items(): + if value & bit: + flags.append(name) + return flags + + +def parse_bitvector(text): + """Parse a bitvector that may be numeric or letter-coded.""" + text = text.strip() + if not text or text == '0': + return 0 + + # Check if it's purely numeric + try: + return int(text) + except ValueError: + pass + + # Letter-coded bitvector (a=1, b=2, c=4, etc.) + value = 0 + for char in text: + if 'a' <= char <= 'z': + value |= (1 << (ord(char) - ord('a'))) + elif 'A' <= char <= 'Z': + value |= (1 << (ord(char) - ord('A'))) + return value + + +def parse_dice(text): + """Parse a dice string like '3d8+10' into dict.""" + text = text.strip() + if '+' in text: + dice_part, bonus = text.split('+') + elif '-' in text: + dice_part, bonus = text.split('-') + bonus = '-' + bonus + else: + dice_part = text + bonus = '0' + + if 'd' in dice_part.lower(): + num, sides = dice_part.lower().split('d') + return {'number': int(num), 'sides': int(sides), 'bonus': int(bonus)} + return {'number': 0, 'sides': 0, 'bonus': int(dice_part)} + + +@attrs +class CircleMudExit: + direction = attr(default=0) + description = attr(default='') + keywords = attr(default=Factory(list)) + door_flags = attr(default=0) + key_vnum = attr(default=-1) + destination = attr(default=-1) + + +@attrs +class CircleMudExtraDesc: + keywords = attr(default=Factory(list)) + description = attr(default='') + + +@attrs +class CircleMudRoom: + vnum = attr(default='') + name = attr(default='') + description = attr(default='') + zone_number = attr(default='') + room_flags = attr(default=0) + sector_type = attr(default=0) + exits = attr(default=Factory(list)) + extra_descriptions = attr(default=Factory(list)) + + +@attrs +class CircleMudMobile: + vnum = attr(default='') + aliases = attr(default=Factory(list)) + short_desc = attr(default='') + long_desc = attr(default='') + description = attr(default='') + action_flags = attr(default=0) + affect_flags = attr(default=0) + alignment = attr(default=0) + mob_type = attr(default='S') + level = attr(default=1) + thac0 = attr(default=20) + ac = attr(default=10) + hit_dice = attr(default=Factory(dict)) + damage_dice = attr(default=Factory(dict)) + gold = attr(default=0) + xp = attr(default=0) + load_position = attr(default=8) + default_position = attr(default=8) + sex = attr(default=0) + extra_specs = attr(default=Factory(dict)) + + +@attrs +class CircleMudObject: + vnum = attr(default='') + aliases = attr(default=Factory(list)) + short_desc = attr(default='') + long_desc = attr(default='') + action_desc = attr(default='') + item_type = attr(default=0) + extra_flags = attr(default=0) + wear_flags = attr(default=0) + values = attr(default=Factory(list)) + weight = attr(default=0) + cost = attr(default=0) + rent = attr(default=0) + affects = attr(default=Factory(list)) + extra_descriptions = attr(default=Factory(list)) + + +@attrs +class CircleMudReset: + command = attr(default='') + if_flag = attr(default=0) + arg1 = attr(default=0) + arg2 = attr(default=0) + arg3 = attr(default=0) + comment = attr(default='') + + +@attrs +class CircleMudZone: + vnum = attr(default='') + name = attr(default='') + top_room = attr(default=0) + lifespan = attr(default=30) + reset_mode = attr(default=2) + resets = attr(default=Factory(list)) + + +@attrs +class CircleMudShop: + vnum = attr(default='') + products = attr(default=Factory(list)) + buy_types = attr(default=Factory(list)) + profit_buy = attr(default=1.0) + profit_sell = attr(default=1.0) + keeper = attr(default=0) + shop_flags = attr(default=0) + rooms = attr(default=Factory(list)) + open_hour1 = attr(default=0) + close_hour1 = attr(default=28) + open_hour2 = attr(default=0) + close_hour2 = attr(default=0) + + +@attrs +class CircleMudArea: + """Container for all CircleMUD zone data.""" + name = attr(default='') + zone_vnum = attr(default='') + rooms = attr(default=Factory(OrderedDict)) + mobs = attr(default=Factory(OrderedDict)) + objects = attr(default=Factory(OrderedDict)) + zone = attr(default=None) + shops = attr(default=Factory(list)) + + +class CircleMudFile: + """Parser for CircleMUD split-file format.""" + + def __init__(self, directory): + """Initialize with a directory containing .wld, .mob, .obj, .zon, .shp files.""" + self.directory = directory + self.area = CircleMudArea() + self.files = {} + self._discover_files() + + def _discover_files(self): + """Find all parseable files in the directory.""" + for filename in os.listdir(self.directory): + filepath = os.path.join(self.directory, filename) + if not os.path.isfile(filepath): + continue + ext = os.path.splitext(filename)[1].lower() + if ext in ('.wld', '.mob', '.obj', '.zon', '.shp'): + if ext not in self.files: + self.files[ext] = [] + self.files[ext].append(filepath) + + def load_sections(self): + """Load all available sections.""" + if '.zon' in self.files: + for f in self.files['.zon']: + self._load_zone(f) + if '.wld' in self.files: + for f in self.files['.wld']: + self._load_rooms(f) + if '.mob' in self.files: + for f in self.files['.mob']: + self._load_mobiles(f) + if '.obj' in self.files: + for f in self.files['.obj']: + self._load_objects(f) + if '.shp' in self.files: + for f in self.files['.shp']: + self._load_shops(f) + + def _read_file(self, filepath): + """Read file with flexible encoding.""" + for encoding in ('utf-8', 'latin-1', 'ascii'): + try: + with open(filepath, 'r', encoding=encoding, errors='replace') as f: + return f.read() + except UnicodeDecodeError: + continue + return '' + + def _split_records(self, text, terminator='#99999'): + """Split file into records by # delimiter.""" + # Remove terminator and anything after + if terminator in text: + text = text.split(terminator)[0] + if '$~' in text: + text = text.split('$~')[0] + if '$' in text and text.strip().endswith('$'): + text = text.rsplit('$', 1)[0] + + # Split by # but keep the vnum + records = [] + parts = re.split(r'\n#', text) + for i, part in enumerate(parts): + if i == 0: + # First part might start with # + if part.startswith('#'): + part = part[1:] + elif not part.strip(): + continue + part = part.strip() + if part and not part.startswith('$'): + records.append(part) + return records + + def _load_zone(self, filepath): + """Load zone file.""" + text = self._read_file(filepath) + lines = text.strip().split('\n') + + zone = CircleMudZone() + + # Parse header + i = 0 + while i < len(lines): + line = lines[i].strip() + if line.startswith('#'): + zone.vnum = line[1:].strip() + i += 1 + break + i += 1 + + if i < len(lines): + zone.name = lines[i].rstrip('~').strip() + i += 1 + + if i < len(lines): + parts = lines[i].split() + if len(parts) >= 3: + zone.top_room = parts[0] + zone.lifespan = int(parts[1]) if parts[1].isdigit() else 30 + zone.reset_mode = int(parts[2]) if parts[2].isdigit() else 2 + i += 1 + + # Parse reset commands + while i < len(lines): + line = lines[i].strip() + i += 1 + + if not line or line.startswith('*'): + continue + if line == 'S' or line == '$': + break + + parts = line.split() + if not parts: + continue + + cmd = parts[0] + if cmd not in 'MOGEPDRT': + continue + + reset = CircleMudReset(command=cmd) + + # Extract comment (after tab or multiple spaces) + comment = '' + if '\t' in line: + comment = line.split('\t', 1)[-1].strip() + + try: + if cmd in 'MOEPD': + reset.if_flag = int(parts[1]) if len(parts) > 1 else 0 + reset.arg1 = parts[2] if len(parts) > 2 else '' + reset.arg2 = parts[3] if len(parts) > 3 else '' + reset.arg3 = parts[4] if len(parts) > 4 else '' + elif cmd == 'G': + reset.if_flag = int(parts[1]) if len(parts) > 1 else 0 + reset.arg1 = parts[2] if len(parts) > 2 else '' + reset.arg2 = parts[3] if len(parts) > 3 else '' + elif cmd == 'R': + reset.if_flag = int(parts[1]) if len(parts) > 1 else 0 + reset.arg1 = parts[2] if len(parts) > 2 else '' + reset.arg2 = parts[3] if len(parts) > 3 else '' + except (ValueError, IndexError): + pass + + reset.comment = comment + zone.resets.append(reset) + + self.area.zone = zone + self.area.zone_vnum = zone.vnum + self.area.name = zone.name + + def _load_rooms(self, filepath): + """Load world/room file.""" + text = self._read_file(filepath) + records = self._split_records(text) + + for record in records: + try: + room = self._parse_room(record) + if room: + self.area.rooms[room.vnum] = room + except Exception as e: + logger.debug(f"Error parsing room: {e}") + + def _parse_room(self, text): + """Parse a single room record.""" + parts = text.split('~') + if len(parts) < 3: + return None + + room = CircleMudRoom() + + # First part: vnum and name + first = parts[0].strip() + if '\n' in first: + vnum, name = first.split('\n', 1) + else: + vnum = first + name = '' + + room.vnum = vnum.strip() + room.name = name.strip() + room.description = parts[1].strip() + + # Third part: zone flags sector (and possibly more) + flags_line = parts[2].strip().split('\n')[0].strip() + flags_parts = flags_line.split() + + if len(flags_parts) >= 1: + room.zone_number = flags_parts[0] + if len(flags_parts) >= 2: + room.room_flags = parse_bitvector(flags_parts[1]) + if len(flags_parts) >= 3: + try: + room.sector_type = int(flags_parts[2]) + except ValueError: + room.sector_type = 0 + + # Parse exits (D0-D5) + bottom = '~'.join(parts[2:]) + exit_pattern = re.compile(r'D(\d+)\n(.*?)~\n(.*?)~\n(\S+)\s+(\S+)\s+(\S+)', re.DOTALL) + for match in exit_pattern.finditer(bottom): + direction, desc, keywords, door_flag, key, dest = match.groups() + exit_obj = CircleMudExit( + direction=int(direction), + description=desc.strip(), + keywords=keywords.split() if keywords.strip() else [], + door_flags=int(door_flag) if door_flag.lstrip('-').isdigit() else 0, + key_vnum=key, + destination=dest + ) + room.exits.append(exit_obj) + + # Parse extra descriptions (E) + extra_pattern = re.compile(r'\nE\n(.*?)~\n(.*?)~', re.DOTALL) + for match in extra_pattern.finditer(bottom): + keywords, desc = match.groups() + extra = CircleMudExtraDesc( + keywords=keywords.split(), + description=desc.strip() + ) + room.extra_descriptions.append(extra) + + return room + + def _load_mobiles(self, filepath): + """Load mobile file.""" + text = self._read_file(filepath) + records = self._split_records(text) + + for record in records: + try: + mob = self._parse_mobile(record) + if mob: + self.area.mobs[mob.vnum] = mob + except Exception as e: + logger.debug(f"Error parsing mobile: {e}") + + def _parse_mobile(self, text): + """Parse a single mobile record.""" + parts = text.split('~') + if len(parts) < 5: + return None + + mob = CircleMudMobile() + + # Parse vnum and aliases + first = parts[0].strip() + if '\n' in first: + vnum, aliases = first.split('\n', 1) + else: + vnum = first + aliases = '' + + mob.vnum = vnum.strip() + mob.aliases = aliases.split() if aliases.strip() else [] + mob.short_desc = parts[1].strip() + mob.long_desc = parts[2].strip() + mob.description = parts[3].strip() + + # Parse stats from after the 4th tilde + bottom = parts[4] if len(parts) > 4 else '' + lines = [l.strip() for l in bottom.strip().split('\n') if l.strip()] + + if not lines: + return mob + + # Line 1: action_flags affect_flags alignment mob_type + stats1 = lines[0].split() + if len(stats1) >= 4: + mob.action_flags = parse_bitvector(stats1[0]) + mob.affect_flags = parse_bitvector(stats1[1]) + try: + mob.alignment = int(stats1[2]) + except ValueError: + mob.alignment = 0 + mob.mob_type = stats1[3] + + # Line 2: level thac0 ac hit_dice damage_dice + if len(lines) > 1: + stats2 = lines[1].split() + if len(stats2) >= 5: + try: + mob.level = int(stats2[0]) + mob.thac0 = int(stats2[1]) + mob.ac = int(stats2[2]) + mob.hit_dice = parse_dice(stats2[3]) + mob.damage_dice = parse_dice(stats2[4]) + except (ValueError, IndexError): + pass + + # Line 3: gold xp + if len(lines) > 2: + stats3 = lines[2].split() + if len(stats3) >= 2: + try: + mob.gold = int(stats3[0]) + mob.xp = int(stats3[1]) + except ValueError: + pass + + # Line 4: load_pos default_pos sex + if len(lines) > 3: + stats4 = lines[3].split() + if len(stats4) >= 3: + try: + mob.load_position = int(stats4[0]) + mob.default_position = int(stats4[1]) + mob.sex = int(stats4[2]) + except ValueError: + pass + + # Extended specs for E-type mobs + if mob.mob_type == 'E' and len(lines) > 4: + for line in lines[4:]: + if line == 'E': + break + if ':' in line: + key, val = line.split(':', 1) + try: + mob.extra_specs[key.strip()] = int(val.strip()) + except ValueError: + mob.extra_specs[key.strip()] = val.strip() + + return mob + + def _load_objects(self, filepath): + """Load object file.""" + text = self._read_file(filepath) + records = self._split_records(text) + + for record in records: + try: + obj = self._parse_object(record) + if obj: + self.area.objects[obj.vnum] = obj + except Exception as e: + logger.debug(f"Error parsing object: {e}") + + def _parse_object(self, text): + """Parse a single object record.""" + parts = text.split('~') + if len(parts) < 5: + return None + + obj = CircleMudObject() + + # Parse vnum and aliases + first = parts[0].strip() + if '\n' in first: + vnum, aliases = first.split('\n', 1) + else: + vnum = first + aliases = '' + + obj.vnum = vnum.strip() + obj.aliases = aliases.split() if aliases.strip() else [] + obj.short_desc = parts[1].strip() + obj.long_desc = parts[2].strip() + obj.action_desc = parts[3].strip() + + # Parse type/flags/values from after 4th tilde + bottom = parts[4] if len(parts) > 4 else '' + lines = [l.strip() for l in bottom.strip().split('\n') if l.strip()] + + if not lines: + return obj + + # Line 1: type extra_flags wear_flags + type_line = lines[0].split() + if len(type_line) >= 1: + try: + obj.item_type = int(type_line[0]) + except ValueError: + obj.item_type = 0 + if len(type_line) >= 2: + obj.extra_flags = parse_bitvector(type_line[1]) + if len(type_line) >= 3: + obj.wear_flags = parse_bitvector(type_line[2]) + + # Line 2: values + if len(lines) > 1: + obj.values = lines[1].split() + + # Line 3: weight cost rent + if len(lines) > 2: + wc_line = lines[2].split() + if len(wc_line) >= 1: + try: + obj.weight = int(wc_line[0]) + except ValueError: + pass + if len(wc_line) >= 2: + try: + obj.cost = int(wc_line[1]) + except ValueError: + pass + if len(wc_line) >= 3: + try: + obj.rent = int(wc_line[2]) + except ValueError: + pass + + # Parse affects (A) and extra descs (E) from remaining content + remaining = '\n'.join(lines[3:]) if len(lines) > 3 else '' + remaining = '~'.join(parts[5:]) if len(parts) > 5 else remaining + + # Affects + affect_pattern = re.compile(r'\nA\n(\S+)\s+(\S+)') + for match in affect_pattern.finditer('\n' + remaining): + try: + loc = int(match.group(1)) + mod = int(match.group(2)) + obj.affects.append({'location': loc, 'modifier': mod}) + except ValueError: + pass + + # Extra descriptions + extra_pattern = re.compile(r'\nE\n(.*?)~\n(.*?)~', re.DOTALL) + for match in extra_pattern.finditer('\n' + remaining): + keywords, desc = match.groups() + obj.extra_descriptions.append(CircleMudExtraDesc( + keywords=keywords.split(), + description=desc.strip() + )) + + return obj + + def _load_shops(self, filepath): + """Load shop file.""" + text = self._read_file(filepath) + + # CircleMUD shop format varies significantly + # Try to parse both old and new formats + records = re.split(r'\n#', text) + + for record in records: + try: + shop = self._parse_shop(record) + if shop: + self.area.shops.append(shop) + except Exception as e: + logger.debug(f"Error parsing shop: {e}") + + def _parse_shop(self, text): + """Parse a single shop record.""" + if not text.strip(): + return None + + shop = CircleMudShop() + lines = [l.strip() for l in text.strip().split('\n') if l.strip()] + + if not lines: + return None + + # First line: vnum (possibly with "NEW~") + first = lines[0].lstrip('#').rstrip('~').strip() + if ' ' in first: + first = first.split()[0] + shop.vnum = first + + # This format varies a lot - just capture what we can + return shop + + def as_dict(self): + """Convert to dictionary for JSON serialization.""" + def convert(obj): + if hasattr(obj, '__attrs_attrs__'): + d = {} + for a in obj.__attrs_attrs__: + val = getattr(obj, a.name) + d[a.name] = convert(val) + return d + elif isinstance(obj, (OrderedDict, dict)): + return {k: convert(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert(v) for v in obj] + else: + return obj + + return convert(self.area) + + def as_json(self, indent=2): + """Convert to JSON string.""" + return json.dumps(self.as_dict(), indent=indent) + + def as_normalized(self): + """Return normalized area data.""" + from normalizer import AreaNormalizer + return AreaNormalizer(self).normalize() + + def as_normalized_dict(self): + """Return normalized area as dictionary.""" + from normalizer import NormalizedConverter + return NormalizedConverter().unstructure(self.as_normalized()) + + def as_normalized_json(self, indent=None): + """Return normalized area as JSON string.""" + return json.dumps(self.as_normalized_dict(), indent=indent) + + def save_as_normalized_json(self, filepath=None): + """Save normalized area to JSON file.""" + if filepath is None: + filepath = os.path.join(self.directory, 'normalized.json') + with open(filepath, 'w') as f: + json.dump(self.as_normalized_dict(), f, indent=2) + + +def load_circlemud_area(directory): + """Convenience function to load a CircleMUD area directory.""" + area = CircleMudFile(directory) + area.load_sections() + return area + + +if __name__ == '__main__': + import sys + if len(sys.argv) < 2: + print("Usage: python circlemud.py ") + sys.exit(1) + + area = load_circlemud_area(sys.argv[1]) + print(area.as_json()) diff --git a/constants.py b/constants.py index 39294d2..3979c42 100644 --- a/constants.py +++ b/constants.py @@ -331,6 +331,12 @@ class EXIT_DIRECTIONS(enum.Enum): WEST = 3 UP = 4 DOWN = 5 + # SMAUG extended directions + SOMEWHERE = 6 + NORTHEAST = 7 + NORTHWEST = 8 + SOUTHEAST = 9 + SOUTHWEST = 10 class EXIT_FLAGS(enum.IntFlag): NONE = 0 @@ -354,10 +360,14 @@ class SECTOR_TYPES(enum.Enum): MOUNTAIN = 5 WATER_SWIM = 6 WATER_NOSWIM = 7 - UNUSED = 8 + UNDERWATER = 8 AIR = 9 DESERT = 10 - MAX = 11 + DUNNO = 11 # Unknown/unused + OCEANFLOOR = 12 + UNDERGROUND = 13 + LAVA = 14 + SWAMP = 15 class SMAUG_AFFECTED_BY(enum.IntFlag): BLIND = 1 diff --git a/convert_all.py b/convert_all.py new file mode 100644 index 0000000..9205909 --- /dev/null +++ b/convert_all.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +Batch conversion script for MUD area files. + +Converts all area files to JSON, stopping on errors for investigation. +Supports ROM, Merc, SMAUG (.are) and CircleMUD (directory) formats. +""" + +import json +import os +import re +import sys +import traceback +from pathlib import Path + +# Add area_reader to path +sys.path.insert(0, str(Path(__file__).parent)) + +from area_reader import RomAreaFile, MercAreaFile, SmaugAreaFile, RotAreaFile, SmaugWdAreaFile, EnvyAreaFile, ParseError +from circlemud import CircleMudFile + + +def detect_format(filepath): + """Detect the area file format by examining content.""" + with open(filepath, 'r', encoding='ascii', errors='replace') as f: + content = f.read(8000) # Read first 8KB for detection + + first_line = content.split('\n')[0].strip() + + # Check for non-area files + if first_line.startswith('http') or first_line.startswith('/*') or first_line.startswith('/'): + return 'invalid' + + # Check for SMAUG-WD format (AREA without #) + if first_line == 'AREA': + return 'smaug_wd' + + # Check for #AREA followed by key-value format (SMAUG-WD style with #AREA) + # Pattern: #AREA\nName~\nK keyword~\n or similar single-letter keys + if '#AREA\n' in content[:100]: + lines = content[:500].split('\n') + if len(lines) > 2: + # Check if 3rd line starts with single uppercase letter followed by space + third_line = lines[2].strip() if len(lines) > 2 else '' + if len(third_line) > 2 and third_line[0].isupper() and third_line[1] == ' ': + return 'smaug_wd' # SMAUG-WD style even with #AREA + + # Check for #AREADATA format (SMAUG style) + if '#AREADATA' in content[:2000]: + return 'smaug_areadata' + + # Check for #ECONOMY section (SMAUG with ROM-style #AREA) + if '#ECONOMY' in content[:2000]: + return 'smaug' + + # Check for Envy format: #AREA with {levels} in AREA section AND mob with S letter after race~ + # Envy has 5 tilde strings (like ROM) but ends mob header line with S (like Merc) + # Check for { in AREA section (between #AREA and next #) + area_section_end = content.find('#', content.find('#AREA') + 1) if '#AREA' in content else 500 + area_section = content[:area_section_end] if area_section_end > 0 else content[:500] + + if '{' in area_section or '[' in area_section: + if '#MOBILES' in content: + mob_section = content[content.find('#MOBILES'):] + # Envy pattern: race~\nFLAGS AFFECTED ALIGNMENT S\n (S at end after 5 tildes) + # Look for the S mob type after what looks like 5 tilde strings + envy_pattern = r'~\n[A-Z]+\s+[A-Z0-9]+\s+-?\d+\s+S\s*\n' + if re.search(envy_pattern, mob_section[:4000]): + return 'envy' + # Even without mobs, {levels} in AREA section suggests Envy/DSA variant + return 'envy' + + # Check for Merc vs Envy by counting tilde strings before mob type letter + if '#MOBILES' in content: + mob_section = content[content.find('#MOBILES'):] + + # Envy format check: look for race~ followed by S mob type + # Pattern: word ending in ~\n, then flags ending in S on next line + # The key is that Envy has a race word before the flags, Merc doesn't + # Check for lowercase word followed by ~ then flags ending in S + envy_race_pattern = r'[a-z]+~\n[A-Z]+[a-zA-Z]*\s+[A-Z0-9]+\s+-?\d+\s+S\s*\n' + if re.search(envy_race_pattern, mob_section[:4000]): + return 'envy' + + # Check for Merc pattern (4 tilde strings): name~ short~ long~ desc~ then "ACT AFF ALIGN S" + # Pattern: ~\n followed by flags line ending in single letter (mob type) + merc_pattern = r'~\n[A-Za-z0-9|]+\s+\d+\s+-?\d+\s+[A-Z]\s*\n' + if re.search(merc_pattern, mob_section[:4000]): + return 'merc' + + # Also check: after description tilde, if next line has letter flags followed by + # number number letter (not number), it's Merc + merc_pattern2 = r'~\n[A-Z][A-Za-z|]*\s+-?\d+\s+-?\d+\s+[A-Z]\s*\n' + if re.search(merc_pattern2, mob_section[:4000]): + return 'merc' + + # Check for ROT format: 5 values after race (act affected flag alignment group) + # Pattern: race~\nFLAGS 0 LETTER 0 0\n (letter in 3rd position) + rot_pattern = r'~\n[A-Z]+\s+\d+\s+[A-Z]\s+\d+\s+\d+\s*\n' + if re.search(rot_pattern, mob_section[:4000]): + return 'rot' + + # Default to ROM + return 'rom' + + +def detect_and_parse_are_file(filepath, tolerant=False): + """Detect format and parse with appropriate parser.""" + detected = detect_format(filepath) + + # Map detected format to parser order + parser_orders = { + 'rom': [('rom', RomAreaFile), ('rot', RotAreaFile), ('merc', MercAreaFile), ('smaug', SmaugAreaFile)], + 'rot': [('rot', RotAreaFile), ('rom', RomAreaFile), ('smaug', SmaugAreaFile)], + 'merc': [('merc', MercAreaFile), ('rom', RomAreaFile), ('smaug', SmaugAreaFile)], + 'envy': [('envy', EnvyAreaFile), ('merc', MercAreaFile), ('rom', RomAreaFile)], + 'smaug': [('smaug', SmaugAreaFile), ('rom', RomAreaFile), ('merc', MercAreaFile)], + 'smaug_areadata': [('smaug', SmaugAreaFile), ('rot', RotAreaFile), ('rom', RomAreaFile), ('merc', MercAreaFile)], + 'smaug_wd': [('smaug_wd', SmaugWdAreaFile), ('smaug', SmaugAreaFile)], + 'invalid': [], # Skip these files + } + + parsers = parser_orders.get(detected, parser_orders['rom']) + + if not parsers: + raise ParseError(f"Unsupported format '{detected}' for {filepath}") + + errors = [] + for name, parser_class in parsers: + try: + af = parser_class(filepath) + af.load_sections(tolerant=tolerant) + return af, name + except Exception as e: + errors.append((name, str(e))) + + # All parsers failed + error_msg = "\n".join(f" {name}: {err}" for name, err in errors) + raise ParseError(f"All parsers failed for {filepath} (detected: {detected}):\n{error_msg}") + + +def convert_are_file(filepath, output_dir, normalized=False, tolerant=False): + """Convert a single .are file to JSON.""" + filename = os.path.basename(filepath) + if normalized: + json_name = filename.replace('.are', '.normalized.json') + else: + json_name = filename.replace('.are', '.json') + output_path = os.path.join(output_dir, json_name) + + af, format_name = detect_and_parse_are_file(filepath, tolerant=tolerant) + + if normalized: + data = af.as_normalized_dict() + else: + data = af.as_dict() + data['_source_format'] = format_name + data['_source_file'] = filepath + + # Add parse errors if in tolerant mode + if tolerant and hasattr(af, '_parse_errors') and af._parse_errors: + data['_parse_errors'] = af._parse_errors + + with open(output_path, 'w') as f: + json.dump(data, f, indent=2) + + return format_name + + +def convert_circlemud_dir(dirpath, output_dir, normalized=False, tolerant=False): + """Convert a CircleMUD directory to JSON.""" + dirname = os.path.basename(dirpath) + if normalized: + json_name = f"cm_{dirname}.normalized.json" + else: + json_name = f"cm_{dirname}.json" + output_path = os.path.join(output_dir, json_name) + + cf = CircleMudFile(dirpath) + cf.load_sections() # CircleMUD doesn't use tolerant mode yet + + if normalized: + data = cf.as_normalized_dict() + else: + data = cf.as_dict() + data['_source_format'] = 'circlemud' + data['_source_dir'] = dirpath + + with open(output_path, 'w') as f: + json.dump(data, f, indent=2) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description='Convert MUD area files to JSON') + parser.add_argument('--areas-dir', default='../areas', + help='Directory containing .are files') + parser.add_argument('--circlemud-dir', default='../circleMUD', + help='Directory containing CircleMUD subdirectories') + parser.add_argument('--output-dir', default='../json', + help='Output directory for JSON files') + parser.add_argument('--skip-are', action='store_true', + help='Skip .are file conversion') + parser.add_argument('--skip-circlemud', action='store_true', + help='Skip CircleMUD directory conversion') + parser.add_argument('--continue-on-error', action='store_true', + help='Continue processing after errors') + parser.add_argument('--normalized', '-n', action='store_true', + help='Output normalized JSON format') + parser.add_argument('--tolerant', '-t', action='store_true', + help='Skip sections/items that fail to parse (get partial data)') + args = parser.parse_args() + + # Resolve paths relative to script location + script_dir = Path(__file__).parent + areas_dir = (script_dir / args.areas_dir).resolve() + circlemud_dir = (script_dir / args.circlemud_dir).resolve() + output_dir = (script_dir / args.output_dir).resolve() + + # Create output directory + output_dir.mkdir(exist_ok=True) + + stats = { + 'are_success': 0, 'are_failed': 0, + 'cm_success': 0, 'cm_failed': 0, + 'formats': {'rom': 0, 'rot': 0, 'merc': 0, 'envy': 0, 'smaug': 0, 'smaug_wd': 0, 'circlemud': 0}, + 'errors': [] + } + + # Convert .are files + if not args.skip_are and areas_dir.exists(): + print(f"\n=== Converting .are files from {areas_dir} ===\n") + + are_files = sorted(areas_dir.glob('**/*.are')) + total = len(are_files) + + for i, filepath in enumerate(are_files, 1): + relpath = filepath.relative_to(areas_dir) + try: + format_name = convert_are_file(str(filepath), str(output_dir), normalized=args.normalized, tolerant=args.tolerant) + stats['are_success'] += 1 + stats['formats'][format_name] += 1 + print(f"[{i}/{total}] OK ({format_name}): {relpath}") + except Exception as e: + stats['are_failed'] += 1 + stats['errors'].append(('are', str(relpath), str(e))) + print(f"[{i}/{total}] FAILED: {relpath}") + print(f" Error: {e}") + if not args.continue_on_error: + print("\n=== Stopping on error ===") + print(f"File: {filepath}") + print("\nFull traceback:") + traceback.print_exc() + sys.exit(1) + + # Convert CircleMUD directories + if not args.skip_circlemud and circlemud_dir.exists(): + print(f"\n=== Converting CircleMUD directories from {circlemud_dir} ===\n") + + # Find directories containing .wld files (indicates CircleMUD zone) + cm_dirs = sorted(set( + p.parent for p in circlemud_dir.glob('**/*.wld') + )) + total = len(cm_dirs) + + for i, dirpath in enumerate(cm_dirs, 1): + relpath = dirpath.relative_to(circlemud_dir) + try: + convert_circlemud_dir(str(dirpath), str(output_dir), normalized=args.normalized) + stats['cm_success'] += 1 + stats['formats']['circlemud'] += 1 + print(f"[{i}/{total}] OK: {relpath}") + except Exception as e: + stats['cm_failed'] += 1 + stats['errors'].append(('circlemud', str(relpath), str(e))) + print(f"[{i}/{total}] FAILED: {relpath}") + print(f" Error: {e}") + if not args.continue_on_error: + print("\n=== Stopping on error ===") + print(f"Directory: {dirpath}") + print("\nFull traceback:") + traceback.print_exc() + sys.exit(1) + + # Summary + print("\n" + "=" * 60) + print("CONVERSION SUMMARY") + print("=" * 60) + print(f".are files: {stats['are_success']} success, {stats['are_failed']} failed") + print(f"CircleMUD: {stats['cm_success']} success, {stats['cm_failed']} failed") + print(f"\nFormat breakdown:") + for fmt, count in stats['formats'].items(): + if count > 0: + print(f" {fmt}: {count}") + + if stats['errors']: + print(f"\n{len(stats['errors'])} ERRORS:") + for ftype, path, error in stats['errors']: + print(f" [{ftype}] {path}: {error[:100]}") + + # Write stats to file + stats_path = output_dir / 'conversion_stats.json' + with open(stats_path, 'w') as f: + json.dump(stats, f, indent=2) + print(f"\nStats saved to: {stats_path}") + + return 1 if stats['errors'] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/normalized.py b/normalized.py new file mode 100644 index 0000000..826e30c --- /dev/null +++ b/normalized.py @@ -0,0 +1,158 @@ +""" +Normalized data classes for unified MUD area representation. + +These classes provide a common schema across ROM, Merc, CircleMUD, and SMAUG formats, +while preserving the original data in the _original field for lossless round-trip. +""" + +from typing import List, Dict, Optional, Any +from attr import attr, attrs, Factory + + +@attrs +class NormalizedDice: + """Normalized dice representation (NdS+B).""" + num: int = attr(default=0) + size: int = attr(default=0) + bonus: int = attr(default=0) + + @classmethod + def from_dict(cls, d): + """Create from dict with number/sides/bonus or num/size/bonus.""" + if not d: + return cls() + return cls( + num=d.get('num', d.get('number', 0)), + size=d.get('size', d.get('sides', 0)), + bonus=d.get('bonus', 0) + ) + + +@attrs +class NormalizedArmorClass: + """Normalized 4-value armor class (ROM style).""" + pierce: int = attr(default=0) + bash: int = attr(default=0) + slash: int = attr(default=0) + exotic: int = attr(default=0) + + @classmethod + def from_single(cls, ac_value): + """Create from a single AC value (Merc/Circle style).""" + return cls(pierce=ac_value, bash=ac_value, slash=ac_value, exotic=ac_value) + + @classmethod + def from_rom(cls, ac_obj): + """Create from ROM's RomArmorClass object.""" + if hasattr(ac_obj, 'pierce'): + return cls( + pierce=ac_obj.pierce, + bash=ac_obj.bash, + slash=ac_obj.slash, + exotic=ac_obj.exotic + ) + # If it's a dict + if isinstance(ac_obj, dict): + return cls( + pierce=ac_obj.get('pierce', 0), + bash=ac_obj.get('bash', 0), + slash=ac_obj.get('slash', 0), + exotic=ac_obj.get('exotic', 0) + ) + # Single value + if isinstance(ac_obj, (int, float)): + return cls.from_single(int(ac_obj)) + return cls() + + +@attrs +class NormalizedMob: + """Normalized mobile/NPC representation.""" + vnum: int = attr(default=0) + keywords: List[str] = attr(factory=list) + short_desc: str = attr(default='') + long_desc: str = attr(default='') + description: str = attr(default='') + level: int = attr(default=1) + alignment: int = attr(default=0) + sex: str = attr(default='none') # "none"/"male"/"female" + race: str = attr(default='human') + act_flags: List[str] = attr(factory=list) # lowercase, no prefix + affect_flags: List[str] = attr(factory=list) + hitroll: int = attr(default=0) + ac: NormalizedArmorClass = attr(factory=NormalizedArmorClass) + hit_dice: NormalizedDice = attr(factory=NormalizedDice) + mana_dice: NormalizedDice = attr(factory=NormalizedDice) + damage_dice: NormalizedDice = attr(factory=NormalizedDice) + damage_type: str = attr(default='') + gold: int = attr(default=0) + position: Dict[str, str] = attr(factory=lambda: {'default': 'standing', 'load': 'standing'}) + resistances: Dict[str, List[str]] = attr(factory=lambda: {'immune': [], 'resist': [], 'vuln': []}) + body: Dict[str, Any] = attr(factory=lambda: {'form': [], 'parts': [], 'size': 'medium'}) + offense_flags: List[str] = attr(factory=list) + programs: List[Dict] = attr(factory=list) # mprogs + original: Dict = attr(factory=dict) # original format-specific data + + +@attrs +class NormalizedItem: + """Normalized object/item representation.""" + vnum: int = attr(default=0) + keywords: List[str] = attr(factory=list) + short_desc: str = attr(default='') + long_desc: str = attr(default='') + item_type: str = attr(default='') # normalized string + extra_flags: List[str] = attr(factory=list) + wear_flags: List[str] = attr(factory=list) + weight: int = attr(default=0) + cost: int = attr(default=0) + level: int = attr(default=0) + condition: int = attr(default=100) + material: str = attr(default='') + values: List[Any] = attr(factory=list) + affects: List[Dict] = attr(factory=list) + extra_descriptions: List[Dict] = attr(factory=list) + original: Dict = attr(factory=dict) # original format-specific data + + +@attrs +class NormalizedExit: + """Normalized room exit.""" + direction: str = attr(default='') # "north", "east", etc. + destination: int = attr(default=-1) + description: str = attr(default='') + keywords: List[str] = attr(factory=list) + flags: List[str] = attr(factory=list) # "door", "locked", "pickproof", etc. + key_vnum: int = attr(default=-1) + + +@attrs +class NormalizedRoom: + """Normalized room representation.""" + vnum: int = attr(default=0) + name: str = attr(default='') + description: str = attr(default='') + room_flags: List[str] = attr(factory=list) + sector_type: str = attr(default='inside') + exits: List[NormalizedExit] = attr(factory=list) + extra_descriptions: List[Dict] = attr(factory=list) + heal_rate: int = attr(default=100) + mana_rate: int = attr(default=100) + original: Dict = attr(factory=dict) # original format-specific data + + +@attrs +class NormalizedArea: + """Normalized area container.""" + name: str = attr(default='') + builders: str = attr(default='') + level_range: List[int] = attr(factory=lambda: [0, 0]) + vnum_range: List[int] = attr(factory=lambda: [0, 0]) + mobs: Dict[int, NormalizedMob] = attr(factory=dict) + objects: Dict[int, NormalizedItem] = attr(factory=dict) + rooms: Dict[int, NormalizedRoom] = attr(factory=dict) + resets: List[Dict] = attr(factory=list) + shops: List[Dict] = attr(factory=list) + specials: List[Dict] = attr(factory=list) + helps: List[Dict] = attr(factory=list) + meta: Dict = attr(factory=lambda: {'source_format': '', 'source_file': ''}) diff --git a/normalizer.py b/normalizer.py new file mode 100644 index 0000000..990599c --- /dev/null +++ b/normalizer.py @@ -0,0 +1,1060 @@ +""" +Normalization logic for converting format-specific area data to unified schema. + +This module provides normalizers for each supported MUD format that convert +format-specific data structures into the normalized schema while preserving +the original data for lossless round-trip. +""" + +import enum +import json +from typing import Dict, List, Any, Optional + +from normalized import ( + NormalizedArea, NormalizedMob, NormalizedItem, NormalizedRoom, + NormalizedDice, NormalizedArmorClass, NormalizedExit +) + +# ============================================================================ +# Mapping Tables +# ============================================================================ + +# Position mappings: various format representations -> normalized string +POSITION_MAP = { + # Numeric positions (Merc/Circle) + 0: 'dead', + 1: 'mortally_wounded', + 2: 'incapacitated', + 3: 'stunned', + 4: 'sleeping', + 5: 'resting', + 6: 'sitting', + 7: 'fighting', + 8: 'standing', + # Word positions (ROM) + 'dead': 'dead', + 'mort': 'mortally_wounded', + 'incap': 'incapacitated', + 'stun': 'stunned', + 'sleep': 'sleeping', + 'rest': 'resting', + 'sit': 'sitting', + 'fight': 'fighting', + 'stand': 'standing', +} + +SEX_MAP = { + # Numeric (Merc/Circle) + 0: 'none', + 1: 'male', + 2: 'female', + # Word (ROM) + 'none': 'none', + 'neutral': 'none', + 'male': 'male', + 'female': 'female', + 'either': 'either', + 'random': 'either', +} + +SECTOR_MAP = { + # Numeric values + 0: 'inside', + 1: 'city', + 2: 'field', + 3: 'forest', + 4: 'hills', + 5: 'mountain', + 6: 'water_swim', + 7: 'water_noswim', + 8: 'unused', + 9: 'air', + 10: 'desert', + # CircleMUD additions + 'INSIDE': 'inside', + 'CITY': 'city', + 'FIELD': 'field', + 'FOREST': 'forest', + 'HILLS': 'hills', + 'MOUNTAIN': 'mountain', + 'WATER_SWIM': 'water_swim', + 'WATER_NOSWIM': 'water_noswim', + 'UNDERWATER': 'underwater', + 'FLYING': 'air', +} + +DIRECTION_MAP = { + 0: 'north', + 1: 'east', + 2: 'south', + 3: 'west', + 4: 'up', + 5: 'down', + 6: 'somewhere', + 7: 'northeast', + 8: 'northwest', + 9: 'southeast', + 10: 'southwest', + 'NORTH': 'north', + 'EAST': 'east', + 'SOUTH': 'south', + 'WEST': 'west', + 'UP': 'up', + 'DOWN': 'down', + 'SOMEWHERE': 'somewhere', + 'NORTHEAST': 'northeast', + 'NORTHWEST': 'northwest', + 'SOUTHEAST': 'southeast', + 'SOUTHWEST': 'southwest', +} + +# Item type mapping (numeric -> string) +ITEM_TYPE_MAP = { + # ROM/Merc numeric types + 0: 'none', + 1: 'light', + 2: 'scroll', + 3: 'wand', + 4: 'staff', + 5: 'weapon', + 6: 'treasure', + 7: 'armor', + 8: 'potion', + 9: 'clothing', + 10: 'furniture', + 11: 'trash', + 12: 'container', + 13: 'drink', + 14: 'key', + 15: 'food', + 16: 'money', + 17: 'boat', + 18: 'npc_corpse', + 19: 'pc_corpse', + 20: 'fountain', + 21: 'pill', + 22: 'protect', + 23: 'map', + 24: 'portal', + 25: 'warp_stone', + 26: 'room_key', + 27: 'gem', + 28: 'jewelry', + 29: 'jukebox', + # CircleMUD types (different numbering) + 'LIGHT': 'light', + 'SCROLL': 'scroll', + 'WAND': 'wand', + 'STAFF': 'staff', + 'WEAPON': 'weapon', + 'FIRE_WEAPON': 'fire_weapon', + 'MISSILE': 'missile', + 'TREASURE': 'treasure', + 'ARMOR': 'armor', + 'POTION': 'potion', + 'WORN': 'worn', + 'OTHER': 'other', + 'TRASH': 'trash', + 'TRAP': 'trap', + 'CONTAINER': 'container', + 'NOTE': 'note', + 'DRINKCON': 'drink', + 'KEY': 'key', + 'FOOD': 'food', + 'MONEY': 'money', + 'PEN': 'pen', + 'BOAT': 'boat', + 'FOUNTAIN': 'fountain', +} + + +# ============================================================================ +# Flag Normalization Functions +# ============================================================================ + +def _clean_flag_name(name: str) -> str: + """Clean up a single flag name.""" + clean_name = name.lower().strip() + # Remove leading prefixes + for prefix in ('is_', '_'): + if clean_name.startswith(prefix): + clean_name = clean_name[len(prefix):] + # Remove trailing underscores + clean_name = clean_name.rstrip('_') + return clean_name + + +def normalize_flags(value, enum_class=None) -> List[str]: + """ + Convert IntFlag enum or numeric value to list of lowercase flag names. + + ROM_ACT_TYPES.IS_NPC|SENTINEL -> ["npc", "sentinel"] + """ + if value is None or value == 0: + return [] + + flags = [] + + # If it's an IntFlag enum + if isinstance(value, enum.IntFlag): + # Get the string representation and parse it + name = value.name + if name: + # name could be "IS_NPC|SENTINEL" for composite flags + if '|' in name: + for part in name.split('|'): + clean = _clean_flag_name(part) + if clean and clean not in flags and not clean.startswith('unused'): + flags.append(clean) + else: + # Single flag + clean = _clean_flag_name(name) + if clean and not clean.startswith('unused'): + flags.append(clean) + else: + # Composite flag - iterate through members + for member in type(value): + if member.value and (value & member.value) == member.value: + if member.value != 0: # Skip NONE + clean = _clean_flag_name(member.name) + if clean and clean not in flags and not clean.startswith('unused'): + flags.append(clean) + elif isinstance(value, int) and enum_class: + # Numeric value with enum class provided + try: + enum_val = enum_class(value) + return normalize_flags(enum_val) + except ValueError: + # Handle composite flags + for member in enum_class: + if member.value and (value & member.value) == member.value: + if member.value != 0: + clean = _clean_flag_name(member.name) + if clean and clean not in flags and not clean.startswith('unused'): + flags.append(clean) + elif isinstance(value, str): + # Already a string representation like "ROM_ACT_TYPES.IS_NPC|SENTINEL" + # Parse the enum string + if '.' in value: + parts = value.split('.')[-1] # Get after the last dot + else: + parts = value + # Split by | if composite + for part in parts.split('|'): + name = part.strip().lower() + for prefix in ('is_', '_'): + if name.startswith(prefix): + name = name[len(prefix):] + if name and not name.startswith('unused'): + flags.append(name) + + return flags + + +def normalize_room_flags(value) -> List[str]: + """Normalize room flags to lowercase names.""" + return normalize_flags(value) + + +def normalize_exit_flags(value) -> List[str]: + """Normalize exit/door flags to lowercase names.""" + flags = [] + if isinstance(value, int): + # Common flag bits + if value & 1: # ISDOOR + flags.append('door') + if value & 2: # CLOSED + flags.append('closed') + if value & 4: # LOCKED + flags.append('locked') + if value & 32: # PICKPROOF + flags.append('pickproof') + if value & 64: # NOPASS + flags.append('nopass') + else: + flags = normalize_flags(value) + return flags + + +def thac0_to_hitroll(thac0: int) -> int: + """Convert CircleMUD THAC0 to ROM-style hitroll.""" + return 20 - thac0 + + +# ============================================================================ +# Converter for attrs objects to dicts +# ============================================================================ + +class NormalizedConverter: + """Converter for unstructuring normalized objects to dictionaries.""" + + def unstructure(self, obj): + """Convert an object to a dictionary representation.""" + if hasattr(obj, '__attrs_attrs__'): + result = {} + for a in obj.__attrs_attrs__: + val = getattr(obj, a.name) + result[a.name] = self.unstructure(val) + return result + elif isinstance(obj, dict): + return {k: self.unstructure(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self.unstructure(v) for v in obj] + elif isinstance(obj, enum.Enum): + return obj.name + elif isinstance(obj, enum.IntFlag): + if obj.name: + return obj.name + return str(obj) + else: + return obj + + +# ============================================================================ +# Original Data Converter +# ============================================================================ + +class OriginalConverter: + """Converter for unstructuring original format objects to dicts.""" + + def unstructure(self, obj): + """Convert an object to dictionary, handling enums specially.""" + if hasattr(obj, '__attrs_attrs__'): + result = {} + for a in obj.__attrs_attrs__: + val = getattr(obj, a.name) + result[a.name] = self.unstructure(val) + return result + elif isinstance(obj, dict): + return {k: self.unstructure(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self.unstructure(v) for v in obj] + elif isinstance(obj, enum.IntFlag): + # Return both the string representation and numeric value + if obj.name: + return f"{type(obj).__name__}.{obj.name}" + return str(obj) + elif isinstance(obj, enum.Enum): + return f"{type(obj).__name__}.{obj.name}" + else: + return obj + + +# ============================================================================ +# Format-Specific Normalizers +# ============================================================================ + +class BaseMobNormalizer: + """Base class for mob normalizers.""" + + def __init__(self, converter=None): + self.converter = converter or OriginalConverter() + + def normalize(self, mob) -> NormalizedMob: + raise NotImplementedError + + +class PartialMobNormalizer(BaseMobNormalizer): + """Normalizer for partially parsed mobs (PartialMudObject).""" + + def normalize(self, mob) -> NormalizedMob: + name = getattr(mob, 'name', '') + keywords = name.split() if name else [] + + return NormalizedMob( + vnum=getattr(mob, 'vnum', 0), + keywords=keywords, + short_desc=getattr(mob, 'short_desc', ''), + long_desc=getattr(mob, 'long_desc', ''), + description=getattr(mob, 'description', ''), + level=0, + alignment=0, + sex='none', + race='unknown', + act_flags=[], + affect_flags=[], + hitroll=0, + ac=NormalizedArmorClass(), + hit_dice=NormalizedDice(), + mana_dice=NormalizedDice(), + damage_dice=NormalizedDice(), + damage_type='', + gold=0, + position={'default': 'standing', 'load': 'standing'}, + resistances={'immune': [], 'resist': [], 'vuln': []}, + body={'form': [], 'parts': [], 'size': 'medium'}, + offense_flags=[], + programs=[], + original={ + 'vnum': getattr(mob, 'vnum', 0), + 'name': name, + 'short_desc': getattr(mob, 'short_desc', ''), + 'long_desc': getattr(mob, 'long_desc', ''), + 'description': getattr(mob, 'description', ''), + '_partial': True, + '_parse_error': getattr(mob, '_parse_error', ''), + '_object_type': getattr(mob, '_object_type', '') + } + ) + + +class PartialItemNormalizer: + """Normalizer for partially parsed items (PartialMudObject).""" + + def __init__(self, converter=None): + self.converter = converter or OriginalConverter() + + def normalize(self, item) -> NormalizedItem: + name = getattr(item, 'name', '') + keywords = name.split() if name else [] + + return NormalizedItem( + vnum=getattr(item, 'vnum', 0), + keywords=keywords, + short_desc=getattr(item, 'short_desc', ''), + long_desc=getattr(item, 'description', ''), # For items, description is often the long_desc + item_type='unknown', + extra_flags=[], + wear_flags=[], + weight=0, + cost=0, + level=0, + condition=100, + material='', + values=[], + affects=[], + extra_descriptions=[], + original={ + 'vnum': getattr(item, 'vnum', 0), + 'name': name, + 'short_desc': getattr(item, 'short_desc', ''), + 'long_desc': getattr(item, 'long_desc', ''), + 'description': getattr(item, 'description', ''), + '_partial': True, + '_parse_error': getattr(item, '_parse_error', ''), + '_object_type': getattr(item, '_object_type', '') + } + ) + + +class RomMobNormalizer(BaseMobNormalizer): + """Normalizer for ROM format mobs.""" + + def normalize(self, mob) -> NormalizedMob: + # Parse keywords from name field + name = getattr(mob, 'name', '') + keywords = name.split() if name else [] + + # Get position values + start_pos = getattr(mob, 'start_pos', 'stand') + default_pos = getattr(mob, 'default_pos', 'stand') + + # Normalize positions + load_pos = POSITION_MAP.get(start_pos, str(start_pos).lower() if start_pos else 'standing') + def_pos = POSITION_MAP.get(default_pos, str(default_pos).lower() if default_pos else 'standing') + + # Normalize sex + sex_raw = getattr(mob, 'sex', 'none') + sex = SEX_MAP.get(sex_raw, str(sex_raw).lower() if sex_raw else 'none') + + # Normalize size + size_raw = getattr(mob, 'size', 'medium') + size = str(size_raw).lower() if size_raw else 'medium' + + # Get dice values + hit_dice = getattr(mob, 'hit', None) + mana_dice = getattr(mob, 'mana', None) + damage_dice = getattr(mob, 'damage', None) + + # Get AC (ROM has 4-value AC) + ac_obj = getattr(mob, 'ac', None) + + # Get resistance flags + imm_flags = normalize_flags(getattr(mob, 'imm_flags', 0)) + res_flags = normalize_flags(getattr(mob, 'res_flags', 0)) + vuln_flags = normalize_flags(getattr(mob, 'vuln_flags', 0)) + + # Get body flags + form_flags = normalize_flags(getattr(mob, 'form', 0)) + parts_flags = normalize_flags(getattr(mob, 'parts', 0)) + + # Get mprogs + mprogs = getattr(mob, 'mprogs', []) + programs = [] + for mp in mprogs: + programs.append(self.converter.unstructure(mp)) + + return NormalizedMob( + vnum=getattr(mob, 'vnum', 0), + keywords=keywords, + short_desc=getattr(mob, 'short_desc', ''), + long_desc=getattr(mob, 'long_desc', ''), + description=getattr(mob, 'description', ''), + level=getattr(mob, 'level', 1), + alignment=getattr(mob, 'alignment', 0), + sex=sex, + race=getattr(mob, 'race', 'human') or 'human', + act_flags=normalize_flags(getattr(mob, 'act', 0)), + affect_flags=normalize_flags(getattr(mob, 'affected_by', 0)), + hitroll=getattr(mob, 'hitroll', 0), + ac=NormalizedArmorClass.from_rom(ac_obj) if ac_obj else NormalizedArmorClass(), + hit_dice=NormalizedDice( + num=getattr(hit_dice, 'number', 0) if hit_dice else 0, + size=getattr(hit_dice, 'sides', 0) if hit_dice else 0, + bonus=getattr(hit_dice, 'bonus', 0) if hit_dice else 0 + ), + mana_dice=NormalizedDice( + num=getattr(mana_dice, 'number', 0) if mana_dice else 0, + size=getattr(mana_dice, 'sides', 0) if mana_dice else 0, + bonus=getattr(mana_dice, 'bonus', 0) if mana_dice else 0 + ), + damage_dice=NormalizedDice( + num=getattr(damage_dice, 'number', 0) if damage_dice else 0, + size=getattr(damage_dice, 'sides', 0) if damage_dice else 0, + bonus=getattr(damage_dice, 'bonus', 0) if damage_dice else 0 + ), + damage_type=getattr(mob, 'damtype', ''), + gold=getattr(mob, 'wealth', 0), + position={'default': def_pos, 'load': load_pos}, + resistances={'immune': imm_flags, 'resist': res_flags, 'vuln': vuln_flags}, + body={'form': form_flags, 'parts': parts_flags, 'size': size}, + offense_flags=normalize_flags(getattr(mob, 'off_flags', 0)), + programs=programs, + original=self.converter.unstructure(mob) + ) + + +class MercMobNormalizer(BaseMobNormalizer): + """Normalizer for Merc format mobs.""" + + def normalize(self, mob) -> NormalizedMob: + name = getattr(mob, 'name', '') + keywords = name.split() if name else [] + + # Merc uses numeric positions + start_pos = getattr(mob, 'start_pos', 8) + default_pos = getattr(mob, 'default_pos', 8) + load_pos = POSITION_MAP.get(start_pos, 'standing') + def_pos = POSITION_MAP.get(default_pos, 'standing') + + # Merc uses numeric sex + sex_raw = getattr(mob, 'sex', 0) + sex = SEX_MAP.get(sex_raw, 'none') + + # Merc has single-value AC + ac_val = getattr(mob, 'ac', 0) + + # Get dice + hit_dice = getattr(mob, 'hit', None) + damage_dice = getattr(mob, 'damage', None) + + return NormalizedMob( + vnum=getattr(mob, 'vnum', 0), + keywords=keywords, + short_desc=getattr(mob, 'short_desc', ''), + long_desc=getattr(mob, 'long_desc', ''), + description=getattr(mob, 'description', ''), + level=getattr(mob, 'level', 1), + alignment=getattr(mob, 'alignment', 0), + sex=sex, + race='human', # Merc doesn't have race + act_flags=normalize_flags(getattr(mob, 'act', 0)), + affect_flags=normalize_flags(getattr(mob, 'affected_by', 0)), + hitroll=getattr(mob, 'hitroll', 0), + ac=NormalizedArmorClass.from_single(ac_val), + hit_dice=NormalizedDice( + num=getattr(hit_dice, 'number', 0) if hit_dice else 0, + size=getattr(hit_dice, 'sides', 0) if hit_dice else 0, + bonus=getattr(hit_dice, 'bonus', 0) if hit_dice else 0 + ), + mana_dice=NormalizedDice(), # Merc doesn't have mana dice + damage_dice=NormalizedDice( + num=getattr(damage_dice, 'number', 0) if damage_dice else 0, + size=getattr(damage_dice, 'sides', 0) if damage_dice else 0, + bonus=getattr(damage_dice, 'bonus', 0) if damage_dice else 0 + ), + damage_type='', + gold=getattr(mob, 'wealth', 0), + position={'default': def_pos, 'load': load_pos}, + resistances={'immune': [], 'resist': [], 'vuln': []}, + body={'form': [], 'parts': [], 'size': 'medium'}, + offense_flags=[], + programs=[], + original=self.converter.unstructure(mob) + ) + + +class CircleMudMobNormalizer(BaseMobNormalizer): + """Normalizer for CircleMUD format mobs.""" + + def normalize(self, mob) -> NormalizedMob: + aliases = getattr(mob, 'aliases', []) + keywords = aliases if isinstance(aliases, list) else aliases.split() if aliases else [] + + # CircleMUD uses numeric positions + load_pos = POSITION_MAP.get(getattr(mob, 'load_position', 8), 'standing') + def_pos = POSITION_MAP.get(getattr(mob, 'default_position', 8), 'standing') + + # CircleMUD uses numeric sex + sex_raw = getattr(mob, 'sex', 0) + sex = SEX_MAP.get(sex_raw, 'none') + + # CircleMUD uses THAC0 instead of hitroll + thac0 = getattr(mob, 'thac0', 20) + hitroll = thac0_to_hitroll(thac0) + + # Single AC value + ac_val = getattr(mob, 'ac', 10) + + # Get dice (CircleMUD uses dicts) + hit_dice = getattr(mob, 'hit_dice', {}) + damage_dice = getattr(mob, 'damage_dice', {}) + + return NormalizedMob( + vnum=int(getattr(mob, 'vnum', 0)) if getattr(mob, 'vnum', 0) else 0, + keywords=keywords, + short_desc=getattr(mob, 'short_desc', ''), + long_desc=getattr(mob, 'long_desc', ''), + description=getattr(mob, 'description', ''), + level=getattr(mob, 'level', 1), + alignment=getattr(mob, 'alignment', 0), + sex=sex, + race='human', # CircleMUD doesn't have race in base format + act_flags=self._normalize_circle_flags(getattr(mob, 'action_flags', 0)), + affect_flags=self._normalize_circle_flags(getattr(mob, 'affect_flags', 0)), + hitroll=hitroll, + ac=NormalizedArmorClass.from_single(ac_val), + hit_dice=NormalizedDice.from_dict(hit_dice), + mana_dice=NormalizedDice(), + damage_dice=NormalizedDice.from_dict(damage_dice), + damage_type='', + gold=getattr(mob, 'gold', 0), + position={'default': def_pos, 'load': load_pos}, + resistances={'immune': [], 'resist': [], 'vuln': []}, + body={'form': [], 'parts': [], 'size': 'medium'}, + offense_flags=[], + programs=[], + original=self.converter.unstructure(mob) + ) + + def _normalize_circle_flags(self, value) -> List[str]: + """Normalize CircleMUD bitvector flags.""" + if not value: + return [] + + # CircleMUD flag mappings + from circlemud import MOB_ACTION_FLAGS, MOB_AFFECT_FLAGS + + flags = [] + for bit, name in MOB_ACTION_FLAGS.items(): + if value & bit: + flags.append(name.lower()) + return flags + + +class BaseItemNormalizer: + """Base class for item normalizers.""" + + def __init__(self, converter=None): + self.converter = converter or OriginalConverter() + + def normalize(self, item) -> NormalizedItem: + raise NotImplementedError + + +class RomItemNormalizer(BaseItemNormalizer): + """Normalizer for ROM format items.""" + + def normalize(self, item) -> NormalizedItem: + name = getattr(item, 'name', '') + keywords = name.split() if name else [] + + # Normalize item type (ROM uses strings) + item_type = getattr(item, 'item_type', '') + if isinstance(item_type, str): + item_type_norm = item_type.lower() + else: + item_type_norm = ITEM_TYPE_MAP.get(item_type, str(item_type)) + + # Get extra descriptions + extra_descs = [] + for ed in getattr(item, 'extra_descriptions', []): + extra_descs.append({ + 'keywords': getattr(ed, 'keyword', '').split() if hasattr(ed, 'keyword') else [], + 'description': getattr(ed, 'description', '') + }) + + # Get affects + affects = [] + for af in getattr(item, 'affected', []): + affects.append(self.converter.unstructure(af)) + + return NormalizedItem( + vnum=getattr(item, 'vnum', 0), + keywords=keywords, + short_desc=getattr(item, 'short_desc', ''), + long_desc=getattr(item, 'description', ''), + item_type=item_type_norm, + extra_flags=normalize_flags(getattr(item, 'extra_flags', 0)), + wear_flags=normalize_flags(getattr(item, 'wear_flags', 0)), + weight=getattr(item, 'weight', 0), + cost=getattr(item, 'cost', 0), + level=getattr(item, 'level', 0), + condition=getattr(item, 'condition', 100), + material=getattr(item, 'material', ''), + values=list(getattr(item, 'value', [])), + affects=affects, + extra_descriptions=extra_descs, + original=self.converter.unstructure(item) + ) + + +class MercItemNormalizer(BaseItemNormalizer): + """Normalizer for Merc format items.""" + + def normalize(self, item) -> NormalizedItem: + name = getattr(item, 'name', '') + keywords = name.split() if name else [] + + # Merc uses numeric item types + item_type = getattr(item, 'item_type', 0) + item_type_norm = ITEM_TYPE_MAP.get(item_type, str(item_type)) + + # Get extra descriptions + extra_descs = [] + for ed in getattr(item, 'extra_descriptions', []): + extra_descs.append({ + 'keywords': getattr(ed, 'keyword', '').split() if hasattr(ed, 'keyword') else [], + 'description': getattr(ed, 'description', '') + }) + + # Get affects + affects = [] + for af in getattr(item, 'affected', []): + affects.append(self.converter.unstructure(af)) + + return NormalizedItem( + vnum=getattr(item, 'vnum', 0), + keywords=keywords, + short_desc=getattr(item, 'short_desc', ''), + long_desc=getattr(item, 'description', ''), + item_type=item_type_norm, + extra_flags=normalize_flags(getattr(item, 'extra_flags', 0)), + wear_flags=normalize_flags(getattr(item, 'wear_flags', 0)), + weight=getattr(item, 'weight', 0), + cost=getattr(item, 'cost', 0), + level=getattr(item, 'level', 0), + condition=100, # Merc doesn't have condition + material='', # Merc doesn't have material + values=list(getattr(item, 'value', [])), + affects=affects, + extra_descriptions=extra_descs, + original=self.converter.unstructure(item) + ) + + +class CircleMudItemNormalizer(BaseItemNormalizer): + """Normalizer for CircleMUD format items.""" + + def normalize(self, item) -> NormalizedItem: + aliases = getattr(item, 'aliases', []) + keywords = aliases if isinstance(aliases, list) else aliases.split() if aliases else [] + + # CircleMUD uses numeric item types + item_type = getattr(item, 'item_type', 0) + # Map CircleMUD item types + from circlemud import ITEM_TYPES + item_type_norm = ITEM_TYPES.get(item_type, str(item_type)).lower() + + # Get extra descriptions + extra_descs = [] + for ed in getattr(item, 'extra_descriptions', []): + extra_descs.append({ + 'keywords': getattr(ed, 'keywords', []), + 'description': getattr(ed, 'description', '') + }) + + # Get affects + affects = list(getattr(item, 'affects', [])) + + return NormalizedItem( + vnum=int(getattr(item, 'vnum', 0)) if getattr(item, 'vnum', 0) else 0, + keywords=keywords, + short_desc=getattr(item, 'short_desc', ''), + long_desc=getattr(item, 'long_desc', ''), + item_type=item_type_norm, + extra_flags=self._normalize_circle_flags(getattr(item, 'extra_flags', 0)), + wear_flags=self._normalize_circle_flags(getattr(item, 'wear_flags', 0)), + weight=getattr(item, 'weight', 0), + cost=getattr(item, 'cost', 0), + level=0, # CircleMUD doesn't have item level in base format + condition=100, + material='', + values=list(getattr(item, 'values', [])), + affects=affects, + extra_descriptions=extra_descs, + original=self.converter.unstructure(item) + ) + + def _normalize_circle_flags(self, value) -> List[str]: + """Normalize CircleMUD bitvector flags to list of names.""" + if not value: + return [] + + from circlemud import EXTRA_FLAGS, WEAR_FLAGS + + flags = [] + # Try both flag dicts + for flag_dict in [EXTRA_FLAGS, WEAR_FLAGS]: + for bit, name in flag_dict.items(): + if value & bit: + if name.lower() not in flags: + flags.append(name.lower()) + return flags + + +class BaseRoomNormalizer: + """Base class for room normalizers.""" + + def __init__(self, converter=None): + self.converter = converter or OriginalConverter() + + def normalize(self, room) -> NormalizedRoom: + raise NotImplementedError + + +class RomRoomNormalizer(BaseRoomNormalizer): + """Normalizer for ROM format rooms.""" + + def normalize(self, room) -> NormalizedRoom: + # Normalize exits + exits = [] + for ex in getattr(room, 'exits', []): + direction = getattr(ex, 'door', 0) + if hasattr(direction, 'value'): + direction = direction.value + dir_name = DIRECTION_MAP.get(direction, str(direction)) + + exits.append(NormalizedExit( + direction=dir_name, + destination=getattr(ex, 'destination', -1), + description=getattr(ex, 'description', ''), + keywords=getattr(ex, 'keyword', '').split() if getattr(ex, 'keyword', '') else [], + flags=normalize_exit_flags(getattr(ex, 'exit_info', 0)), + key_vnum=getattr(ex, 'key', -1) + )) + + # Normalize sector type + sector = getattr(room, 'sector_type', 0) + if hasattr(sector, 'value'): + sector = sector.value + if hasattr(sector, 'name'): + sector_name = sector.name.lower() + else: + sector_name = SECTOR_MAP.get(sector, str(sector)) + + # Get extra descriptions + extra_descs = [] + for ed in getattr(room, 'extra_descriptions', []): + extra_descs.append({ + 'keywords': getattr(ed, 'keyword', '').split() if hasattr(ed, 'keyword') else [], + 'description': getattr(ed, 'description', '') + }) + + return NormalizedRoom( + vnum=getattr(room, 'vnum', 0), + name=getattr(room, 'name', ''), + description=getattr(room, 'description', ''), + room_flags=normalize_room_flags(getattr(room, 'room_flags', 0)), + sector_type=sector_name, + exits=[self.converter.unstructure(e) for e in exits], + extra_descriptions=extra_descs, + heal_rate=getattr(room, 'heal_rate', 100), + mana_rate=getattr(room, 'mana_rate', 100), + original=self.converter.unstructure(room) + ) + + +class CircleMudRoomNormalizer(BaseRoomNormalizer): + """Normalizer for CircleMUD format rooms.""" + + def normalize(self, room) -> NormalizedRoom: + # Normalize exits + exits = [] + for ex in getattr(room, 'exits', []): + direction = getattr(ex, 'direction', 0) + dir_name = DIRECTION_MAP.get(direction, str(direction)) + + exits.append(NormalizedExit( + direction=dir_name, + destination=int(getattr(ex, 'destination', -1)) if getattr(ex, 'destination', -1) != '' else -1, + description=getattr(ex, 'description', ''), + keywords=getattr(ex, 'keywords', []), + flags=normalize_exit_flags(getattr(ex, 'door_flags', 0)), + key_vnum=int(getattr(ex, 'key_vnum', -1)) if getattr(ex, 'key_vnum', -1) != '' else -1 + )) + + # Normalize sector type + sector = getattr(room, 'sector_type', 0) + sector_name = SECTOR_MAP.get(sector, str(sector)) + + # Normalize room flags + from circlemud import ROOM_FLAGS + flags = [] + room_flags_val = getattr(room, 'room_flags', 0) + for bit, name in ROOM_FLAGS.items(): + if room_flags_val & bit: + flags.append(name.lower()) + + # Get extra descriptions + extra_descs = [] + for ed in getattr(room, 'extra_descriptions', []): + extra_descs.append({ + 'keywords': getattr(ed, 'keywords', []), + 'description': getattr(ed, 'description', '') + }) + + return NormalizedRoom( + vnum=int(getattr(room, 'vnum', 0)) if getattr(room, 'vnum', 0) else 0, + name=getattr(room, 'name', ''), + description=getattr(room, 'description', ''), + room_flags=flags, + sector_type=sector_name, + exits=[self.converter.unstructure(e) for e in exits], + extra_descriptions=extra_descs, + heal_rate=100, + mana_rate=100, + original=self.converter.unstructure(room) + ) + + +# ============================================================================ +# Main Normalizer +# ============================================================================ + +class AreaNormalizer: + """Main normalizer that converts an area file to normalized format.""" + + def __init__(self, area_file): + self.area_file = area_file + self.format = self._detect_format() + self.converter = OriginalConverter() + + # Select appropriate normalizers based on format + self._setup_normalizers() + + def _detect_format(self) -> str: + """Detect the format of the area file.""" + class_name = type(self.area_file).__name__ + + if 'CircleMud' in class_name: + return 'circlemud' + elif 'Merc' in class_name: + return 'merc' + elif 'Smaug' in class_name: + return 'smaug' + elif 'Rot' in class_name: + return 'rot' + elif 'Envy' in class_name: + return 'envy' + else: + return 'rom' + + def _setup_normalizers(self): + """Set up the appropriate normalizers for the detected format.""" + if self.format == 'circlemud': + self.mob_normalizer = CircleMudMobNormalizer(self.converter) + self.item_normalizer = CircleMudItemNormalizer(self.converter) + self.room_normalizer = CircleMudRoomNormalizer(self.converter) + elif self.format == 'merc': + self.mob_normalizer = MercMobNormalizer(self.converter) + self.item_normalizer = MercItemNormalizer(self.converter) + self.room_normalizer = RomRoomNormalizer(self.converter) # Merc rooms are similar to ROM + else: + # ROM, SMAUG, ROT, Envy all use similar structures + self.mob_normalizer = RomMobNormalizer(self.converter) + self.item_normalizer = RomItemNormalizer(self.converter) + self.room_normalizer = RomRoomNormalizer(self.converter) + + def normalize(self) -> NormalizedArea: + """Convert the area file to normalized format.""" + area = self.area_file.area + + # Extract metadata + name = getattr(area, 'name', '') + builders = getattr(area, 'metadata', '') + + # Get vnum range + first_vnum = getattr(area, 'first_vnum', 0) + last_vnum = getattr(area, 'last_vnum', 0) + if first_vnum == -1: + first_vnum = 0 + if last_vnum == -1: + last_vnum = 0 + + # Normalize mobs + mobs = {} + area_mobs = getattr(area, 'mobs', {}) + partial_mob_normalizer = PartialMobNormalizer(self.converter) + for vnum, mob in area_mobs.items(): + try: + # Check if this is a partially parsed object + if getattr(mob, '_partial', False): + mobs[int(vnum)] = partial_mob_normalizer.normalize(mob) + else: + mobs[int(vnum)] = self.mob_normalizer.normalize(mob) + except Exception as e: + # Log but continue + pass + + # Normalize objects + objects = {} + area_objects = getattr(area, 'objects', {}) + partial_item_normalizer = PartialItemNormalizer(self.converter) + for vnum, obj in area_objects.items(): + try: + # Check if this is a partially parsed object + if getattr(obj, '_partial', False): + objects[int(vnum)] = partial_item_normalizer.normalize(obj) + else: + objects[int(vnum)] = self.item_normalizer.normalize(obj) + except Exception as e: + pass + + # Normalize rooms + rooms = {} + area_rooms = getattr(area, 'rooms', {}) + for vnum, room in area_rooms.items(): + try: + rooms[int(vnum)] = self.room_normalizer.normalize(room) + except Exception as e: + pass + + # Convert resets, shops, specials, helps + resets = [self.converter.unstructure(r) for r in getattr(area, 'resets', [])] + shops = [self.converter.unstructure(s) for s in getattr(area, 'shops', [])] + specials = [self.converter.unstructure(s) for s in getattr(area, 'specials', [])] + helps = [self.converter.unstructure(h) for h in getattr(area, 'helps', [])] + + # Get source file + source_file = getattr(self.area_file, 'filename', '') + if not source_file: + source_file = getattr(self.area_file, 'directory', '') + + return NormalizedArea( + name=name, + builders=builders, + level_range=[0, 0], # Not always available + vnum_range=[first_vnum, last_vnum], + mobs=mobs, + objects=objects, + rooms=rooms, + resets=resets, + shops=shops, + specials=specials, + helps=helps, + meta={ + 'source_format': self.format, + 'source_file': source_file + } + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2cf3f2a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "area-reader" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [ + "attrs>=25.4.0", + "cattrs>=25.3.0", + "pytest>=9.0.2", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index bff153f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -attrs -cattrs \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2eb7ed0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,112 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "area-reader" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", specifier = ">=25.4.0" }, + { name = "cattrs", specifier = ">=25.3.0" }, + { name = "pytest", specifier = ">=9.0.2" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "cattrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/validate_samples.py b/validate_samples.py new file mode 100644 index 0000000..3e26962 --- /dev/null +++ b/validate_samples.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Sample validation script for converted MUD area files. + +Extracts random samples from JSON files for human/AI review to verify +the conversion captured the content correctly. +""" + +import json +import os +import random +import sys +from pathlib import Path + + +def get_random_items(d, key, count=2): + """Get random items from a dict or list.""" + if not d.get(key): + return [] + items = d[key] + if isinstance(items, dict): + keys = list(items.keys()) + if len(keys) <= count: + return [(k, items[k]) for k in keys] + selected = random.sample(keys, count) + return [(k, items[k]) for k in selected] + elif isinstance(items, list): + if len(items) <= count: + return items + return random.sample(items, count) + return [] + + +def sample_area_file(json_path): + """Extract samples from a converted area file.""" + with open(json_path) as f: + data = json.load(f) + + samples = { + 'file': os.path.basename(json_path), + 'source_format': data.get('_source_format', 'unknown'), + 'area_name': data.get('name', 'Unknown'), + } + + # Sample rooms + rooms = get_random_items(data, 'rooms', 2) + if rooms: + samples['rooms'] = [] + for vnum, room in rooms: + samples['rooms'].append({ + 'vnum': vnum, + 'name': room.get('name'), + 'description': room.get('description', room.get('desc', ''))[:200] + '...' if room.get('description', room.get('desc', '')) and len(room.get('description', room.get('desc', ''))) > 200 else room.get('description', room.get('desc', '')), + 'exits': [{'dir': e.get('door', e.get('dir')), 'to': e.get('destination', e.get('room_linked'))} for e in room.get('exits', [])][:3], + }) + + # Sample mobs + mobs = get_random_items(data, 'mobs', 2) + if mobs: + samples['mobs'] = [] + for vnum, mob in mobs: + samples['mobs'].append({ + 'vnum': vnum, + 'name': mob.get('name', mob.get('short_desc', 'Unknown')), + 'short_desc': mob.get('short_desc'), + 'level': mob.get('level'), + 'description': (mob.get('description', '') or '')[:150] + '...' if mob.get('description') and len(mob.get('description', '')) > 150 else mob.get('description'), + }) + + # Sample objects + objects = get_random_items(data, 'objects', 2) + if objects: + samples['objects'] = [] + for vnum, obj in objects: + samples['objects'].append({ + 'vnum': vnum, + 'name': obj.get('name', obj.get('short_desc', 'Unknown')), + 'short_desc': obj.get('short_desc'), + 'item_type': obj.get('item_type'), + 'description': (obj.get('description', '') or '')[:150] + '...' if obj.get('description') and len(obj.get('description', '')) > 150 else obj.get('description'), + }) + + # Sample resets + resets = get_random_items(data, 'resets', 3) + if resets: + samples['resets'] = resets + + # Zone info for CircleMUD + if 'zone' in data: + samples['zone'] = { + 'name': data['zone'].get('name'), + 'lifespan': data['zone'].get('lifespan'), + 'reset_mode': data['zone'].get('reset_mode'), + } + + return samples + + +def main(): + import argparse + parser = argparse.ArgumentParser(description='Sample and validate converted area files') + parser.add_argument('--json-dir', default='../json', + help='Directory containing JSON files') + parser.add_argument('--count', type=int, default=10, + help='Number of files to sample') + parser.add_argument('--output', default=None, + help='Output file for samples (default: stdout)') + parser.add_argument('--format', choices=['json', 'text'], default='text', + help='Output format') + parser.add_argument('--seed', type=int, default=None, + help='Random seed for reproducibility') + args = parser.parse_args() + + if args.seed: + random.seed(args.seed) + + script_dir = Path(__file__).parent + json_dir = (script_dir / args.json_dir).resolve() + + json_files = list(json_dir.glob('*.json')) + json_files = [f for f in json_files if f.name != 'conversion_stats.json'] + + if not json_files: + print(f"No JSON files found in {json_dir}") + sys.exit(1) + + # Sample files + if len(json_files) <= args.count: + selected = json_files + else: + selected = random.sample(json_files, args.count) + + all_samples = [] + for json_path in selected: + try: + samples = sample_area_file(json_path) + all_samples.append(samples) + except Exception as e: + all_samples.append({ + 'file': json_path.name, + 'error': str(e) + }) + + # Output + if args.format == 'json': + output = json.dumps(all_samples, indent=2) + else: + lines = [] + for s in all_samples: + lines.append("=" * 60) + lines.append(f"FILE: {s.get('file')}") + lines.append(f"FORMAT: {s.get('source_format', 'unknown')}") + lines.append(f"AREA: {s.get('area_name', 'Unknown')}") + + if 'error' in s: + lines.append(f"ERROR: {s['error']}") + continue + + if s.get('rooms'): + lines.append("\n--- SAMPLE ROOMS ---") + for room in s['rooms']: + lines.append(f" [{room['vnum']}] {room['name']}") + lines.append(f" {room['description']}") + if room.get('exits'): + exits_str = ', '.join(f"{e['dir']}->{e['to']}" for e in room['exits']) + lines.append(f" Exits: {exits_str}") + + if s.get('mobs'): + lines.append("\n--- SAMPLE MOBS ---") + for mob in s['mobs']: + lines.append(f" [{mob['vnum']}] {mob['name']} (Level {mob.get('level', '?')})") + if mob.get('short_desc'): + lines.append(f" Short: {mob['short_desc']}") + if mob.get('description'): + lines.append(f" Desc: {mob['description']}") + + if s.get('objects'): + lines.append("\n--- SAMPLE OBJECTS ---") + for obj in s['objects']: + lines.append(f" [{obj['vnum']}] {obj['name']} ({obj.get('item_type', '?')})") + if obj.get('short_desc'): + lines.append(f" Short: {obj['short_desc']}") + + if s.get('resets'): + lines.append("\n--- SAMPLE RESETS ---") + for reset in s['resets'][:3]: + lines.append(f" {reset}") + + if s.get('zone'): + lines.append(f"\n--- ZONE INFO ---") + lines.append(f" Name: {s['zone'].get('name')}") + lines.append(f" Lifespan: {s['zone'].get('lifespan')}") + + lines.append("") + output = '\n'.join(lines) + + if args.output: + with open(args.output, 'w') as f: + f.write(output) + print(f"Samples written to {args.output}") + else: + print(output) + + +if __name__ == '__main__': + main()