/ npc.py
npc.py
1 #!/usr/bin/env python3 2 import argparse 3 import functools 4 import glob 5 import itertools 6 import json 7 import operator 8 import os 9 import random 10 11 ranks = ["Average", "Fair", "Good", "Great", "Superb", "Fantastic", "Epic", "Legendary"] 12 13 def shuffled(seq): 14 seq = list(seq) 15 random.shuffle(seq) 16 return seq 17 18 @functools.lru_cache(maxsize=None) 19 def json_resource(fn): 20 fn = os.path.join(os.path.dirname(__file__), fn) 21 with open(fn) as f: 22 return json.load(f) 23 24 def json_resource_set(fn): 25 return set(json_resource(fn)) 26 27 def choose_from_json(fn): 28 return random.choice(json_resource(fn)) 29 30 def choose_from_json_keys(fn): 31 return random.choice(json_resource(fn).keys()) 32 33 def choose_from_json_values(fn): 34 return random.choice(json_resource(fn).keys()) 35 36 37 feats = json_resource('skills.json') 38 feats.update(json_resource('skills-elder.json')) 39 40 all_skills = set(feats.keys()) 41 for skill in all_skills: 42 feats[skill] = set(f"{feat} [{skill}]" for feat in feats[skill]) 43 44 def skill_levels(): 45 for i in itertools.count(1): 46 for j in range(i): 47 yield j+1 48 49 def take(n, seq): 50 for i, j in zip(range(n), seq): 51 yield j 52 53 def skill_pyramid(n): 54 return sorted(take(n, skill_levels()), reverse=True) 55 56 class PropertyGenerator: 57 def __init__(self, maker, *, type=str, metavar="...", nargs='?'): 58 self._maker = maker 59 self._key = "_" + maker.__name__ 60 self._validate = None 61 self.type = type 62 self.metavar = metavar 63 self.nargs = nargs 64 self.__doc__ = maker.__doc__ 65 66 def validator(self, fn): 67 self._validate = fn 68 return self 69 70 def __get__(self, obj, type=None): 71 key = self._key 72 try: 73 return getattr(obj, key) 74 except AttributeError: 75 value = self._maker(obj) 76 if self._validate: 77 value = self._validate(obj, value) 78 setattr(obj, key, value) 79 return value 80 81 def __set__(self, obj, value): 82 if self._validate: 83 value = self._validate(obj, value) 84 setattr(obj, self._key, value) 85 86 def __delete__(self, obj): 87 delattr(obj, self._key) 88 89 def generated_property(fn=None, **kw): 90 if callable(fn): 91 return PropertyGenerator(fn) 92 def _inner(fn): 93 return PropertyGenerator(fn, **kw) 94 return _inner 95 96 class FateNPC: 97 def __init__(self, **kw): 98 for k, v in kw.items(): 99 setattr(self, k, v) 100 101 @generated_property 102 def gender(self): 103 return random.choice(("Male", "Female", "Nonbinary")) 104 105 @generated_property 106 def name_styles(self): 107 if random.random() > .1: 108 if self.gender == "Male": return ["unisex", "masculine"] 109 if self.gender == "Female": return ["unisex", "feminine"] 110 return ["unisex", "masculine", "feminine"] 111 112 @generated_property 113 def race(self): 114 return random.choice(("Altmer", "Argonian", "Bosmer", "Breton", "Dunmer", "Imperial", "Khajiit", "Nord", "Orsimer", "Redguard")) 115 116 @generated_property 117 def name_background(self): 118 if random.random() > .1: 119 return random.choice(("Altmer", "Argonian", "Bosmer", "Breton", "Dunmer", "Imperial", "Khajiit", "Nord", "Orsimer", "Redguard")) 120 return self.race 121 122 @generated_property 123 def names_json_file(self): 124 style = self.name_styles 125 random.shuffle(style) 126 choices = [] 127 for s in style: 128 pat = f"names/{self.race.lower()}-{s}*.json" 129 choices.extend(glob.glob(pat)) 130 return random.choice(choices) 131 132 @generated_property 133 def name(self): 134 return choose_from_json(self.names_json_file).title() 135 136 @generated_property(type=int) 137 def skill_cap(self): 138 return 4 139 140 @generated_property(nargs='*') 141 def skills(self): 142 # Filled out by validator 143 return [] 144 145 @skills.validator 146 def skills(self, value): 147 skills = set(value) 148 if len(skills) != len(value): 149 raise ValueError("duplicated skills") 150 nskills = self.skill_cap * (self.skill_cap + 1) // 2 151 if len(value) < nskills: 152 other_skills = shuffled(all_skills - skills) 153 value.extend(other_skills[:nskills - len(value)]) 154 return value 155 156 @generated_property(type=int) 157 def num_stunts(self): 158 return self.skill_cap - 1 159 160 @generated_property(nargs='*') 161 def stunts(self): 162 # Filled out by validator 163 return [] 164 165 @stunts.validator 166 def stunts(self, value): 167 # When undefined, the first stunt is chosen from the top skill, 168 # the second stunt from the top two skills, and so on. 169 stunts = set(value) 170 if len(stunts) != len(value): 171 raise ValueError("duplicated stunts") 172 skills_with_stunts = [i for i in self.skills if i in feats] 173 while len(value) < self.num_stunts: 174 choose_from_skills = skills_with_stunts[:1+len(value)] 175 other_stunts = shuffled(functools.reduce(operator.__or__, 176 (feats.get(sk, set()) for sk in choose_from_skills))-stunts) 177 stunt = random.choice(other_stunts) 178 value.append(stunt) 179 stunts.add(stunt) 180 return value 181 182 @generated_property 183 def high_concept(self): 184 return choose_from_json('high-concepts.json') 185 186 @generated_property 187 def motto(self): 188 return choose_from_json('mottos.json') 189 190 @generated_property 191 def advantage(self): 192 return choose_from_json('advantages.json') 193 194 @generated_property 195 def trouble(self): 196 return choose_from_json('troubles.json') 197 198 @generated_property 199 def disposition(self): 200 return choose_from_json('dispositions.json') 201 202 @generated_property 203 def gear(self): 204 return choose_from_json('gear.json') 205 206 def print(self): 207 print(f'{self.name} - {self.gender} {self.race}') 208 print(f' "{self.motto}"') 209 print() 210 print(' High Concept:', self.high_concept) 211 print(' Advantage: ', self.advantage) 212 print(' Trouble: ', self.trouble) 213 print(' Disposition: ', self.disposition) 214 215 old_level = None 216 for level, skill in zip(skill_pyramid(len(self.skills)), self.skills): 217 if level != old_level: 218 print(f"\n{ranks[level]:7} {level:+2} | ", end="") 219 old_level = level 220 print(f" {skill:12}", end="") 221 print("\n") 222 223 for stunt in self.stunts: 224 print(stunt) 225 226 if __name__ == '__main__': 227 parser = argparse.ArgumentParser(description='Generate a Fate NPC') 228 parser.add_argument("--preset", metavar='PRESET', type=str, default=[], nargs='*') 229 parser.add_argument("--count", metavar='N', type=int, default=1, nargs='?') 230 for k, v in FateNPC.__dict__.items(): 231 if isinstance(v, PropertyGenerator): 232 parser.add_argument("--" + k, 233 metavar=v.metavar, 234 type=v.type, 235 nargs=v.nargs, 236 default=argparse.SUPPRESS, 237 help=v.__doc__) 238 args = parser.parse_args() 239 sep = "" 240 for i in range(args.count): 241 npc = FateNPC() 242 for fn in args.preset: 243 for k, v in json_resource(fn).items(): 244 setattr(npc, k, v) 245 246 for k, v in args.__dict__.items(): 247 if v is not None: 248 setattr(npc, k, v) 249 print(end=sep) 250 sep = "\n\n" 251 npc.print() 252