Skip to content
Snippets Groups Projects
Commit ae3ae610 authored by Hachem_Muche's avatar Hachem_Muche
Browse files

NPC Class Autoleveling

Updated the way NPCs (randbosses) are assigned character classes (game.state:applyRandomClass).
The new method makes high level NPCs with character classes scale like player levels and tends to increase the number of talents randomly learned while limiting the maximum talent levels.
This is a nerf for high level randbosses (including those enhanced due to game difficulty) and a buff for fixed bosses that are updated, while making turning their difficulty much simpler.
This replaces the previous method of assigning an increasing number of class talents at maximum level, that could result in boss's skills scaling much faster than its actor level.

Talents are learned randomly one level at a time and are restricted by the normal progression of talent and stat points and all normal talent restrictions.
After setup, NPCs automatically advance in their character classes when gaining levels, tending to focus their talent choices with randomly determined preferred categories.

Random bosses and bosses enhanced for game difficulty can use "partial" character classes.  Game balance tuning is handled my adjusting the number of added classes.
Adjusted the number of character classes assigned at higher difficulties:
NIGHTMARE -- max 1 random class (BUFF), INSANE -- max 2 random classes , MADNESS -- max 3 random classes

NPC (fixed boss) definitions can be updated to specify automatic class advancement begining at a certain level and rate.
This allows many bosses that become unchallenging on advanced difficulties or when spawning in the I.D. to be easily buffed by specifying character class advancement based on level.

Summary of function changes:

Actor:learnStats(statorder, repeats)
Updated to maintain the current stat index within the passed statorder table.
This allows multiple stat progressions to be applied to the same Actor and repeated multiple times with the same function call.

Actor:levelupClass(c_data)
New function to automatically assign character class levels to an actor according to c_data.
Sets up the self.auto_classes table, retrieves data as needed from class birth descriptors.
Assigns stat/talent/category points randomly on a level-by-level basis.

Actor:resolveLevelTalents()
Updated to process any self.auto_classes defined

game.state:applyRandomClass(b, data, instant)
Works as before, but is updated to use Actor:levelupClass to set up class definitions.
nb_classes can be a flotaing point number, allowing "fractional" classes to be assigned: (1.5 == one class at 100% actor level plus another class at 50% of actor level).
The instant parameter forces advancement in the class(es) immediately.
Fixed a bug where auto_equip_filters were being copied even when forbid_equip was set.

game.state:createRandomBoss(base, data)
By default, randombosses are nerfed slightly, getting 1.75 random classes by default.

NPC:addedToLevel(level, x, y)
Higher game difficulties assign fewer randomclasses based on rank, which may be partial classes:
NIGHTMARE -- up to 1 random class, INSANE -- up to 2 random classes, MADNESS -- up to 3 random classes
Random bosses gain extra character classes at 1/2 the rate of fixed bosses.

resolvers.sustains_at_birth()
Will check that a talent is inactive before trying to activate it.

resolvers.talented_ai_tactic(method, tactic_emphasis, weight_power)
May specify "instant" as the method to force recalculating the talent table immediately when resolved (instead of through on_added_to_level).

