diff --git a/game/modules/tome/class/Actor.lua b/game/modules/tome/class/Actor.lua index 8f35a12bb332bee520dcfbf0c361f8f766880146..626fe4893a85dffa18f8af6426341474a1ce0064 100644 --- a/game/modules/tome/class/Actor.lua +++ b/game/modules/tome/class/Actor.lua @@ -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() diff --git a/game/modules/tome/class/GameState.lua b/game/modules/tome/class/GameState.lua index df21e3b4de1e02f99e3285eb97571e76e2d705fa..e9b05c2a30ab954a7c91f9d5efca7b1f12483cd3 100644 --- a/game/modules/tome/class/GameState.lua +++ b/game/modules/tome/class/GameState.lua @@ -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 diff --git a/game/modules/tome/class/NPC.lua b/game/modules/tome/class/NPC.lua index 8cb5f86e1af22253e606b9801b7d590d7e51b403..01f814fbcf3c6dd1a8c7f60b118e4bb8756a6032 100644 --- a/game/modules/tome/class/NPC.lua +++ b/game/modules/tome/class/NPC.lua @@ -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 diff --git a/game/modules/tome/dialogs/debug/RandomActor.lua b/game/modules/tome/dialogs/debug/RandomActor.lua index a9a07cf22235be7b2578bab3096cc2afe321f619..effd2d2dbd2cfab607760e845d7c0b1d69458a53 100644 --- a/game/modules/tome/dialogs/debug/RandomActor.lua +++ b/game/modules/tome/dialogs/debug/RandomActor.lua @@ -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 diff --git a/game/modules/tome/dialogs/debug/SummonCreature.lua b/game/modules/tome/dialogs/debug/SummonCreature.lua index 3a58fa4e30837de3f7e1e207a4d382954b9cebfc..e2681afc6eea138a495ed9b17ce2217085e61fab 100644 --- a/game/modules/tome/dialogs/debug/SummonCreature.lua +++ b/game/modules/tome/dialogs/debug/SummonCreature.lua @@ -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) diff --git a/game/modules/tome/resolvers.lua b/game/modules/tome/resolvers.lua index e761aa18386790e83f6c851c9fe9e84310192da9..284da639ab62c5d7c0548e013b5955f57eae5bdd 100644 --- a/game/modules/tome/resolvers.lua +++ b/game/modules/tome/resolvers.lua @@ -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