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