/ 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