def generate_national_spells(targetnumberofnationalspells: int, spelleffects: Dict[str, SpellEffect], researchmod: int, alreadygeneratedeffectsatlevels: Dict[int, List[str]], generatedspells: List[Spell], nationstogeneratefor: List[int], options: Dict[str, str]): _writetoconsole("Generating national spells...\n") if targetnumberofnationalspells < 1: return # Initialize national mage map nationcount = len(nationstogeneratefor) print(f"Nations to generate for: {nationstogeneratefor}") index = 0 # used for progress report for nationid, nation in nationals.nations.items(): if nationid not in nationstogeneratefor: continue beginningnationlogtext = f"Progress: Beginning nation {index} of {nationcount}...\n" if index % 20 == 0: _writetoconsole(beginningnationlogtext) DebugLogger.debuglog(beginningnationlogtext, debugkeys.NATIONALSPELLGENERATION) index += 1 _generate_spells_for_nation( nation=nation, researchmod=researchmod, spelleffects=spelleffects, alreadygeneratedeffectsatlevels=alreadygeneratedeffectsatlevels, generatedspells=generatedspells, options=options, targetnumberofnationalspells=targetnumberofnationalspells ) _writetoconsole( f"Attempted to generate {targetnumberofnationalspells} national spells per nation for {nationcount} nations.\n") NationalSpellGenerationInfoCollector.print()
def get_commander_with_path(self, path: int) -> Union[NationalMage, None]: random.shuffle(self.mages) for mage in self.mages: DebugLogger.debuglog( f"Testing {mage.to_text()} for {utils.pathstotext(path)}: {mage.has_access_to_path(path)}", debugkeys.MAGESELECTIONFORPATHFORNATIONALSPELL) if mage.has_access_to_path(path): return mage raise ValueError( f"Could not find mage for path {utils.pathstotext(path)} in {self.to_text()}" )
def get_pathweights(self) -> Dict[int, int]: mageweights: Dict[ NationalMage, float] = self._get_natspell_weight_distribution_for_mages() weights: Dict[int, float] = self._calculate_raw_pathweights(mageweights) weights: Dict[int, int] = {i: int(round(weights[i])) for i in weights} DebugLogger.debuglog( f"Pathweights for nation {self.name} (ID{self.id})\n" f"Mages:{[i.to_text() for i in self.mages]}\n" f"MageWeights: {mageweights}\n" f"Weights:{[str(utils.pathstotext(i)) + ' ' + str(weights[i]) + ', ' for i in weights]}", debugkeys.NATIONALSPELLGENERATIONWEIGHTING) return weights
def _choose_effect(effectpool: Dict[str, SpellEffect], primarypath: int, alreadygeneratedeffectsatlevels: Dict[int, List[str]], researchlevel: int) -> SpellEffect: availableeffects = list(filter(lambda x: ((primarypath & x.paths) != 0) and # Matching path (x.name not in alreadygeneratedeffectsatlevels[researchlevel]), # generic with same effect not already existing in same researchlevel effectpool.values())) if len(availableeffects) == 0: raise ValueError("No Spelleffect found available") choseneffect: Union[SpellEffect] = availableeffects[random.randrange(0, len(availableeffects))] DebugLogger.debuglog(f"Selected effect: {choseneffect.name}\n" f"Primary path: {utils.pathstotext(primarypath)}\n" f"Already generated effects: {alreadygeneratedeffectsatlevels}\n" f"Current research level: {researchlevel}", debugkeys.NATIONALSPELLGENERATION) return choseneffect
def _select_research_level(researchmod: int, generatedeffectsatlevels: Dict[int, List[str]]) -> int: researchlevelstotry: List[int] = [] for level in range(1, 10): if level in generatedeffectsatlevels: duplicates = 5 - abs(5 - level) realLevel = level + researchmod for i in range(0, duplicates): researchlevelstotry.append(realLevel) random.shuffle(researchlevelstotry) researchlevel = researchlevelstotry.pop(0) if len(researchlevelstotry) == 0: raise ValueError(f"Failed to select research level for spell") DebugLogger.debuglog(f"Selecting researchlevel {researchlevel}", debugkeys.NATIONALSPELLGENERATION) return researchlevel
def _roll_path_for_national_spell(nation: Nation) -> int: pathweights = nation.get_pathweights() totalweights = 0 for i, weight in pathweights.items(): totalweights += weight if totalweights == 0: raise ValueError(f"Failed calculating national spell path weights.\n" f"Nation: {nation.to_text()}\n" f"Weights: {pprint.pformat(pathweights)}\n" f"Total Weight: {totalweights}") initialroll = roll = random.randrange(0, totalweights, 1) for path, weight in pathweights.items(): if roll < weight: DebugLogger.debuglog(f"Selected path {utils.pathstotext(path)} from weights {pathweights.items()}, " f"rolled {initialroll}", debugkeys.NATIONALSPELLGENERATION, debugkeys.NATIONALSPELLGENERATIONWEIGHTING) return path roll -= weight raise ValueError(f"Attempted and failed to roll for weighted path\n Rolled {initialroll}\n Total weight: " f"{totalweights}\n Weights: {pprint.pformat(pathweights)}\n")
def _calculate_pathweight_proportions(self): DebugLogger.debuglog( f"Generating pathweights for mage {self.to_text()}", debugkeys.NATIONALSPELLGENERATIONWEIGHTING) self.pathweightsinitialised = True for i in range(0, 8): self.pathweights[2**i] = self.get_average_level_in_path(2**i)**1.5 DebugLogger.debuglog( f"Average levels (default weight) in paths: " + str([ utils.pathstotext(i) + " " + str(self.pathweights[i]) for i in self.pathweights ]), debugkeys.NATIONALSPELLGENERATIONWEIGHTING) s = sum(self.pathweights.values()) DebugLogger.debuglog(f"Total weights sum {s}", debugkeys.NATIONALSPELLGENERATIONWEIGHTING) # Avoid ZeroDivisionError for non mages if s == 0.0: DebugLogger.debuglog(f"No weight, therefore skipping adjustment", debugkeys.NATIONALSPELLGENERATIONWEIGHTING) return for pathflag, weight in self.pathweights.items(): # Normalize self.pathweights[pathflag] = float(weight) / s DebugLogger.debuglog( f"Adjusted weights: " + str([ utils.pathstotext(i) + " " + str(self.pathweights[i]) for i in self.pathweights ]), debugkeys.NATIONALSPELLGENERATIONWEIGHTING) s = sum(self.pathweights.values()) DebugLogger.debuglog(f"Total weights sum {s}", debugkeys.NATIONALSPELLGENERATIONWEIGHTING)
def _compatibility(self, eff, modifier, researchlevel): # Skipchance is done by the main processing loop now # it makes determining if there are legal modifiers for a spell a LOT better DebugLogger.debuglog(f"Begin secondary compatibility for {self.name} and {eff.name} " f"with mod {modifier.name} at RL {researchlevel}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) # see if the event list allows this unitmod # if yes then we need to be allowed, ignoring all the other reqs if eff.eventset is not None: realeventset = eventsets[eff.eventset] if self.unitmod in realeventset.allowedunitmods: DebugLogger.debuglog(f"Secondary is valid: allowed unit mod for eventset", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return True if realeventset.unitmodlist is not None: unitmodlist = utils.unitmodlists[realeventset.unitmodlist] if self.unitmod in unitmodlist: DebugLogger.debuglog(f"Secondary is valid: this unitmod is in unitmodlist for eventset", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return True if self.nextspell != "" and eff.noadditionalnextspells > 0: DebugLogger.debuglog(f"Secondary is invalid: this effect does not allow nextspells", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if self.requiredresearchlevel is not None and self.requiredresearchlevel != researchlevel: DebugLogger.debuglog(f"Secondary is invalid: required research level is {self.requiredresearchlevel}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False finalpower = researchlevel + self.power + modifier.power if self.anysummon: if eff.effect in [1, 10001, 10050, 10038, 21, 10021]: if eff.effect in [1, 10001, 10050, 10038, 21]: pass elif eff.effect in [10021]: # Block weapon mods on permanent summon commanders if self.unitmod != "": unitmod = unitmods[self.unitmod] if unitmod.weaponmod != "": DebugLogger.debuglog( f"Secondary is invalid: no weapon mods allowed on permanent summon commanders", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False else: DebugLogger.debuglog(f"Secondary is invalid: secondary requires summon, but this spell effect is not", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False # Calculate what final power this should be, accounting for magic value and chassis value mismatches # This is so squishy human mages don't get pushed up to really high research for simple modifications # that don't really affect their combat ability if eff.chassisvalue is not None: thispower = self.calcModifiedPowerForSpellEffect(eff) finalpower = researchlevel + thispower + modifier.power if eff.isnextspell and self.name != "Do Nothing": DebugLogger.debuglog(f"Secondary is invalid: this is a nextspell, and nextspells are only allowed Do Nothing", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False # do not give nextspells secondary effects as they don't respect the path requirements the way the main spell does # oh and it could chain to ridiculous levels like "add a set on fire effect" "add an entangle to that set on fire" etc etc etc okay = self.paths == 0 for flag in utils.breakdownflagcomponents(self.paths): if (eff.paths & flag): okay = True break if not okay: DebugLogger.debuglog(f"Secondary is invalid: effect paths {eff.paths} do not contain required paths {self.paths}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if self.nobattlefield: if 660 <= eff.aoe <= 670: DebugLogger.debuglog(f"Secondary is invalid: not allowed on battlefield wide effects", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False for flag in utils.breakdownflagcomponents(self.spelltype): if not (eff.spelltype & flag): DebugLogger.debuglog(f"Secondary is invalid: effect's spelltype missing flag {flag}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False # Check #reqs for r in self.reqs: if not r.test(eff): DebugLogger.debuglog(f"Secondary is invalid: failed req {str(r)}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False # Make sure that various things cannot be pushed out of range finalrange = self.range + modifier.range + (eff.range % 1000) if finalrange < 0: DebugLogger.debuglog(f"Secondary is invalid: final range would be negative", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False cast = (100 if eff.casttime is None else eff.casttime) + modifier.casttime finalcast = self.casttime + cast if finalcast < 5: DebugLogger.debuglog(f"Secondary is invalid: final cast time would be {finalcast} (<5% cast time)", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False # power extremes don't matter for holy spells # do nothing should also always be allowed if not eff.isnextspell and self.name != "Do Nothing": if finalpower < max(0, eff.power) and eff.paths != 256: DebugLogger.debuglog(f"Secondary is invalid: final power level of {finalpower} below effect minimum of {eff.power}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if finalpower > eff.maxpower and eff.paths != 256: DebugLogger.debuglog(f"Secondary is invalid: final powerlevel {finalpower} above effect max of {eff.maxpower}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False finalnreff = self.nreff + modifier.nreff + (eff.nreff % 1000) if finalnreff <= 0: DebugLogger.debuglog(f"Secondary is invalid: final number of effects {finalnreff} must be positive", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False finalaoe = self.aoe + modifier.aoe + (eff.aoe % 1000) + (eff.aoe // 1000)*eff.pathlevel if finalaoe < 0: DebugLogger.debuglog(f"Secondary is invalid: final aoe {finalaoe} must not be negative", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False finalbounces = self.maxbounces + eff.maxbounces + modifier.maxbounces if finalbounces < 0: DebugLogger.debuglog(f"Secondary is invalid: final maxbounces {finalbounces} must not be negative", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False finalpathlevel = self.pathlevel + eff.pathlevel + modifier.pathlevel # skip for holy if eff.paths != 256: if finalpathlevel <= 0 and eff.pathlevel > 0: DebugLogger.debuglog(f"Secondary is invalid: finalpathlevel {finalpathlevel} not positive", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if self.unitmod != "": umod = unitmods[self.unitmod] if not umod.compatibilityWithSpellEffect(eff): DebugLogger.debuglog(f"Secondary is invalid: unitmod incompatible with effect", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False extraresearch = researchlevel - finalpower actualpowerlvl = (researchlevel - eff.power) + self.power + modifier.power if not eff.canGenerateAtPowerlvl(actualpowerlvl, modifier, self): return False scaleamt = eff.scalerate * ((actualpowerlvl * (actualpowerlvl + 1)) / 2) if eff.spelltype & SpellTypes.POWER_SCALES_AOE: finalaoe += scaleamt # aoe limit so that mass rust doesn't get decay/burning etc # don't apply to holy spells as (at research 0) they need to be allowed this if self.scalingaoelimit is None and self.offensiveeffect != 0 and eff.paths != 256: scalingaoelimit = 3 else: scalingaoelimit = self.scalingaoelimit if scalingaoelimit is not None: if finalaoe > 600: finalaoe = 50 maxbaseaoe = scalingaoelimit * ((researchlevel * (researchlevel + 1)) / 2) # this is quadratic, negative power differentials (from slower casting etc) should be considered 0 finalpower = max(0, finalpower) print(f"scalingaoelimit: {scalingaoelimit} at rl{researchlevel} has maxbaseaoe {maxbaseaoe}, " f"spell has {finalaoe}, scaleamt was {scaleamt}") if finalaoe > maxbaseaoe: DebugLogger.debuglog(f"Secondary is invalid: failed scalingaoelimit", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if self.reqdamagingeffect is not None: if self.reqdamagingeffect: if not utils.isDamagingSpellEffect(eff): DebugLogger.debuglog( f"Secondary is invalid: spell effect is not damaging", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False else: if utils.isDamagingSpellEffect(eff): DebugLogger.debuglog( f"Secondary is invalid: spell effect is damaging", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if self.ondamage: curr = eff while 1: # look for existing on damage effects, having multiple in the same spell # does not work correctly if isinstance(curr, str): curr = spelleffects[curr] if curr.spec & 0x1000000000000000: DebugLogger.debuglog( f"Secondary is invalid: this spell already has an ondamage effect", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if curr.effect > 1000: DebugLogger.debuglog( f"Secondary is invalid: ondamage effect not compatible with lingering spell effect", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False if curr.nextspell is not None and curr.nextspell != "": curr = curr.nextspell continue # this does currently not work on chain lightning effects (134) if (not utils.isDamagingSpellEffect(curr)) or curr.effect % 1000 == 134: DebugLogger.debuglog( f"Secondary is invalid: is ondamage secondary, spell effect chain lightning or nondamaging", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False break # Modifiers cannot check this including the secondary as well, this is # because modifiers come first: secondaries need to also check the modifier value maxfinalfatiguecost = self.maxfinalfatiguecost if modifier.maxfinalfatiguecost is not None: if self.maxfinalfatiguecost is None: maxfinalfatiguecost = modifier.maxfinalfatiguecost else: maxfinalfatiguecost = min(modifier.maxfinalfatiguecost, self.maxfinalfatiguecost) minfinalfatiguecost = self.minfinalfatiguecost if modifier.minfinalfatiguecost is not None: if self.minfinalfatiguecost is None: minfinalfatiguecost = modifier.minfinalfatiguecost else: minfinalfatiguecost = max(modifier.minfinalfatiguecost, self.minfinalfatiguecost) if maxfinalfatiguecost is not None or minfinalfatiguecost is not None: finalfatigue = eff.calculateExpectedFinalFatigue(researchlevel, modifier, self) if maxfinalfatiguecost is not None and finalfatigue >= maxfinalfatiguecost: print(f"maxfinalfatiguecost of {finalfatigue} too high (vs {maxfinalfatiguecost})") return False if minfinalfatiguecost is not None and finalfatigue < minfinalfatiguecost: print(f"minfinalfatiguecost of {finalfatigue} too low (vs {maxfinalfatiguecost})") return False if self.minfinalaoe is not None: if eff.spelltype & SpellTypes.POWER_SCALES_AOE: finalaoe = eff.aoe % 1000 + (eff.aoe // 1000 * eff.pathlevel) finalaoe += round(eff.scalerate * ((actualpowerlvl*(actualpowerlvl+1))/2), 0) else: finalaoe = eff.aoe if finalaoe < self.minfinalaoe: DebugLogger.debuglog(f"Secondary is invalid: finalaoe {finalaoe} below specified min of {self.minfinalaoe}", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return False DebugLogger.debuglog(f"Secondary is valid!", debugkeys.SECONDARYEFFECTCOMPATIBILITY) return True
def _generate_spells_for_nation(nation: Nation, researchmod: int, spelleffects: Dict[str, SpellEffect], alreadygeneratedeffectsatlevels: Dict[int, List[str]], generatedspells: List[Spell], targetnumberofnationalspells: int, options: Dict[str, str]): DebugLogger.debuglog(f"Generating spells for nation: {nation.to_text()}", debugkeys.NATIONALSPELLGENERATION) if not nation.has_mages(): _writetoconsole(f"Skipping nation {nation.to_text()} because no national mages were found\n") return availableeffectpool = copy.copy(spelleffects) while len(nation.nationalspells) < targetnumberofnationalspells: primarypath: int = _roll_path_for_national_spell(nation) DebugLogger.debuglog(f"Attempting to generate for primary path {utils.pathstotext(primarypath)}\n", debugkeys.NATIONALSPELLGENERATION) # Select a commander to generate this spell for commander: NationalMage = nation.get_commander_with_path(primarypath) # calculate if blood shall be allowed as path in the spell allowblood = commander.can_have_blood() # Select effect for spell DebugLogger.debuglog(f"Selecting national spell effect\n", debugkeys.NATIONALSPELLGENERATION) researchlevel = _select_research_level(researchmod, alreadygeneratedeffectsatlevels) try: choseneffect = _choose_effect( effectpool=availableeffectpool, primarypath=primarypath, alreadygeneratedeffectsatlevels=alreadygeneratedeffectsatlevels, researchlevel=researchlevel ) # Only one attempt an effect per nation del availableeffectpool[choseneffect.name] except ValueError: raise ValueError( f"Couldn't make a national spell for nation {nation.name} (ID:{nation.id})\n" f"Primarypath={utils.pathstotext(primarypath)}\n" f"Researchlevel={researchlevel}\n " f"Available effects: {availableeffectpool}\n" f"No effect available\n") DebugLogger.debuglog( f"Try generating national spell for nation {nation.id} with effect {choseneffect.name}, " f"primarypath={utils.pathstotext(primarypath)}, secondaries={commander.get_total_possible_paths_mask()}, there are " f"{len(availableeffectpool)} effects available\n", debugkeys.NATIONALSPELLGENERATION) spell = _try_to_generate_a_national_spell( nation=nation, spelleffect=choseneffect, researchlevel=researchlevel, primarypath=primarypath, allowblood=allowblood, options=options, secondarypathoptions=commander.get_total_possible_paths_mask() ) if spell is None: DebugLogger.debuglog(f"Failed to generate spell for effect {choseneffect.name}\n", debugkeys.NATIONALSPELLGENERATION) else: generatedspells.append(spell) nation.register_national_spell(spell) NationalSpellGenerationInfoCollector.numberofgeneratedspells += 1 DebugLogger.debuglog("Spell successfully generated\n", debugkeys.NATIONALSPELLGENERATION)