1#!/usr/local/bin/python3.8 2 3""" 4Generate species-data.h, aptitudes.h, species-groups.h, and species-type.h 5 6Works with both Python 2 & 3. If that changes, update how the Makefile calls 7this. 8""" 9 10from __future__ import print_function 11 12import argparse 13import os 14import sys 15import traceback 16import re 17import collections 18if sys.version_info.major == 2: 19 from collections import MutableMapping 20else: 21 from collections.abc import MutableMapping 22 23import yaml # pip install pyyaml 24 25def quote_or_nullptr(key, d): 26 if key in d: 27 return quote(d[key]) 28 else: 29 return 'nullptr' 30 31class Species(MutableMapping): 32 """Parser for YAML definition files. 33 34 If any YAML content is invalid, the relevant parser function below should 35 raise ValueError. 36 """ 37 38 # TODO: unify with processing in from_yaml 39 YAML_MAIN_FIELDS = {'TAG_MAJOR_VERSION', 'fake_mutations', 'difficulty', 40 'recommended_jobs', 'enum', 'monster', 'name', 'short_name', 41 'adjective', 'genus', 'species_flags', 'aptitudes', 'can_swim', 42 'undead_type', 'size', 'str', 'int', 'dex', 'levelup_stats', 43 'levelup_stat_frequency', 'recommended_jobs', 'recommended_weapons', 44 'difficulty', 'difficulty_priority', 'create_enum', 'walking_verb', 45 'altar_action', 'mutations'} 46 47 def __init__(self, yaml_dict): 48 self.backing_dict = dict() 49 self.from_yaml(yaml_dict) 50 51 def __getitem__(self, key): 52 return self.backing_dict[key] 53 54 def __setitem__(self, key, val): 55 self.backing_dict[key] = val 56 57 def __delitem__(self, key): 58 del self.backing_dict[key] 59 60 def __iter__(self): 61 return iter(self.backing_dict) 62 63 def __len__(self): 64 return len(self.store) 65 66 def set_recommended_weapons(self, weapons): 67 if not self.starting_species: 68 self.backing_dict['recommended_weapons'] = "" 69 return 70 if not weapons: 71 weapons = list(ALL_WEAPON_SKILLS) 72 weapons.remove('SK_SHORT_BLADES') 73 weapons.remove('SK_UNARMED_COMBAT') 74 self.backing_dict['recommended_weapons'] = ', '.join( 75 validate_string(weap, 'Weapon Skill', 'SK_[A-Z_]+') 76 for weap in weapons) 77 78 def print_unknown_warnings(self, s): 79 for key in s: 80 if key not in self.YAML_MAIN_FIELDS: 81 print("species_gen.py warning: Unknown field '%s' in species %s" 82 % (key, self['enum']), file=sys.stderr) 83 84 def levelup_stats_from_yaml(self, s): 85 self['levelup_stats'] = levelup_stats(s.get('levelup_stats', "default")) 86 self['levelup_stat_frequency'] = validate_int_range( 87 s['levelup_stat_frequency'], 'levelup_stat_frequency', 0, 28) 88 89 if (self['levelup_stats'] == empty_set("stat_type") 90 and self['levelup_stat_frequency'] < 28): 91 print("species_gen.py warning: species %s has empty levelup_stats" 92 " but a <28 levelup_stat_frequency." 93 % (self['enum']), file=sys.stderr) 94 95 if (self['levelup_stats'] != empty_set("stat_type") 96 and self['levelup_stat_frequency'] > 27): 97 print("species_gen.py warning: species %s has non-empty" 98 " levelup_stats but a levelup_stat_frequency that is > 27." 99 % (self['enum']), file=sys.stderr) 100 101 def from_yaml(self, s): 102 # Pre-validation 103 if s.get('TAG_MAJOR_VERSION', None) is not None: 104 if not isinstance(s['TAG_MAJOR_VERSION'], int): 105 raise ValueError('TAG_MAJOR_VERSION must be an integer') 106 if not isinstance(s.get('fake_mutations', []), list): 107 raise ValueError('fake_mutations must be a list') 108 self.starting_species = s.get('difficulty') != False 109 has_recommended_jobs = bool(s.get('recommended_jobs')) 110 if self.starting_species != has_recommended_jobs: 111 raise ValueError('recommended_jobs must not be empty (or' 112 ' difficulty must be False)') 113 114 # Set attributes 115 self['enum'] = validate_string(s['enum'], 'enum', 'SP_[A-Z_]+$') 116 self['monster_name'] = validate_string(s['monster'], 'monster', 117 'MONS_[A-Z_]+$') 118 self['name'] = validate_string(s['name'], 'name', '..+') 119 self['short_name'] = s.get('short_name', s['name'][:2]) 120 self['adjective'] = quote_or_nullptr('adjective', s) 121 self['genus'] = quote_or_nullptr('genus', s) 122 self['species_flags'] = species_flags(s.get('species_flags', [])) 123 self['xp'] = validate_int_range(s['aptitudes']['xp'], 'xp', -10, 10) 124 self['hp'] = validate_int_range(s['aptitudes']['hp'], 'hp', -10, 10) 125 self['mp'] = validate_int_range(s['aptitudes']['mp_mod'], 'mp_mod', 126 -5, 20) 127 self['mr'] = validate_int_range(s['aptitudes']['mr'], 'mr', 0, 20) 128 self['aptitudes'] = aptitudes(s['aptitudes']) 129 self['habitat'] = 'HT_LAND' if not s.get('can_swim') else 'HT_WATER' 130 self['undead'] = undead_type(s.get('undead_type', 'US_ALIVE')) 131 self['size'] = size(s.get('size', 'medium')) 132 self['str'] = validate_int_range(s['str'], 'str', 1, 100) 133 self['int'] = validate_int_range(s['int'], 'int', 1, 100) 134 self['dex'] = validate_int_range(s['dex'], 'dex', 1, 100) 135 self.levelup_stats_from_yaml(s) 136 self['mutations'] = mutations(s.get('mutations', {})) 137 self['fake_mutations_long'] = fake_mutations_long( 138 s.get('fake_mutations', [])) 139 self['fake_mutations_short'] = fake_mutations_short( 140 s.get('fake_mutations', [])) 141 self['recommended_jobs'] = recommended_jobs( 142 s.get('recommended_jobs', [])) 143 self.set_recommended_weapons(s.get('recommended_weapons', [])) 144 self['difficulty'] = difficulty(s.get('difficulty')) 145 self['difficulty_priority'] = validate_int_range(difficulty_priority( 146 s.get('difficulty_priority', 0)), 'difficulty_priority', 0, 1000) 147 self['create_enum'] = validate_bool( 148 s.get('create_enum', True), 'create_enum') 149 self['walking_verb'] = quote_or_nullptr('walking_verb', s) 150 self['altar_action'] = quote_or_nullptr('altar_action', s) 151 152 if 'TAG_MAJOR_VERSION' in s: 153 self['tag_major_version_opener'] = ( 154 "#if TAG_MAJOR_VERSION == %s" % s['TAG_MAJOR_VERSION']) 155 self['tag_major_version_closer'] = "#endif" 156 else: 157 self['tag_major_version_opener'] = '' 158 self['tag_major_version_closer'] = '' 159 self.print_unknown_warnings(s) 160 161SpeciesGroup = collections.namedtuple('SpeciesGroup', 162 ['position', 'width', 'species']) 163SpeciesGroupEntry = collections.namedtuple('SpeciesGroupEntry', 164 ['priority', 'enum']) 165SPECIES_GROUPS_TEMPLATE = collections.OrderedDict() 166SPECIES_GROUPS_TEMPLATE['Simple'] = SpeciesGroup('coord_def(0, 0)', '50', []) 167SPECIES_GROUPS_TEMPLATE['Intermediate'] = SpeciesGroup('coord_def(1, 0)', '20', []) 168SPECIES_GROUPS_TEMPLATE['Advanced'] = SpeciesGroup('coord_def(2, 0)', '20', []) 169SPECIES_GROUP_TEMPLATE = """ 170 {{ 171 "{name}", 172 {position}, 173 {width}, 174 {{ {species} }} 175 }}, 176""" 177ALL_APTITUDES = ('fighting', 'short_blades', 'long_blades', 'axes', 178 'maces_and_flails', 'polearms', 'staves', 'slings', 'bows', 'crossbows', 179 'throwing', 'armour', 'dodging', 'stealth', 'shields', 'unarmed_combat', 180 'spellcasting', 'conjurations', 'hexes', 'summoning', 181 'necromancy', 'transmutations', 'translocations', 'fire_magic', 182 'ice_magic', 'air_magic', 'earth_magic', 'poison_magic', 'invocations', 183 'evocations') 184UNDEAD_TYPES = ('US_ALIVE', 'US_UNDEAD', 'US_SEMI_UNDEAD') 185SIZES = ('SIZE_TINY', 'SIZE_LITTLE', 'SIZE_SMALL', 'SIZE_MEDIUM', 'SIZE_LARGE', 186 'SIZE_BIG', 'SIZE_GIANT') 187ALL_STATS = ('str', 'int', 'dex') 188ALL_WEAPON_SKILLS = ('SK_SHORT_BLADES', 'SK_LONG_BLADES', 'SK_AXES', 189 'SK_MACES_FLAILS', 'SK_POLEARMS', 'SK_STAVES', 'SK_SLINGS', 'SK_BOWS', 190 'SK_CROSSBOWS', 'SK_UNARMED_COMBAT') 191 192ALL_SPECIES_FLAGS = {'SPF_NO_HAIR', 'SPF_DRACONIAN', 'SPF_SMALL_TORSO', 193 'SPF_NO_BONES', 'SPF_BARDING'} 194 195def recommended_jobs(jobs): 196 return ', '.join(validate_string(job, 'Job', 'JOB_[A-Z_]+') for job in jobs) 197 198 199def validate_string(val, name, pattern): 200 ''' 201 Validate a string. 202 203 Note that re.match anchors to the start of the string, so you don't need to 204 prefix the pattern with '^'. But it doesn't require matching to the end, so 205 you'll probably want to suffix '$'. 206 ''' 207 if not isinstance(val, str): 208 raise ValueError('%s isn\'t a string' % name) 209 if re.match(pattern, val): 210 return val 211 else: 212 raise ValueError('%s doesn\'t match pattern %s' % (val, pattern)) 213 return val 214 215 216def validate_bool(val, name): 217 '''Validate a boolean.''' 218 if not isinstance(val, bool): 219 raise ValueError('%s isn\'t a boolean' % name) 220 return val 221 222 223def validate_int_range(val, name, min, max): 224 if not isinstance(val, int): 225 raise ValueError('%s isn\'t an integer' % name) 226 if not min <= val <= max: 227 raise ValueError('%s isn\'t between %s and %s' % (name, min, max)) 228 return val 229 230 231def size(size): 232 val = "SIZE_%s" % size.upper() 233 if val not in SIZES: 234 raise ValueError('Size %s is invalid, pick one of tiny, little, ' 235 'small, medium, large, big, or giant') 236 return val 237 238 239def enumify(s): 240 return s.replace(' ', '_').upper() 241 242def quote(s): 243 if not isinstance(s, str): 244 raise ValueError('Expected a string but got %s' % repr(s)) 245 return '"%s"' % s 246 247def species_flags(flags): 248 global ALL_SPECIES_FLAGS 249 out = set() 250 for f in flags: 251 if f not in ALL_SPECIES_FLAGS: 252 raise ValueError("Unknown species flag %s" % f) 253 out.add(f) 254 if not out: 255 out.add('SPF_NONE') 256 return ' | '.join(out) 257 258 259def undead_type(type): 260 if type not in UNDEAD_TYPES: 261 raise ValueError('Unknown undead type %s' % type) 262 return type 263 264 265def levelup_stats(stats): 266 if stats == "default": 267 stats = ALL_STATS 268 else: 269 # this is pretty loose type checking because we don't want to make 270 # any assumptions about how yaml parser handles sequences. 271 if isinstance(stats, str): 272 raise ValueError( 273 "Expected `default` or list for levelup_stats, not `%s`" % stats) 274 for s in stats: 275 if s not in ALL_STATS: 276 raise ValueError('Unknown stat %s' % s) 277 if len(stats) == 0: 278 return empty_set("stat_type") 279 else: 280 return make_list(', '.join("STAT_%s" % s.upper() for s in stats)) 281 282global LIST_TEMPLATE 283LIST_TEMPLATE = """ {{ {list} }}""" 284 285def empty_set(typ): 286 return " set<%s>()" % typ 287 288def make_list(list_str): 289 global LIST_TEMPLATE 290 #TODO: add linebreaks + indents to obey 80 chars? 291 if len(list_str.strip()) == 0: 292 return " {}" 293 else: 294 return LIST_TEMPLATE.format(list=list_str) 295 296def mutations(mut_def): 297 out = [] 298 for xl, muts in sorted(mut_def.items()): 299 validate_int_range(xl, 'Mutation Level', 1, 27) 300 if not isinstance(muts, dict): 301 raise ValueError('Mutation key %s doesn\'t seem to have a valid ' 302 'map of {name: amount} entries' % xl) 303 for mut_name, amt in sorted(muts.items()): 304 validate_string(mut_name, 'Mutation Name', 'MUT_[A-Z_]+') 305 validate_int_range(amt, 'Mutation Amount', -3, 3) 306 out.append("{{ {mut_name}, {amt}, {xl} }}".format( 307 mut_name=mut_name, 308 xl=xl, 309 amt=amt, 310 )) 311 return make_list(', '.join(out)) 312 313def fake_mutations_long(fmut_def): 314 return make_list(', '.join(quote(m.get('long')) 315 for m in fmut_def if m.get('long'))) 316 317def fake_mutations_short(fmut_def): 318 return make_list(', '.join(quote(m.get('short')) 319 for m in fmut_def if m.get('short'))) 320 321def aptitudes(apts): 322 for apt, val in apts.items(): 323 if apt not in ALL_APTITUDES and apt not in ('xp', 'hp', 'mp_mod', 'mr'): 324 raise ValueError("Unknown aptitude (typo?): %s" % apt) 325 validate_int_range(val, apt, -10, 10) 326 return apts 327 328 329def difficulty(d): 330 if d not in SPECIES_GROUPS_TEMPLATE.keys() and d is not False: 331 raise ValueError("Unknown difficulty: %s" % d) 332 return d 333 334 335def difficulty_priority(prio): 336 try: 337 return int(prio) 338 except ValueError: 339 raise ValueError('difficulty_priority value "%s" is not an integer' % 340 prio) 341 342 343def generate_aptitudes_data(s, template): 344 """Convert a species in YAML representation to aptitudes.h format. 345 346 If any of the required data can't be loaded, ValueError is raised and 347 passed to the caller. 348 """ 349 # Now generate the aptitudes block. The default is 0. 350 # Note: We have to differentiate between 0 and 'False' aptitudes specified 351 # in YAML. The latter is UNUSABLE_SKILL. 352 aptitudes = {apt: 0 for apt in ALL_APTITUDES} 353 for apt, val in s['aptitudes'].items(): 354 if apt in ('xp', 'hp', 'mp_mod', 'mr'): 355 continue 356 if val is False: 357 aptitudes[apt] = 'UNUSABLE_SKILL' 358 else: 359 aptitudes[apt] = val 360 return template.format(enum = s['enum'], **aptitudes) 361 362 363def update_species_group(sg, s): 364 difficulty = s['difficulty'] 365 if difficulty is False: 366 # Don't add this species to the species select screen 367 return sg 368 entry = SpeciesGroupEntry(s['difficulty_priority'], s['enum']) 369 sg[difficulty].species.append(entry) 370 return sg 371 372 373def generate_species_groups(sg): 374 out = '' 375 for name, group in sg.items(): 376 out += SPECIES_GROUP_TEMPLATE.format( 377 name = name, 378 position = group.position, 379 width = group.width, 380 species = ', '.join( 381 e.enum for e in reversed(sorted(group.species))), 382 ) 383 return out 384 385 386def generate_species_type_data(s): 387 if s['create_enum'] == False: 388 return '' 389 else: 390 return ' %s,\n' % s['enum'] 391 392 393def load_template(templatedir, name): 394 return open(os.path.join(templatedir, name)).read() 395 396 397def main(): 398 parser = argparse.ArgumentParser(description='Generate species-data.h') 399 parser.add_argument('datadir', help='dat/species source dir') 400 parser.add_argument('templatedir', 401 help='util/species-gen template source dir') 402 parser.add_argument('species_data', help='species-data.h output file path') 403 parser.add_argument('aptitudes', help='aptitudes.h output file path') 404 parser.add_argument('species_groups', 405 help='species-groups.h output file path') 406 parser.add_argument('species_type', help='species-type.h output file path') 407 args = parser.parse_args() 408 409 # Validate args 410 if not os.path.isdir(args.datadir): 411 print('datadir isn\'t a directory') 412 sys.exit(1) 413 if not os.path.isdir(args.templatedir): 414 print('templatedir isn\'t a directory') 415 sys.exit(1) 416 417 # Load all species 418 all_species = [] 419 for f_name in sorted(os.listdir(args.datadir)): 420 if not f_name.endswith('.yaml'): 421 continue 422 f_path = os.path.join(args.datadir, f_name) 423 try: 424 species_spec = yaml.safe_load(open(f_path)) 425 except yaml.YAMLError as e: 426 print("Failed to load %s: %s" % (f_name, e)) 427 sys.exit(1) 428 429 try: 430 species = Species(species_spec) 431 except (ValueError, KeyError) as e: 432 print("Failed to load %s" % f_name) 433 traceback.print_exc() 434 sys.exit(1) 435 all_species.append(species) 436 437 # Generate code 438 species_data_out_text = load_template(args.templatedir, 439 'species-data-header.txt') 440 aptitudes_out_text = load_template(args.templatedir, 'aptitudes-header.txt') 441 species_type_out_text = load_template(args.templatedir, 442 'species-type-header.txt') 443 444 species_data_template = load_template(args.templatedir, 445 'species-data-species.txt') 446 aptitude_template = load_template(args.templatedir, 'aptitude-species.txt') 447 species_groups = SPECIES_GROUPS_TEMPLATE 448 for species in all_species: 449 # species-data.h 450 species_data_out_text += species_data_template.format(**species) 451 # aptitudes.h 452 aptitudes_out_text += generate_aptitudes_data(species, 453 aptitude_template) 454 # species-type.h 455 species_type_out_text += generate_species_type_data(species) 456 # species-groups.h 457 species_groups = update_species_group(species_groups, species) 458 459 species_data_out_text += load_template(args.templatedir, 460 'species-data-deprecated-species.txt') 461 species_data_out_text += load_template(args.templatedir, 462 'species-data-footer.txt') 463 with open(args.species_data, 'w') as f: 464 f.write(species_data_out_text) 465 466 aptitudes_out_text += load_template(args.templatedir, 467 'aptitudes-deprecated-species.txt') 468 aptitudes_out_text += load_template(args.templatedir, 469 'aptitudes-footer.txt') 470 with open(args.aptitudes, 'w') as f: 471 f.write(aptitudes_out_text) 472 473 species_type_out_text += load_template(args.templatedir, 474 'species-type-footer.txt') 475 with open(args.species_type, 'w') as f: 476 f.write(species_type_out_text) 477 478 species_groups_out_text = '' 479 species_groups_out_text += load_template(args.templatedir, 480 'species-groups-header.txt') 481 species_groups_out_text += generate_species_groups(species_groups) 482 species_groups_out_text += load_template(args.templatedir, 483 'species-groups-footer.txt') 484 with open(args.species_groups, 'w') as f: 485 f.write(species_groups_out_text) 486 487 488if __name__ == '__main__': 489 main() 490