RandomActor debug Dialog: updated Help reference to show header for Actor:levelupClass
SummonCreature debug Dialog: updated to spawn the alchemy golem if needed
parent e96a567f
No related branches found
No related tags found
1 merge request!494Npc class autolevel
......@@ -3295,25 +3295,393 @@ function _M:die(src, death_note)
return true
end
function _M:learnStats(statorder)
self.auto_stat_cnt = self.auto_stat_cnt or 1
--- Learn stats (stat increment) in a specified order up to a maximum
-- @param[table] statorder: an orded list of stat ids in which to learn, generally all learned in one character level
-- @param repeats: maximum number of times to apply the statorder <1>
function _M:learnStats(statorder, repeats)
statorder.idx = statorder.idx or 1
repeats = (repeats or 1)*#statorder
local nb = 0
local max = 60
-- Allow to go over a natural 60, up to 80 at level 50
-- Allow stats to go over a natural 60, up to 80 at level 50
if not self.no_auto_high_stats then max = 60 + (self.level * 20 / 50) end
while self.unused_stats > 0 do
if self:getStat(statorder[self.auto_stat_cnt]) < max then
self:incIncStat(statorder[self.auto_stat_cnt], 1)
while self.unused_stats > 0 and nb < repeats do
if self:getStat(statorder[statorder.idx]) < max then
self:incIncStat(statorder[statorder.idx], 1)
self.unused_stats = self.unused_stats - 1
end
self.auto_stat_cnt = util.boundWrap(self.auto_stat_cnt + 1, 1, #statorder)
statorder.idx = util.boundWrap(statorder.idx + 1, 1, #statorder)
nb = nb + 1
if nb >= #statorder then break end
end
end
--- Actor learns/advances in a character class, randomly gaining stats and learning talents based on levels in the class
-- When first called, the actor gains starting stat bonuses and talents and learns talent categories according to the birth class descriptor
-- Note: this does not handle any special class requirements, such as creating a golem, generating equipment, etc.
-- @see game.state:applyRandomClass
-- @param c_data[table] contains info on the class to learn/levelup in:
-- @field class: name (birth descriptor subclass name) of the character class to learn ("Bulwark", "Solipsist", ...)
-- @field start_level: starting actor level to begin leveling in the class <1>
-- @field level_rate: rate levels in character class are gained as % of actor level <100>
-- @field check_talents_level: set true to enforce character level limits on talent levels (i.e. level 5 at level 50) <nil>
-- @field level_by_class: set true to use class level rather than actor level when checking talent/stat limits <nil>
-- @foe;d ignore_special: set true to skip checking talent special requirements
-- @field auto_sustain: set true to automatically turn on sustained talents learned <nil>
-- @field tt_focus: talent type focus, higher values cause talent selections to be focused within fewer talent types <3>
-- @field use_actor_points: set true to apply stat/talent points from base actor levels in addtion to class levels <nil>
-- The following fields are automatically generated or updated from the class birth descriptor:
-- @field ttypes[table]: talent trees to learn talents from (updated with birth descriptor)
-- {talent_type_name = {[1]=known, [2]=mastery_add}, ...}
-- @field auto_stats: ordered list of stat ids to use when applying unused_stats points
-- generated from class descriptor by default, set false to disable, see Actor:learnStats
-- Additional talent inputs for each talent definition t:
-- t.random_boss_rarity: if defined, the percent chance the talent may be learned each time randomly selected
function _M:levelupClass(c_data)
c_data.last_level = c_data.last_level or 0
c_data.start_level = c_data.start_level or 1
local new_level = math.ceil((self.level - c_data.start_level + 1)*(c_data.level_rate or 100)/100)
if new_level <= c_data.last_level then return end
print("[Actor:levelupClass]", self.name, "auto level up", c_data.class, c_data.last_level, "-->", new_level, c_data)
table.set(game, "debug", "levelupClass", {act=self, c_data=c_data}) -- debugging
local ttypes
-- temporarily remove any previous stat/talent points if they won't be used
local base_points = {self.unused_stats, self.unused_talents, self.unused_generics}
if not c_data.use_actor_points then
self.unused_stats, self.unused_talents, self.unused_generics = 0, 0, 0
end
-- Initialize if needed, updating auto_classes table
if c_data.class and not c_data.initialized then -- build talent category list from the class
local Birther = require "engine.Birther"
local c_def = Birther.birth_descriptor_def.subclass[c_data.class]
if not c_def then
print("[Actor:levelupClass] ### undefined class:", c_data.class)
return
end
local mclasses = Birther.birth_descriptor_def.class
local mclass = nil
for name, data in pairs(mclasses) do
if data.descriptor_choices and data.descriptor_choices.subclass and data.descriptor_choices.subclass[c_def.name] then mclass = data break end
end
if not mclass then
print("[Actor:levelupClass] ###class", c_data.class, "has no parent class type###")
return
end
print(("[Actor:levelupClass] %s %s ## Initialzing auto_class %s (%s) %s%% level_rate from level %s ##"):format(self.uid, self.name, c_data.class, mclass.name, c_data.level_rate, c_data.start_level))
-- update class descriptor list and build inherent power sources
self.descriptor = self.descriptor or {}
self.descriptor.classes = self.descriptor.classes or {}
table.append(self.descriptor.classes, {c_def.name})
-- build inherent power sources and forbidden power sources
-- self.forbid_power_source --> self.not_power_source used for classes
self.power_source = table.merge(self.power_source or {}, c_def.power_source or {})
self.not_power_source = table.merge(self.not_power_source or {}, c_def.not_power_source or {})
-- update power source parameters with the new class
self.not_power_source, self.power_source = game.state:updatePowers(game.state:attrPowers(self, self.not_power_source), self.power_source)
print(" *** power types: not_power_source =", table.concat(table.keys(self.not_power_source),","), "power_source =", table.concat(table.keys(self.power_source),","))
-- apply class stat bonuses and set up class auto_stats (in auto_classes)
local auto_stats
if c_def.stats and c_data.auto_stats ~= false then
auto_stats = {}
for stat, v in pairs(c_def.stats or {}) do
local stat_id = self.stats_def[stat].id
self.stats[stat_id] = (self.stats[stat_id] or 10) + v
for i = 1, v do auto_stats[#auto_stats+1] = stat_id end
end
c_data.auto_stats = auto_stats
end
c_data.auto_stats = c_data.auto_stats or auto_stats
ttypes = {}
for tt, d in pairs(mclass.talents_types or {}) do
self:learnTalentType(tt, d[1]) self:setTalentTypeMastery(tt, (self:getTalentTypeMastery(tt) or 1) + d[2])
ttypes[tt] = table.clone(d)
end
for tt, d in pairs(mclass.unlockable_talents_types or {}) do
self:learnTalentType(tt, d[1]) self:setTalentTypeMastery(tt, (self:getTalentTypeMastery(tt) or 1) + d[2])
ttypes[tt] = table.clone(d)
end
for tt, d in pairs(c_def.talents_types or {}) do
self:learnTalentType(tt, d[1]) self:setTalentTypeMastery(tt, (self:getTalentTypeMastery(tt) or 1) + d[2])
ttypes[tt] = table.clone(d)
end
for tt, d in pairs(c_def.unlockable_talents_types or {}) do
self:learnTalentType(tt, d[1]) self:setTalentTypeMastery(tt, (self:getTalentTypeMastery(tt) or 1) + d[2])
ttypes[tt] = table.clone(d)
end
-- set up input talent categories specified (generally for non-class talents)
if c_data.ttypes then
for tt, d in pairs(c_data.ttypes) do
if not self:knowTalentType(tt) then
if type(d) ~= "table" then
d={true, type(d) == "number" and d or rng.range(1, 3)*0.1}
else d=table.clone(d)
end
self:learnTalentType(tt, d[1])
self:setTalentTypeMastery(tt, (self:getTalentTypeMastery(tt) or 1) + d[2])
ttypes[tt] = table.mergeAdd(ttypes[tt] or {}, d)
end
end
end
if not next(ttypes) then -- if not specified, use all known talent types
for tt, known in pairs(self.talents_types) do
ttypes[tt] = {known, (self:getTalentTypeMastery(tt) or 1) - 1}
end
end
-- Note: could limit number of talent trees selected here to limit # talents learned
--print("\t *** auto_levelup initialized talent category choices:", mclass.name , c_def.name , "\n\t")
-- set up (semi-random) rarity levels for talent categories
-- This tends to focus learned talents within certain trees (usually those with improved mastery)
local tt_count = 0
local unknown_tt={}
local tt_focus = c_data.tt_focus or 3
for tt, d in pairs(ttypes) do
d.tt = tt
tt_count = tt_count + 1
d.tt_count = tt_count
d.rarity = d.rarity or (1.3 + 0.5*tt_count)/math.max(0.1, self:getTalentTypeMastery(tt)*rng.float(0.1, tt_focus))^2
--print(("\t *** %-40s rarity %5.3f"):format(tt, d.rarity))
if not d[1] then table.insert(unknown_tt, d) end
end
c_data.unknown_tt = unknown_tt
c_data.ttypes = ttypes
-- Assign class starting talents and set them to level up later
for tid, v in pairs(c_def.talents or {}) do
c_data.auto_talents = c_data.auto_talents or {}
local t = self:getTalentFromId(tid)
if not t.no_npc_use and (not t.random_boss_rarity or rng.chance(t.random_boss_rarity)) then
local every = 0
if t.points > 1 then
every = math.ceil(50/(t.points * 1.2))
table.insert(c_data.auto_talents, {tid=tid, start_level=c_data.start_level, base=v, every=every})
end
print(("\t ** learning %s birth talent %s %s (every %s levels)"):format(c_data.class, tid, v, every))
self:learnTalent(tid, true, v)
end
end
c_data.initialized = true
end
local init_level, learn_tids, learn_stats = c_data.last_level -- for log output summary
local to_activate = {}
while new_level > c_data.last_level do
c_data.last_level = c_data.last_level + 1
--print("{Actor:levelupClass] to", c_data.class, c_data.last_level)
learn_tids = learn_tids or {}
learn_stats = learn_stats or {}
-- Gain talent and category points, similar to Actor:levelup: (but with no prodigies)
if not self.no_points_on_levelup then
self.unused_stats = self.unused_stats + (self.stats_per_level or 3) + self:getRankStatAdjust()
self.unused_talents = self.unused_talents + 1
self.unused_generics = self.unused_generics + 1
if c_data.last_level % 5 == 0 then self.unused_talents = self.unused_talents + 1 end
if c_data.last_level % 5 == 0 then self.unused_generics = self.unused_generics - 1 end
if self.extra_talent_point_every and c_data.last_level % self.extra_talent_point_every == 0 then self.unused_talents = self.unused_talents + 1 end
if self.extra_generic_point_every and c_data.last_level % self.extra_generic_point_every == 0 then self.unused_generics = self.unused_generics + 1 end
-- At levels 10, 20 and 36 and then every 30 levels, we gain a new talent type
if c_data.last_level == 10 or c_data.last_level == 20 or c_data.last_level == 36 or (c_data.last_level > 50 and (c_data.last_level - 6) % 30 == 0) then
self.unused_talents_types = self.unused_talents_types + 1
end
-- if c_data.last_level == 30 or c_data.last_level == 42 then self.unused_prodigies = self.unused_prodigies + 1 end
elseif type(self.no_points_on_levelup) == "function" then
self:no_points_on_levelup()
end
--print((" *** level: %s/%s stats: %s talents: %s generics: %s categories: %s prodigies: %s"):format(c_data.last_level, new_level, self.unused_stats, self.unused_talents, self.unused_generics, self.unused_talents_types, self.unused_prodigies))
-- automatically level up any auto_talents, (usualy birth talents)
if c_data.auto_talents then
for i, d in ipairs(c_data.auto_talents) do
if c_data.last_level > d.start_level and (c_data.last_level - d.start_level)%d.every == 0 then
--print(("\t ** advancing %s auto_talent %s"):format(c_data.class, d.tid))
self:learnTalent(d.tid, true)
end
end
end
ttypes = c_data.ttypes
-- generate list of possible talent types based on the master list
--print("\t *** auto_levelup available talent categories:\n\t", table.concat(table.keys(ttypes), ", "))
local tt_choices = {}
for tt, d in pairs(ttypes) do
table.insert(tt_choices, d)
end
local fails, max_fails = 0, #tt_choices*4
-- assign unused category points
while self.unused_talents_types >= 1 and fails <= 5 do
print("\t *** Assigning", self.unused_talents_types, "category points")
-- 50% chance to learn a new talent category if possible
local tt
if rng.percent(50) then tt = rng.tableRemove(c_data.unknown_tt) end
if not tt then tt = rng.table(tt_choices) end
if tt then
if self:knowTalentType(tt.tt) then
print("\t *** auto_levelup IMPROVING TALENT TYPE", tt.tt)
local ml = self:getTalentTypeMastery(tt.tt) or 1
self:setTalentTypeMastery(tt.tt, ml + (ml <= 1 and 0.2 or 0.1)) -- 0.2 for 1st then 0.1 thereafter
else
print("\t *** auto_levelup LEARNING TALENT TYPE", tt.tt)
self:learnTalentType(tt.tt, true)
tt.rarity = tt.rarity/2 -- makes talents within an unlocked talent tree more likely to be learned
end
--print("\t *** talent type mastery:", tt, self:getTalentTypeMastery(tt))
self.unused_talents_types = self.unused_talents_types - 1
else fails = fails + 1
end
end
-- learn talents randomly from available talent trees
-- Note: could put limit on number of talents learned here (similar to previous method)
fails = 0
local tt, tt_idx, tt_def
local t, t_idx
while fails < max_fails and (self.unused_talents >= 1 or self.unused_generics >= 1) do
-- pick a talent tree
tt, tt_idx = rng.rarityTable(tt_choices)
if not tt then break end
tt = tt.tt
tt_def = tt and self:knowTalentType(tt) and self.talents_types_def[tt]
-- begin category loop
if tt_def and (tt_def.generic and self.unused_generics >= 1 or not tt_def.generic and self.unused_talents >=1) then
--print("\t *** checking category:", tt)
-- try to find a talent to learn
local t_choices = {}
local nb_known = self:numberKnownTalent(tt)
-- update talent choices with each talent in the tree that can be learned
for i, t in ipairs(tt_def.talents) do
if t.no_npc_use or t.not_on_random_boss then
nb_known = nb_known + 1 -- treat as known to allow later talents to be learned
elseif t.type[2] and nb_known >= t.type[2] - 1 and (not t.random_boss_rarity or rng.percent(t.random_boss_rarity)) then -- check category talents known
table.insert(t_choices, t)
end
end
-- print("talent choices for", tt) for i, t in ipairs(t_choices) do print("\t", i, t.id, t.name) end
local ok
repeat
t, t_idx = rng.table(t_choices)
if not t then break end
ok = true
local tlev = self:getTalentLevelRaw(t) + 1 -- talent level to learn
-- check max talent level
local max = t.points
if max > 1 then
max = math.max(max, math.ceil(max*1.2*c_data.last_level/50))
if c_data.check_talents_level then -- apply character level limits
max = math.min(max, t.points + math.max(0, math.floor((c_data.last_level - 50) / 10)) + (self.talents_inc_cap and self.talents_inc_cap[t.id] or 0))
end
end
if tlev > max then
ok = false
end
if ok and t.require then -- check requirements to learn the talent
local req = rawget(t, "require")
if type(req) == "function" then req = req(self, t) end
if req then
if req.level then -- check talent-defined level limits
local lev = util.getval(req.level, tlev)
if c_data.level_by_class then
if c_data.last_level < lev then ok = false end
elseif self.level < lev then
ok = false
end
end
if ok and req.talent then -- other prerequisite talents
for _, tid in ipairs(req.talent) do
if type(tid) == "table" then
if type(tid[2]) == "boolean" and tid[2] == false then
if self:knowTalent(tid[1]) then ok = false end
else
if tlev - 1 < tid[2] then ok = false end
end
else
if not self:knowTalent(tid) then ok = false end
end
end
end
if ok and req.special and not c_data.ignore_special then -- special checks
if not req.special.fct(self, t, 1) then ok = false end
end
if ok and req.stat then -- check stat requirements, allocate stat points if needed
local need, needed, need_stats = 0, 0, {}
for s, v in pairs(req.stat) do
need = util.getval(v, tlev) - self:getStat(s)
if need > 0 then
need_stats[s] = need; needed = needed + need
end
end
if ok and needed <= self.unused_stats then -- allocate stats needed for the talent
for s, v in pairs(need_stats) do
local pts = self:incIncStat(s, v)
learn_stats[s] = (learn_stats[s] or 0) + pts
self.unused_stats = self.unused_stats - pts
end
else ok = false
end
end
end
end
ok = ok and self:learnTalent(t.id, true)
if ok then
-- print("\t *** learning "..(tt_def.generic and "GENERIC" or "CLASS").." talent", t.id, ok)
learn_tids[t.id] = (learn_tids[t.id] or 0) + 1
if tt_def.generic then self.unused_generics = self.unused_generics - 1 else self.unused_talents = self.unused_talents - 1 end
if t.mode == "sustained" and c_data.auto_sustain and not self:isTalentActive(t.id) then to_activate[t.id] = true end
else table.remove(t_choices, t_idx)
end
until ok
if not ok then -- no more talent choices possible for category
-- print("\t *** no talent choices in category:", tt)
table.remove(tt_choices, tt_idx)
end
else -- impossible to learn talents in the category
-- print("\t *** rejecting category:", tt)
table.remove(tt_choices, tt_idx)
end -- end category loop
fails = fails + 1
end
end
print("[Actor:levelupClass] ### levelup summary for", self.name, c_data.class, init_level, "-->", c_data.last_level)
print("[Actor:levelupClass] ### Talents Learned:")
table.print(learn_tids, "\t*\t")
print("[Actor:levelupClass] ### Talents forced Stats:", table.concatNice(table.to_strings(learn_stats, "%s: %s"), ", "))
-- print((" *** auto_level complete: level: %s/%s remaining stats: %s talents: %s generics: %s categories: %s prodigies: %s"):format(c_data.last_level, new_level, self.unused_stats, self.unused_talents, self.unused_generics, self.unused_talents_types, self.unused_prodigies))
-- allocate any remaining stat points
if self.unused_stats > 0 and c_data.auto_stats then
print(" *** auto allocating", self.unused_stats, "remaining stat points")
self:learnStats(c_data.auto_stats, 5)
end
-- restore unused stat and talent points
if not c_data.use_actor_points then
self.unused_stats, self.unused_talents, self.unused_generics = unpack(base_points)
end
-- turn on designated sustains
for tid, _ in pairs(to_activate) do self:forceUseTalent(tid, {ignore_energy=true}) end
end
function _M:resetToFull()
if self.dead then return end
self.life = self.max_life
......@@ -3348,20 +3716,30 @@ function _M:resolveLevelTalents()
end
self.learn_tids = nil
end
if not self.start_level or not self._levelup_talents then return end
-- if not self.start_level or not self._levelup_talents then return end
local maxfact = 1 -- Balancing parameter for levels > 50: maxtalent level = actorlevel/50*maxfact * normal max talent level
maxfact=math.max(maxfact,self.level/50)
if self.start_level and self._levelup_talents then
local maxfact = 1 -- Balancing parameter for levels > 50: maxtalent level = actorlevel/50*maxfact * normal max talent level
maxfact=math.max(maxfact,self.level/50)
for tid, info in pairs(self._levelup_talents) do
if not info.max or (self.talents[tid] or 0) < math.floor(info.max*maxfact) then
local last = info.last or self.start_level
if self.level - last >= info.every then
self:learnTalent(tid, true)
info.last = self.level
for tid, info in pairs(self._levelup_talents) do
if not info.max or (self.talents[tid] or 0) < math.floor(info.max*maxfact) then
local last = info.last or self.start_level
if self.level - last >= info.every then
self:learnTalent(tid, true)
info.last = self.level
end
end
end
end
-- automatically level up classes as defined
if self.auto_classes then
for i, c_data in ipairs(self.auto_classes) do
self:levelupClass(c_data)
end
end
end
function _M:levelup()
......
......@@ -1517,6 +1517,7 @@ function _M:entityFilterPost(zone, level, type, e, filter)
level = lev,
nb_rares = filter.random_elite.nb_rares or 1,
check_talents_level = true,
level_by_class = true,
user_post = filter.post,
post = function(b, data)
if data.level <= 20 then
......@@ -1912,85 +1913,56 @@ function _M:createRandomZone(zbase)
return zone, boss
end
--- Add one or more character classes to an actor, updating stats, talents, and equipment
--- Add one or more character classes to an actor, updating stats, talents, and equipment,
-- Updates autoleveling data so that class skills are advanced with level
-- @see Actor:levelupClass
-- @param b = actor(boss) to update
-- @param data = optional parameters:
-- @param data.update_body a table of inventories to add, set true to add a full suite of inventories
-- @param data.force_classes = specific subclasses to apply first, ignoring restrictions
-- {"Rogue", "Necromancer", Corruptor = true, Bulwark = true, ...}
-- applied in order of numerical index, then randomly
-- @param data.nb_classes = random classes to add (in addition to any forced classes) <2>
-- @param data.class_filter = function(cdata, b) that must return true for any class picked.
-- @field data.update_body a table of inventories to add, set true to add a full suite of inventories
-- @field data.start_level: actor level to being leveling in the class(es) <1>
-- @field data.level_rate: level of character class as % of actor level <100>
-- @field data.force_classes = specific subclasses to apply first, ignoring restrictions
-- {"Rogue" <=={Rogue = 100}>, {Necromancer = 75}, Corruptor = true <==100>, Bulwark = 50, ...}
-- applied in order of numerical index, then randomly, numbers are the specified level_rate for each class
-- @field data.nb_classes = random classes to add (in addition to any forced classes) <2>
-- fractional classes are applied first with reduced level_rate
-- @field data.class_filter = function(cdata, b) that must return true for any class picked.
-- (cdata, b = subclass definition in engine.Birther.birth_descriptor_def.subclass, boss (before classes are applied))
-- @param data.no_class_restrictions set true to skip class compatibility checks <nil>
-- @param data.autolevel = autolevel scheme to use for stats (set false to keep current) <"random_boss">
-- @param data.spend_points = spend any unspent stat points (after adding all classes)
-- @param data.add_trees = {["talent tree name 1"]=true/mastery bonus, ["talent tree name 2"]=true/mastery bonus, ..} additional talent trees to learn
-- @param data.check_talents_level set true to enforce talent level restrictions <nil>
-- @param data.auto_sustain set true to activate sustained talents at birth <nil>
-- @param data.forbid_equip set true to not apply class equipment resolvers or equip inventory <nil>
-- @param data.loot_quality = drop table to use for equipment <"boss">
-- @param data.drop_equipment set true to force dropping of equipment <nil>
-- @field data.no_class_restrictions set true to skip class compatibility checks <nil>
-- @field data.autolevel = autolevel scheme to use for stats (set false to keep current) <"random_boss">
-- @field data.spend_points = spend any unspent stat points (after adding all classes)
-- @field data.add_trees = {["talent tree name 1"]=true/mastery bonus, ["talent tree name 2"]=true/mastery bonus, ..} additional talent trees to learn
-- @field data.check_talents_level set true to enforce talent level restrictions based on class level <nil>
-- @field data.auto_sustain set true to activate sustained talents at birth <nil>
-- @field data.forbid_equip set true to ignore class equipment resolvers (and filters) or equip inventory <nil>
-- @field data.loot_quality = drop table to use for equipment <"boss">
-- @field data.drop_equipment set true to force dropping of equipment <nil>
-- @param instant set true to force instant learning of talents and generating golem <nil>
function _M:applyRandomClass(b, data, instant)
if not data.level then data.level = b.level end
if not data.level then data.level = b.level end -- use the level specified if needed
print("[applyRandomClass] instant:", instant, "input data:") table.print(data, "__data__\t") -- debugging
------------------------------------------------------------
-- Apply talents from classes
------------------------------------------------------------
-- Apply a class
local Birther = require "engine.Birther"
b.learn_tids = {}
local function apply_class(class)
local function apply_class(class, level_rate)
local mclasses = Birther.birth_descriptor_def.class
local mclass = nil
for name, data in pairs(mclasses) do
if data.descriptor_choices and data.descriptor_choices.subclass and data.descriptor_choices.subclass[class.name] then mclass = data break end
end
if not mclass then return end
print("[applyRandomClass]", b.uid, b.name, "Adding class", class.name, mclass.name)
-- add class to list and build inherent power sources
b.descriptor = b.descriptor or {}
b.descriptor.classes = b.descriptor.classes or {}
table.append(b.descriptor.classes, {class.name})
-- build inherent power sources and forbidden power sources
-- b.forbid_power_source --> b.not_power_source used for classes
b.power_source = table.merge(b.power_source or {}, class.power_source or {})
b.not_power_source = table.merge(b.not_power_source or {}, class.not_power_source or {})
-- update power source parameters with the new class
b.not_power_source, b.power_source = self:updatePowers(self:attrPowers(b, b.not_power_source), b.power_source)
print(" power types: not_power_source =", table.concat(table.keys(b.not_power_source),","), "power_source =", table.concat(table.keys(b.power_source),","))
-- Update/initialize base stats, set stats auto_leveling
if class.stats or b.auto_stats then
b.stats, b.auto_stats = b.stats or {}, b.auto_stats or {}
for stat, v in pairs(class.stats or {}) do
local stat_id = b.stats_def[stat].id
b.stats[stat_id] = (b.stats[stat_id] or 10) + v
for i = 1, v do b.auto_stats[#b.auto_stats+1] = stat_id end
end
end
if data.autolevel ~= false then b.autolevel = data.autolevel or "random_boss" end
-- Class talent categories
for tt, d in pairs(mclass.talents_types or {}) do b:learnTalentType(tt, true) b:setTalentTypeMastery(tt, (b:getTalentTypeMastery(tt) or 1) + d[2]) end
for tt, d in pairs(mclass.unlockable_talents_types or {}) do b:learnTalentType(tt, true) b:setTalentTypeMastery(tt, (b:getTalentTypeMastery(tt) or 1) + d[2]) end
for tt, d in pairs(class.talents_types or {}) do b:learnTalentType(tt, true) b:setTalentTypeMastery(tt, (b:getTalentTypeMastery(tt) or 1) + d[2]) end
for tt, d in pairs(class.unlockable_talents_types or {}) do b:learnTalentType(tt, true) b:setTalentTypeMastery(tt, (b:getTalentTypeMastery(tt) or 1) + d[2]) end
-- Non-class talent categories
if data.add_trees then
for tt, d in pairs(data.add_trees) do
if not b:knowTalentType(tt) then
if type(d) ~= "number" then d = rng.range(1, 3)*0.1 end
b:learnTalentType(tt, true)
b:setTalentTypeMastery(tt, (b:getTalentTypeMastery(tt) or 1) + d)
end
end
if not mclass then
print("[applyRandomClass] ### ABORTING ###", b.uid, b.name, "No main class type for", class.name)
return
end
-- Add starting equipment
print("[applyRandomClass]", b.uid, b.name, "Adding class", class.name, mclass.name, "level_rate", level_rate)
-- Add starting equipment and update filters as needed
local apply_resolvers = function(k, resolver)
if type(resolver) == "table" and resolver.__resolver then
if resolver.__resolver == "equip" then
......@@ -2007,6 +1979,10 @@ function _M:applyRandomClass(b, data, instant)
end
b[#b+1] = resolver
end
elseif resolver.__resolver == "auto_equip_filters" then
if not data.forbid_equip then
b[#b+1] = resolver
end
elseif resolver._allow_random_boss then -- explicitly allowed resolver
b[#b+1] = resolver
end
......@@ -2024,81 +2000,18 @@ function _M:applyRandomClass(b, data, instant)
for k, resolver in pairs(mclass.copy or {}) do apply_resolvers(k, resolver) end
for k, resolver in pairs(class.copy or {}) do apply_resolvers(k, resolver) end
-- Assign a talent resolver for class starting talents (this makes them autoleveling)
local tres = nil
for k, resolver in pairs(b) do if type(resolver) == "table" and resolver.__resolver and resolver.__resolver == "talents" then tres = resolver break end end
if not tres then tres = resolvers.talents{} b[#b+1] = tres end
for tid, v in pairs(class.talents or {}) do
local t = b:getTalentFromId(tid)
if not t.no_npc_use and (not t.random_boss_rarity or rng.chance(t.random_boss_rarity)) then
local max = (t.points == 1) and 1 or math.ceil(t.points * 1.2)
local step = max / 50
tres[1][tid] = v + math.ceil(step * data.level)
end
end
-- Select additional talents from the class
local known_types = {}
for tt, d in pairs(b.talents_types) do
known_types[tt] = b:numberKnownTalent(tt)
end
local list = {}
for _, t in pairs(b.talents_def) do
if b.talents_types[t.type[1]] then
if t.no_npc_use or t.not_on_random_boss then
known_types[t.type[1]] = known_types[t.type[1]] + 1 -- allows higher tier talents to be learnt
else
local ok = true
if data.check_talents_level and rawget(t, 'require') then
local req = t.require
if type(req) == "function" then req = req(b, t) end
if req and req.level and util.getval(req.level, 1) > math.ceil(data.level/2) then
print("Random boss forbade talent because of level", t.name, t.id, data.level)
ok = false
end
end
if t.type[1]:find("/other$") then
print("Random boss forbase talent because category /other", t.name, t.id, t.type[1])
ok = false
end
if ok then list[t.id] = true end
end
end
end
local nb = 4 + 0.38*data.level^.75 -- = 11 at level 50
nb = math.max(rng.range(math.floor(nb * 0.7), math.ceil(nb * 1.3)), 1)
print("Adding "..nb.." random class talents to boss")
local count, fails = 0, 0
while count < nb do
local tid = rng.tableIndex(list, b.learn_tids)
if not tid or fails > nb * 5 then break end
local t = b:getTalentFromId(tid)
if t then
if t.type[2] and known_types[t.type[1]] < t.type[2] - 1 then -- not enough of talents of type
fails = fails + 1
else -- ok to add
count = count + 1
local max = (t.points == 1) and 1 or math.ceil(t.points * 1.2)
local step = max / 50
local lev = math.ceil(step * data.level)
print(count, " * talent:", tid, lev)
if instant then -- affected by game difficulty settings
if b:getTalentLevelRaw(tid) < lev then b:learnTalent(tid, true, lev - b:getTalentLevelRaw(tid)) end
if t.mode == "sustained" and data.auto_sustain then b:forceUseTalent(tid, {ignore_energy=true}) end
else -- applied when added to the level (unaffected by game difficulty settings)
b.learn_tids[tid] = lev
end
known_types[t.type[1]] = known_types[t.type[1]] + 1
list[tid] = nil
end
else list[tid] = nil
end
end
print(" ** Finished adding", count, "of", nb, "random class talents")
b.auto_classes = b.auto_classes or {}
local c_data = {
class = class.name,
ttypes = data.add_trees, -- adds specified talent trees
spend_points = data.spend_points,
start_level = data.start_level,
level_rate = level_rate or 100,
auto_sustain = data.auto_sustain,
check_talents_level = data.check_talents_level,
level_by_class = data.level_by_class,
}
table.insert(b.auto_classes, c_data)
return true
end
......@@ -2110,17 +2023,28 @@ function _M:applyRandomClass(b, data, instant)
-- Select classes
local classes = Birther.birth_descriptor_def.subclass
if data.force_classes then -- apply forced classes first, by index, then in random order
local c_list = table.clone(data.force_classes)
-- apply forced classes first, by index, then in random order, extracting specified or implied level rates
if data.force_classes then
local c_list = table.clone(data.force_classes, true)
local force_classes = {}
for i, c_name in ipairs(c_list) do
force_classes[i] = c_list[i]
if type(c_name) == "table" then
force_classes[i] = c_list[i]
else
force_classes[i] = {[c_name]=data.level_rate or 100} -- default 100% level_rate
end
c_list[i] = nil
end
table.append(force_classes, table.shuffle(table.keys(c_list)))
for i, c_name in ipairs(force_classes) do
local rng_fc = {}
for c_name, lr in pairs(c_list) do
table.insert(rng_fc, {[c_name]=(type(lr) == "number" and lr or data.level_rate or 100)})
end
table.append(force_classes, table.shuffle(rng_fc))
for i, cl in ipairs(force_classes) do
local c_name, lr = next(cl)
if classes[c_name] then
apply_class(table.clone(classes[c_name], true))
apply_class(table.clone(classes[c_name], true), lr)
else
print(" ###Forced class", c_name, "NOT DEFINED###")
end
......@@ -2132,16 +2056,25 @@ function _M:applyRandomClass(b, data, instant)
end
end
local to_apply = data.nb_classes or 2
-- apply random classes
local to_apply = data.nb_classes or 1.5 -- 1.5 is one primary class and one secondary class @ 50% stats/talents
print("[applyRandomClass] applying", to_apply, "classes at", data.level_rate, "%%")
while to_apply > 0 do
local c = rng.tableRemove(list)
if not c then break end --repeat attempts until list is exhausted
if data.no_class_restrictions or self:checkPowers(b, c) then -- recheck power restricts here to account for any previously picked classes
if apply_class(table.clone(c, true)) then to_apply = to_apply - 1 end
-- if nb_classes is not an integer, apply partial classes first so that resolvers for later classes take precedence
local lr = to_apply - math.floor(to_apply)
lr = lr == 0 and 1 or lr
if apply_class(table.clone(c, true), math.max(lr*(data.level_rate or 100), 10)) then
to_apply = to_apply - lr
if instant then b:levelup() end -- force immediate level up of class to appropriate level (learning talents, etc.)
end
else
print(" * class", c.name, " rejected due to power source")
end
end
if data.spend_points then -- spend any remaining unspent stat points
repeat
local last_stats = b.unused_stats
......@@ -2150,28 +2083,30 @@ function _M:applyRandomClass(b, data, instant)
end
end
--- Creates a random Boss (or elite) actor
-- @param base = base actor to add classes/talents to
-- calls _M:applyRandomClass(b, data, instant) to add classes, talents, and equipment based on class descriptors
-- handles data.nb_classes, data.force_classes, data.class_filter, ...
-- optional parameters:
-- @param data.init = function(data, b) to run before generation
-- @param data.level = minimum level range for actor generation <1>
-- @param data.rank = rank <3.5-4>
-- @param data.life_rating = function(b.life_rating) <1.7 * base.life_rating + 4-9>
-- @param data.resources_boost = multiplier for maximum resource pool sizes <3>
-- @param data.talent_cds_factor = multiplier for all talent cooldowns <1>
-- @param data.ai = ai_type <"tactical" if rank>3 or base.ai>
-- @param data.ai_tactic = tactical weights table for the tactical ai <nil - generated based on talents>
-- @param data.no_loot_randart set true to not drop a randart <nil>
-- @param data.on_die set true to run base.rng_boss_on_die and base.rng_boss_on_die_custom on death <nil>
-- @param data.name_scheme <randart_name_rules.default>
-- @param data.post = function(b, data) to run last to finish generation
-- @field data.init = function(data, b) to run before generation
-- @field data.level = minimum level range for actor generation <1>
-- @field data.nb_classes = number of random classes to add <1.75>
-- @field data.rank = rank <3.5-4>
-- @field data.life_rating = function(b.life_rating) <1.7 * base.life_rating + 4-9>
-- @field data.resources_boost = multiplier for maximum resource pool sizes <3>
-- @field data.talent_cds_factor = multiplier for all talent cooldowns <1>
-- @field data.ai = ai_type <"tactical" if rank>3 or base.ai>
-- @field data.ai_tactic = tactical weights table for the tactical ai <nil - generated based on talents>
-- @field data.no_loot_randart set true to not drop a randart <nil>
-- @field data.on_die set true to run base.rng_boss_on_die and base.rng_boss_on_die_custom on death <nil>
-- @field data.name_scheme <randart_name_rules.default>
-- @field data.post = function(b, data) to run last to finish generation
function _M:createRandomBoss(base, data)
local b = base:clone()
data = data or {level=1}
if data.init then data.init(data, b) end
data.nb_classes = data.nb_classes or 2
data.nb_classes = data.nb_classes or 1.75 -- Default one primary class @100% and one secondary class @ 75% level
------------------------------------------------------------
-- Basic stuff, name, rank, ...
......@@ -2230,6 +2165,7 @@ function _M:createRandomBoss(base, data)
-- Leveling stats
b.autolevel = "random_boss"
b.auto_stats = {}
b.max_level = nil
-- Update default equipment, if any, to "boss" levels
for k, resolver in ipairs(b) do
......@@ -2263,12 +2199,14 @@ function _M:createRandomBoss(base, data)
------------------------------------------------------------
-- Apply talents from classes
------------------------------------------------------------
self:applyRandomClass(b, data)
-- apply classes (instant to level up stats/talents before equipment is resolved)
self:applyRandomClass(b, data, true)
b.rnd_boss_on_added_to_level = b.on_added_to_level
b._rndboss_resources_boost = data.resources_boost or 3
b._rndboss_talent_cds = data.talent_cds_factor
b.on_added_to_level = function(self, ...)
self:levelup() -- this triggers processing of auto_classes to learn class talents and gain appropriate stats
self:check("birth_create_alchemist_golem")
self:check("rnd_boss_on_added_to_level", ...)
self.rnd_boss_on_added_to_level = nil
......
......@@ -481,26 +481,15 @@ function _M:addedToLevel(level, x, y)
if game.difficulty == game.DIFFICULTY_NIGHTMARE then
talent_mult = 1.3
life_mult = 1.5
nb_classes = util.bound(self.rank - 3.5, 0, 1) -- up to 1 extra class
elseif game.difficulty == game.DIFFICULTY_INSANE then
talent_mult = 1.8
life_mult = 2.0
if self.rank >= 3.5 then
if self.rank >= 10 then nb_classes = 3
elseif self.rank >= 5 then nb_classes = 2
else nb_classes = 1
end
end
nb_classes = util.bound(self.rank - 3, 0, 2) -- up to 2 extra classes
elseif game.difficulty == game.DIFFICULTY_MADNESS then
talent_mult = 2.7
life_mult = 3.0
if self.rank >= 3.5 then
if self.rank >= 10 then nb_classes = 5
elseif self.rank >= 5 then nb_classes = 3
elseif self.rank >= 4 then nb_classes = 2
else nb_classes = 1
end
end
nb_classes = util.bound((self.rank - 3)*1.5, 0, 3) -- up to 3 extra classes
end
if talent_mult ~= 1 then
-- increase level of innate talents
......@@ -510,13 +499,16 @@ function _M:addedToLevel(level, x, y)
self:learnTalent(tid, true, math.floor(lev*(talent_mult - 1)))
end
end
-- add extra character classes
if nb_classes > 0 and not self.randboss and not self.no_difficulty_random_class then
-- Note: talent levels from added classes are not adjusted for difficulty
-- add the extra character classes (halved for randbosses)
if nb_classes > 0 and not self.no_difficulty_random_class then
-- Note: talent levels from added classes are not adjusted for difficulty directly
-- This means that the NPC's innate talents are generally higher level, preserving its "character"
local data = {auto_sustain=true, forbid_equip=false, nb_classes=nb_classes, update_body=true, spend_points=true, autolevel=nb_classes<2 and self.autolevel or "random_boss"}
if self.randboss then nb_classes = nb_classes/2 end
local data = {auto_sustain=true, forbid_equip=nb_classes<1, nb_classes=nb_classes, update_body=true, spend_points=true, autolevel=nb_classes<2 and self.autolevel or "random_boss"}
game.state:applyRandomClass(self, data, true)
self[#self+1] = resolvers.talented_ai_tactic("instant") -- regenerate AI TACTICS with the new class(es)
self:resolve() self:resolve(nil, true)
self:resetToFull()
end
-- increase maximum life
......
......@@ -52,19 +52,23 @@ lines, fname, lnum = DebugConsole:functionHelp(game.state.createRandomBoss)
_M.data_help = "#GOLD#DATA HELP#LAST# "..formatHelp(lines, fname, lnum)
lines, fname, lnum = DebugConsole:functionHelp(game.state.applyRandomClass)
_M.data_help = _M.data_help.."\n#GOLD#DATA HELP#LAST# "..formatHelp(lines, fname, lnum)
lines, fname, lnum = DebugConsole:functionHelp(mod.class.Actor.levelupClass)
_M.data_help = _M.data_help.."\n#GOLD#DATA HELP#LAST# "..formatHelp(lines, fname, lnum)
function _M:init()
engine.ui.Dialog.init(self, "DEBUG -- Create Random Actor", 1, 1)
local tops={0} self.tops = tops
if not _M._base_actor then self:generateBase() end
-- if not _M._base_actor then self:generateBase() end
local dialog_txt = Textzone.new{auto_width=true, auto_height=true, no_color_bleed=true, font=self.font,
text=([[Randomly generate actors subject to a filter and/or create random bosses according to a data table.
Filters and data are interpreted by either game.zone:checkFilter or game.state:createRandomBoss and game.state:applyRandomClass respectively,
within the _G environment (used by the Lua Console) using the current zone's #LIGHT_GREEN#npc_list#LAST#. Press #GOLD#'F1'#LAST# for help.
Mouse over controls for actor preview. (Actors may be adjusted when placed on to the level.)
Filters are interpreted by game.zone:checkFilter.
#ORANGE#Boss Data:#LAST# is interpreted by game.state:createRandomBoss, game.state:applyRandomClass, and Actor.levelupClass.
Generation is performed within the _G environment (used by the Lua Console) using the current zone's #LIGHT_GREEN#npc_list#LAST#.
Press #GOLD#'F1'#LAST# for help.
Mouse over controls for an actor preview (which may be further adjusted when placed on to the level).
(Press #GOLD#'L'#LAST# to lua inspect or #GOLD#'C'#LAST# to open the character sheet.)
The #LIGHT_BLUE#Base Filter#LAST# is used to filter the actor randomly generated.]]):format(), can_focus=false}
......@@ -345,6 +349,7 @@ function _M:generateBase()
end
-- generate random boss
-- note: difficulty effects will not be reapplied when a base actor is used
function _M:generateBoss()
local base = _M._base_actor
if not base then
......
......@@ -130,6 +130,8 @@ function _M:placeCreature(m)
end
local Dstring = m.getDisplayString and m:getDisplayString() or ""
game.log("#LIGHT_BLUE#Added %s[%s]%s at (%d, %d)", Dstring, m.uid, m.name, x, y)
-- special cases
if m.alchemy_golem then game.zone:addEntity(game.level, m.alchemy_golem, "actor", util.findFreeGrid(m.x, m.y, 3, false, {[engine.Map.ACTOR]=true})) end
end
end
game:registerDialog(self)
......
......@@ -199,7 +199,7 @@ function resolvers.resolveObject(e, filter, do_wear, tries)
end
end
end
if not worn then print("General Object resolver]", o.uid, o.name, "COULD NOT BE WORN") end
if not worn then print("[General Object resolver]", o.uid, o.name, "COULD NOT BE WORN") end
end
-- if not worn, add to main inventory unless do_wear == false
if do_wear ~= false then
......@@ -808,7 +808,7 @@ function resolvers.calc.sustains_at_birth(_, e)
e.on_added = function(self)
for tid, _ in pairs(self.talents) do
local t = self:getTalentFromId(tid)
if t and t.mode == "sustained" then
if t and t.mode == "sustained" and not self:isTalentActive(tid) then
self.energy.value = game.energy_to_act
self:useTalent(tid, nil, nil, nil, nil, true)
end
......@@ -913,10 +913,11 @@ end
--- Resolve tactical ai weights based on talents known
-- mostly to make sure randbosses have sensible ai_tactic tables
-- this tends to make npc's slightly more aggressive/defensive depending on their talents
-- @param method = function to be applied to generating the ai_tactic table <not implemented>
-- @param tactic_emphasis = average weight of favored tactics <1.5>
-- @param weight_power = smoothing factor to balance out weights <0.5>
-- applied with "on_added_to_level"
-- @param method = function to be applied to generate the ai_tactic table <generally not implemented>
-- tactics are updated with "on_added_to_level"
-- use "instant" to resolve the tactics immediately using the "simple_recursive" method
-- @param tactic_emphasis = average weight of favored tactics, higher values make the NPC more aggressive or defensive <1.5>
-- @param weight_power = smoothing factor (> 0) to balance out weights <0.5>
function resolvers.talented_ai_tactic(method, tactic_emphasis, weight_power)
local method = method or "simple_recursive"
return {__resolver="talented_ai_tactic", method, tactic_emphasis or 1.5, weight_power, __resolve_last=true,
......@@ -931,9 +932,10 @@ function resolvers.calc.talented_ai_tactic(t, e)
end
--print("talented_ai_tactic resolver setting up on_added_to_level function")
--print(debug.traceback())
e.on_added_to_level = function(e, level, x, y)
local on_added = function(e, level, x, y)
print("running talented_ai_tactic resolver on_added_to_level function for", e.uid, e.name)
local t = e.__ai_tactic_resolver
if not t then print("talented_ai_tactic: No resolver table. Aborting") return end
e.__ai_tactic_resolver = nil
if t.old_on_added_to_level then t.old_on_added_to_level(e, level, x, y) end
......@@ -942,7 +944,7 @@ function resolvers.calc.talented_ai_tactic(t, e)
return t[1](t, e, level)
end
-- print(" # talented_ai_tactic resolver function for", e.name, "level=", e.level, e.uid)
local tactic_emphasis = t[2] or t.tactic_emphasis or 2 --want average tactic weight to be 2
local tactic_emphasis = t[2] or t.tactic_emphasis or 1.5 --desired average tactic weight
local weight_power = t[3] or t.weight_power or 0.5 --smooth out tactical weights
local tacs_offense = {attack=1, attackarea=1, areaattack=1}
local tacs_close = {closein=1, go_melee=1}
......@@ -1078,13 +1080,19 @@ function resolvers.calc.talented_ai_tactic(t, e)
tactic.tactical_sum=tactical
tactic.count = count
tactic.level = e.level
tactic.type = "computed"
tactic.type = "simple_recursive"
--- print("### talented_ai_tactic resolver ai_tactic table:")
--- for tac, wt in pairs(tactic) do print(" ##", tac, wt) end
e.ai_tactic = tactic
-- e.__ai_tactic_resolver = nil
return tactic
end
if t[1] == "instant" then
e.__ai_tactic_resolver = t
on_added(e, level or game.level, e.x, e.y)
else
e.on_added_to_level = on_added
end
end
--- Racial Talents resolver
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment