diff --git a/MWSE/mods/celediel/NPCsGoHome/common.lua b/MWSE/mods/celediel/NPCsGoHome/common.lua index 3a052ee..0924272 100644 --- a/MWSE/mods/celediel/NPCsGoHome/common.lua +++ b/MWSE/mods/celediel/NPCsGoHome/common.lua @@ -2,6 +2,7 @@ local inspect = require("inspect") local this = {} +-- {{{ Variables and such this.modName = "NPCs Go Home (At Night)" this.author = "OEA/Celediel" this.version = "0.0.1" @@ -10,7 +11,25 @@ this.modInfo = "Move NPCs to their homes, or public houses (or just disable them this.configPath = "NPCSGOHOME" this.logLevels = {none = 0, small = 1, medium = 2, large = 3} +-- }}} +-- {{{ Filled at runtime +this.runtimeData = { + -- cells marked as public + publicHouses = {}, + -- homes picked for NPCs + homes = { + byName = {}, + byCell = {} + }, + -- NPCs who have been moved + movedNPCs = {}, + -- player companions + followers = {} +} +-- }}} + +-- {{{ Functions this.split = function(input, sep) if not input then return end if not sep then sep = "%s" end @@ -34,5 +53,6 @@ this.vowel = function(str) return n end +-- }}} return this diff --git a/MWSE/mods/celediel/NPCsGoHome/positions.lua b/MWSE/mods/celediel/NPCsGoHome/data/positions.lua similarity index 100% rename from MWSE/mods/celediel/NPCsGoHome/positions.lua rename to MWSE/mods/celediel/NPCsGoHome/data/positions.lua diff --git a/MWSE/mods/celediel/NPCsGoHome/functions/checks.lua b/MWSE/mods/celediel/NPCsGoHome/functions/checks.lua new file mode 100644 index 0000000..6c95e0c --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/functions/checks.lua @@ -0,0 +1,319 @@ +local common = require("celediel.NPCsGoHome.common") +local config = require("celediel.NPCsGoHome.config").getConfig() +local housing = require("celediel.NPCsGoHome.functions.housing") + +-- {{{ local variables and such +-- Waistworks string match +local waistworks = { + "[Cc]analworks", -- These will match Vivec and Molag Mar + "[Ww]aistworks" -- and Almas Thirr from Tamriel Rebuilt +} +-- these are separate because doors to underworks should be ignored +-- but NPCs in underworks should not be disabled +local underworks = "[Uu]nderworks" + +-- city name if cell.name is nil +local wilderness = "Wilderness" +-- }}} + +-- {{{ local functions +local function log(level, ...) if config.logLevel >= level then common.log(...) end end + +-- patented by Merlord +local yeet = function(reference) + -- tes3.positionCell({reference = reference, position = {0, 0, 10000}}) + reference:disable() + timer.delayOneFrame(function() mwscript.setDelete({reference = reference}) end) +end + +-- very todd workaround +local function getFightFromSpawnedReference(id) + -- Spawn a reference of the given id in toddtest + local toddTest = tes3.getCell("toddtest") + + log(common.logLevels.medium, "Spawning %s in %s", id, toddTest.id) + + local ref = tes3.createReference({ + object = id, + -- cell = toddTest, + cell = tes3.getPlayerCell(), + -- position = zeroVector, + position = {0, 0, 10000}, + orientation = housing.zeroVector + }) + + local fight = ref.mobile.fight + + log(common.logLevels.medium, "Got fight of %s, time to yeet %s", fight, id) + + yeet(ref) + + return fight +end +-- }}} + +local this = {} + +this.isInteriorCell = function(cell) + if not cell then return end + + log(common.logLevels.large, "Cell %s: interior: %s, behaves as exterior: %s therefore returning %s", + cell.id, cell.isInterior, cell.behavesAsExterior, cell.isInterior and not cell.behavesAsExterior) + + return cell.isInterior and not cell.behavesAsExterior +end + +this.isCityCell = function(internalCellId, externalCellId) + -- easy mode + if string.match(internalCellId, externalCellId) then + log(common.logLevels.large, "easy mode city: %s in %s", internalCellId, externalCellId) + return true + end + + local cityMatch = "^(%w+), (.*)" + -- check for "advanced" cities + local _, _, internalCity = string.find(internalCellId, cityMatch) + local _, _, externalCity = string.find(externalCellId, cityMatch) + + if externalCity and externalCity == internalCity then + log(common.logLevels.large, "hard mode city: %s in %s, %s == %s", + internalCellId, externalCellId, externalCity, internalCity) + return true + end + + log(common.logLevels.large, "hard mode not city: %s not in %s, %s ~= %s or both are nil", + internalCellId, externalCellId, externalCity, internalCity) + return false +end + +this.isIgnoredCell = function(cell) + log(common.logLevels.large, "%s is %s, %s is %s", cell.id, config.ignored[cell.id] and "ignored" or "not ignored", + cell.sourceMod, config.ignored[cell.sourceMod] and "ignored" or "not ignored") + + -- don't do things in the wilderness + -- local wilderness = false + -- if not cell.name then wilderness = true end + + return config.ignored[cell.id] or config.ignored[cell.sourceMod] -- or wilderness +end + +this.isCantonCell = function(cellName) + for _, str in pairs(waistworks) do if cellName:match(str) then return true end end + return false +end + +-- ! this one depends on tes3 ! -- +this.fargothCheck = function() + local fargothJournal = tes3.getJournalIndex({id = "MS_Lookout"}) + if not fargothJournal then return false end + + -- only disable Fargoth before speaking to Hrisskar, and after observing Fargoth sneak + log(common.logLevels.large, "Fargoth journal check %s: %s", fargothJournal, + fargothJournal > 10 and fargothJournal <= 30) + + return fargothJournal > 10 and fargothJournal <= 30 +end + +this.isIgnoredNPC = function(npc) + local obj = npc.baseObject and npc.baseObject or npc.object + + -- ignore dead, attack on sight NPCs, and vampires + local isDead = false + local isHostile = false + local isVampire = false + + if npc.mobile then + if npc.mobile.health.current <= 0 or npc.mobile.isDead then isDead = true end + if npc.mobile.fight > 70 then isHostile = true end + isVampire = tes3.isAffectedBy({reference = npc, effect = tes3.effect.vampirism}) + else + -- local fight = getFightFromSpawnedReference(obj.id) -- ! calling this hundreds of times is bad for performance lol + -- if (fight or 0) > 70 then isHostile = true end + isVampire = obj.head and (obj.head.vampiric and true or false) or false + if obj.id:match("[Dd]ead") or obj.name:match("[Dd]ead") then isDead = true end + end + + local isFargothActive = obj.id:match("fargoth") and this.fargothCheck() or false + + -- todo: non mwscript version of this + local isWerewolf = mwscript.getSpellEffects({reference = npc, spell = "werewolf vision"}) + -- local isVampire = mwscript.getSpellEffects({reference = npc, spell = "vampire sun damage"}) + + -- this just keeps getting uglier but it's debug logging so whatever I don't care + log(common.logLevels.large, ("Checking NPC:%s (%s or %s): id blocked:%s, %s blocked:%s " .. -- + "guard:%s dead:%s vampire:%s werewolf:%s dreamer:%s follower:%s hostile:%s %s%s"), -- + obj.name, npc.object.id, npc.object.baseObject and npc.object.baseObject.id or "nil", -- + config.ignored[obj.id:lower()], obj.sourceMod, config.ignored[obj.sourceMod:lower()], -- + obj.isGuard, isDead, isVampire, isWerewolf, (obj.class and obj.class.id == "Dreamers"), -- + common.runtimeData.followers[npc.object.id], isHostile, obj.id:match("fargoth") and "fargoth:" or "", -- + obj.id:match("fargoth") and isFargothActive or "") + + return config.ignored[obj.id:lower()] or -- + config.ignored[obj.sourceMod:lower()] or -- + obj.isGuard or -- + isFargothActive or -- + isDead or -- don't move dead NPCS + isHostile or -- + common.runtimeData.followers[npc.object.id] or -- ignore followers + isVampire or -- + isWerewolf or -- + (obj.class and obj.class.id == "Dreamers") -- +end + +this.isNPCPet = function(creature) + local obj = creature.baseObject and creature.baseObject or creature.object + + -- todo: more pets? + if obj.id:match("guar") and obj.mesh:match("pack") then + return true + else + return false + end +end + +-- checks NPC class and faction in cells for block list and adds to publicHouse list +-- todo: rewrite this +this.isPublicHouse = function(cell) + -- only interior cells are public "houses" + if not this.isInteriorCell(cell) then return false end + + local typeOfPub = housing.pickPublicHouseType(cell) + local city, publicHouseName + + if cell.name and string.match(cell.name, ",") then + city = common.split(cell.name, ",")[1] + publicHouseName = common.split(cell.name, ",")[2]:gsub("^%s", "") + else + city = wilderness + publicHouseName = cell.id + end + + -- don't iterate NPCs in the cell if we've already marked it public + if common.runtimeData.publicHouses[city] and (common.runtimeData.publicHouses[city][typeOfPub] and common.runtimeData.publicHouses[city][typeOfPub][cell.id]) then return true end + + local npcs = {factions = {}, total = 0} + for npc in cell:iterateReferences(tes3.objectType.npc) do + -- Check for NPCS of ignored classes first + if not this.isIgnoredNPC(npc) then + if npc.object.class and config.ignored[npc.object.class.id] then + log(common.logLevels.medium, "NPC:\'%s\' of class:\'%s\' made %s public", npc.object.name, + npc.object.class and npc.object.class.id or "none", cell.name) + + housing.createPublicHouseTableEntry(cell, npc, city, publicHouseName) + + return true + end + + local faction = npc.object.faction + + if faction then + if not npcs.factions[faction] then npcs.factions[faction] = {total = 0, percentage = 0} end + + if not npcs.factions[faction].master or npcs.factions[faction].master.object.factionIndex < + npc.object.factionIndex then npcs.factions[faction].master = npc end + + npcs.factions[faction].total = npcs.factions[faction].total + 1 + end + + npcs.total = npcs.total + 1 + end + end + + -- no NPCs of ignored classes, so let's check out factions + for faction, info in pairs(npcs.factions) do + info.percentage = (info.total / npcs.total) * 100 + log(common.logLevels.large, + "No NPCs of ignored class in %s, checking faction %s (ignored: %s, player joined: %s) with %s (%s%%) vs total %s", + cell.name, faction, config.ignored[faction.id], faction.playerJoined, info.total, info.percentage, + npcs.total) + + -- less than 3 NPCs can't possibly be a public house unless it's a Blades house + if (config.ignored[faction.id] or faction.playerJoined) and + (npcs.total >= config.minimumOccupancy or faction == "Blades") and info.percentage >= + config.factionIgnorePercentage then + log(common.logLevels.medium, "%s is %s%% faction %s, marking public.", cell.name, info.percentage, faction) + + housing.createPublicHouseTableEntry(cell, npcs.factions[faction].master, city, publicHouseName) + return true + end + end + + log(common.logLevels.large, "%s isn't public", cell.name) + return false +end + +-- doors that lead to ignored, exterior, canton, unoccupied, or public cells, and doors that aren't in cities +this.isIgnoredDoor = function(door, homeCellId) + -- don't lock non-cell change doors + if not door.destination then + log(common.logLevels.large, "Non-Cell-change door %s, ignoring", door.id) + return true + end + + -- we use this a lot, so set a reference to it + local dest = door.destination.cell + + -- Only doors in cities and towns (interior cells with names that contain the exterior cell) + local inCity = this.isCityCell(dest.id, homeCellId) + + -- peek inside doors to look for guild halls, inns and clubs + local leadsToPublicCell = this.isPublicHouse(dest) + + -- don't lock unoccupied cells + local hasOccupants = false + for npc in dest:iterateReferences(tes3.objectType.npc) do + if not this.isIgnoredNPC(npc) then + hasOccupants = true + break + end + end + + -- don't lock doors to underworks in addition to other canton cells + local isCanton = this.isCantonCell(dest.id) or dest.id:match(underworks) + + log(common.logLevels.large, "%s is %s, (%sin a city, is %spublic, %soccupied)", -- + dest.id, this.isIgnoredCell(dest) and "ignored" or "not ignored", -- destination is ignored + inCity and "" or "not ", leadsToPublicCell and "" or "not ", hasOccupants and "" or "un") -- in a city, is public, is ocupado + + return this.isIgnoredCell(dest) or + not this.isInteriorCell(dest) or + isCanton or + not inCity or + leadsToPublicCell or + not hasOccupants +end + +-- AT NIGHT +this.checkTime = function() + log(common.logLevels.large, "Current time is %s, things are closed between %s and %s", + tes3.worldController.hour.value, config.closeTime, config.openTime) + return tes3.worldController.hour.value >= config.closeTime or tes3.worldController.hour.value <= config.openTime +end + +-- inclement weather +this.checkWeather = function(cell) + if not cell.region then return end + + log(common.logLevels.large, "Weather: %s >= %s == %s", cell.region.weather.index, config.worstWeather, + cell.region.weather.index >= config.worstWeather) + + return cell.region.weather.index >= config.worstWeather +end + +-- travel agents, their steeds, and argonians stick around +this.isBadWeatherNPC = function(npc) + local obj = npc.baseObject and npc.baseObject or npc.object + if not obj then return end + + log(common.logLevels.large, "NPC Inclement Weather: %s is %s, %s", npc.object.name, npc.object.class.name, + npc.object.race.id) + + -- todo: better detection of NPCs who offer travel services + -- found a rogue "shipmaster" in molag mar + return obj.class.name == "Caravaner" or + obj.class.name == "Gondolier" or + obj.class.name == "Shipmaster" or + obj.race.id == "Argonian" +end + +return this diff --git a/MWSE/mods/celediel/NPCsGoHome/functions/housing.lua b/MWSE/mods/celediel/NPCsGoHome/functions/housing.lua new file mode 100644 index 0000000..d160899 --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/functions/housing.lua @@ -0,0 +1,265 @@ +local common = require("celediel.NPCsGoHome.common") +local config = require("celediel.NPCsGoHome.config").getConfig() +local interop = require("celediel.NPCsGoHome.interop") +local positions = require("celediel.NPCsGoHome.data.positions") + +local function log(level, ...) if config.logLevel >= level then common.log(...) end end + +local publicHouseTypes = {inns = "Inns", guildhalls = "Guildhalls", temples = "Temples", houses = "Houses"} + +-- animated morrowind NPCs are contextual +local contextualNPCs = {"^AM_"} + +local this = {} + +this.zeroVector = tes3vector3.new(0, 0, 0) + +this.checkModdedCell = function(cellId) + local id + + if cellId == "Balmora, South Wall Cornerclub" and tes3.isModActive("South Wall.ESP") then + id = "Balmora, South Wall Den Of Iniquity" + elseif cellId == "Balmora, Eight Plates" and tes3.isModActive("Eight Plates.esp") then + id = "Balmora, Seedy Eight Plates" + elseif cellId == "Hla Oad, Fatleg's Drop Off" and tes3.isModActive("Clean DR115_TheDropoff_HlaOadDocks.ESP") then + id = "Hla Oad, The Drop Off" + else + id = cellId + end + + return id +end +-- {{{ npc evaluators + +-- NPCs barter gold + value of all inventory items +this.calculateNPCWorth = function(npc, merchantCell) + local worth = npc.object.barterGold + local obj = npc.baseObject and npc.baseObject or npc.object + + if npc.object.inventory then + for _, item in pairs(npc.object.inventory) do worth = worth + (item.object.value or 0) end + end + + if merchantCell then -- if we pass a cell argument + for box in merchantCell:iterateReferences(tes3.objectType.container) do -- loop over each container + if box.inventory then -- if it's not empty + for item in tes3.iterate(box.inventory) do -- loop over its items + if obj:tradesItemType(item.objectType) then -- if the NPC sells that type + worth = worth + item.object.value -- add its value to the NPCs total value + end + end + end + end + end + + return worth +end + +-- }}} + +-- todo: pick this better +this.pickPublicHouseType = function(cell) + if cell.id:match("Guild") then + return publicHouseTypes.guildhalls + elseif cell.id:match("Temple") then + return publicHouseTypes.temples + -- elseif cell.id:match("House") then + -- return publicHouseTypes.houses + else + return publicHouseTypes.inns + end +end + +-- ? I honestly don't know if there are any wandering NPCs that "live" in close-by manors, but I wrote this anyway +this.livesInManor = function(cellName, npcName) + if not cellName or (cellName and not string.find(cellName, "Manor")) then return end + + local splitName = common.split(npcName) + local given = splitName[1] + local sur = splitName[2] + + -- surnameless peasants don't live in manors + if not sur then return end + + log(common.logLevels.large, "Checking if %s %s lives in %s", given, sur, cellName) + return string.match(cellName, sur) +end + +this.pickInnForNPC = function(npc, city) + -- todo: pick in Inn intelligently ? + -- high class inns for nobles and rich merchants and such + -- lower class inns for middle class npcs and merchants + -- temple for commoners and the poorest people + -- ? pick based on barterGold and value of equipment for merchants ? + -- ? for others, pick based on value of equipment + + -- but for now pick one at random + if common.runtimeData.publicHouses[city] and common.runtimeData.publicHouses[city][publicHouseTypes.inns] then + local choice = table.choice(common.runtimeData.publicHouses[city][publicHouseTypes.inns]) + if not choice then return end + log(common.logLevels.medium, "Picking inn %s, %s for %s", choice.city, choice.name, npc.object.name) + return choice.cell + end +end + +this.pickPublicHouseForNPC = function(npc, city) + -- look for wandering guild members + if common.runtimeData.publicHouses[city] and common.runtimeData.publicHouses[city][publicHouseTypes.guildhalls] then + for _, data in pairs(common.runtimeData.publicHouses[city][publicHouseTypes.guildhalls]) do + -- if npc's faction and proprietor's faction match, pick that one + if npc.object.faction == data.proprietor.object.faction then + log(common.logLevels.medium, "Picking %s for %s based on faction", data.cell.id, npc.object.name) + return data.cell + end + end + end + + -- temple members go to the temple + if common.runtimeData.publicHouses[city] and common.runtimeData.publicHouses[city][publicHouseTypes.temples] then + for _, data in pairs(common.runtimeData.publicHouses[city][publicHouseTypes.temples]) do + if npc.object.faction == data.proprietor.object.faction then + log(common.logLevels.medium, "Picking temple %s for %s based on faction", data.cell.id, npc.object.name) + return data.cell + end + end + end + + -- found nothing so pick an inn + return this.pickInnForNPC(npc, city) +end + +this.createHomedNPCTableEntry = function(npc, home, startingPlace, isHome, position, orientation) + if npc.object and (npc.object.name == nil or npc.object.name == "") then return end + + local pickedPosition, pickedOrientation, pos, ori + + -- mod support for different positions in cells + local id = this.checkModdedCell(home.id) + + log(common.logLevels.medium, "Found %s for %s: %s... adding it to in memory table...", + isHome and "home" or "public house", npc.object.name, id) + + if isHome and positions.npcs[npc.object.name] then + pos = positions.npcs[npc.object.name].position + ori = positions.npcs[npc.object.name].orientation + -- pickedPosition = positions.npcs[npc.object.name] and tes3vector3.new(p[1], p[2], p[3]) or zeroVector:copy() + -- pickedOrientation = positions.npcs[npc.object.name] and tes3vector3.new(o[1], o[2], o[3]) or zeroVector:copy() + elseif positions.cells[id] then + pos = table.choice(positions.cells[id]).position + ori = table.choice(positions.cells[id]).orientation + -- pickedPosition = positions.cells[id] and tes3vector3.new(p[1], p[2], p[3]) or zeroVector:copy() + -- pickedOrientation = positions.cells[id] and tes3vector3.new(o[1], o[2], o[3]) or zeroVector:copy() + -- pickedPosition = tes3vector3.new(p[1], p[2], p[3]) + -- pickedOrientation = tes3vector3.new(o[1], o[2], o[3]) + else + pos = {0,0,0} + ori = {0,0,0} + -- pickedPosition = zeroVector:copy() + -- pickedOrientation = zeroVector:copy() + end + + pickedPosition = tes3vector3.new(pos[1], pos[2], pos[3]) + pickedOrientation = tes3vector3.new(ori[1], ori[2], ori[3]) + + local ogPosition = position and + (tes3vector3.new(position.x, position.y, position.z)) or + (npc.position and npc.position:copy() or zeroVector:copy()) + + local ogOrientation = orientation and + (tes3vector3.new(orientation.x, orientation.y, orientation.z)) or + (npc.orientation and npc.orientation:copy() or zeroVector:copy()) + + local this = { + name = npc.object.name, -- string + npc = npc, -- tes3npc + isHome = isHome, -- bool + home = home, -- tes3cell + homeName = home.id, -- string + ogPlace = startingPlace, -- tes3cell + ogPlaceName = startingPlace.id, + ogPosition = ogPosition, -- tes3vector3 + ogOrientation = ogOrientation, -- tes3vector3 + homePosition = pickedPosition, -- tes3vector3 + homeOrientation = pickedOrientation, -- tes3vector3 + worth = this.calculateNPCWorth(npc) -- int + } + + common.runtimeData.homes.byName[npc.object.name] = this + if isHome then common.runtimeData.homes.byCell[home.id] = this end + + interop.setHomedNPCTable(common.runtimeData.homes.byName) + + return this +end + +this.createPublicHouseTableEntry = function(publicCell, proprietor, city, name) + local typeOfPub = this.pickPublicHouseType(publicCell) + + local worth = 0 + + -- for houses, worth is equal to NPC who lives there + -- if typeOfPub == publicHouseTypes.houses then + -- worth = calculateNPCWorth(proprietor) + -- else + -- for other types, worth is combined worth of all NPCs + for innard in publicCell:iterateReferences(tes3.objectType.npc) do + if innard == proprietor then + worth = worth + this.calculateNPCWorth(innard, publicCell) + else + worth = worth + this.calculateNPCWorth(innard) + end + end + -- end + + if not common.runtimeData.publicHouses[city] then common.runtimeData.publicHouses[city] = {} end + if not common.runtimeData.publicHouses[city][typeOfPub] then common.runtimeData.publicHouses[city][typeOfPub] = {} end + + common.runtimeData.publicHouses[city][typeOfPub][publicCell.id] = { + name = name, + city = city, + cell = publicCell, + proprietor = proprietor, + proprietorName = proprietor.object.name, + worth = worth + } + + interop.setPublicHouseTable(common.runtimeData.publicHouses) +end + +-- looks through doors to find a cell that matches a wandering NPCs name +this.pickHomeForNPC = function(cell, npc) + -- wilderness cells don't have name + if not cell.name then return end + + -- don't move contextual, such as Animated Morrowind, NPCs at all + for _, str in pairs(contextualNPCs) do if npc.object.id:match(str) then return end end + + local name = npc.object.name + local city = common.split(cell.name, ",")[1] + for door in cell:iterateReferences(tes3.objectType.door) do + if door.destination then + local dest = door.destination.cell + + -- essentially, if npc full name, or surname matches the cell name + if dest.id:match(name) or this.livesInManor(dest.name, name) then + if common.runtimeData.homes.byName[name] then -- already have a home, don't create the table entry again + return common.runtimeData.homes.byName[name] + else + return this.createHomedNPCTableEntry(npc, dest, cell, true) + end + end + end + end + + -- haven't found a home, so put them in an inn or guildhall + if config.homelessWanderersToPublicHouses then + log(common.logLevels.medium, "Didn't find a home for %s, trying inns", npc.object.name) + local dest = this.pickPublicHouseForNPC(npc, city) + -- return createHomedNPCTableEntry(npc, dest, door) + if dest then return this.createHomedNPCTableEntry(npc, dest, cell, false) end + end + + return nil +end + +return this diff --git a/MWSE/mods/celediel/NPCsGoHome/functions/processors.lua b/MWSE/mods/celediel/NPCsGoHome/functions/processors.lua new file mode 100644 index 0000000..98156da --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/functions/processors.lua @@ -0,0 +1,239 @@ +local common = require("celediel.NPCsGoHome.common") +local config = require("celediel.NPCsGoHome.config").getConfig() +local checks = require("celediel.NPCsGoHome.functions.checks") +local interop = require("celediel.NPCsGoHome.interop") +local housing = require("celediel.NPCsGoHome.functions.housing") + +local function log(level, ...) if config.logLevel >= level then common.log(...) end end + +local this = {} + +-- search in a specific cell for moved NPCs +this.checkForMovedNPCs = function(cell) + -- NPCs don't get moved to exterior cells, so no need to check them for moved NPCs + if not checks.isInteriorCell(cell) then return end + + log(common.logLevels.medium, "Looking for moved NPCs in cell %s", cell.id) + for npc in cell:iterateReferences(tes3.objectType.npc) do + if npc.data and npc.data.NPCsGoHome then + housing.createHomedNPCTableEntry(npc, cell, tes3.getCell(npc.data.NPCsGoHome.cell), true, npc.data.NPCsGoHome.position, npc.data.NPCsGoHome.orientation) + end + end +end + +this.searchCellsForNPCs = function() + for _, cell in pairs(tes3.getActiveCells()) do + -- check active cells + this.checkForMovedNPCs(cell) + for door in cell:iterateReferences(tes3.objectType.door) do + if door.destination then + -- then check cells attached to active cells + this.checkForMovedNPCs(door.destination.cell) + end + end + end +end + +this.moveNPC = function(homeData) + -- add to in memory table + table.insert(common.runtimeData.movedNPCs, homeData) + interop.setMovedNPCTable(common.runtimeData.movedNPCs) + + -- set npc data, so we can move NPCs back after a load + local npc = homeData.npc + npc.data.NPCsGoHome = { + position = { + x = npc.position.x, + y = npc.position.y, + z = npc.position.z, + }, + orientation = { + x = npc.orientation.x, + y = npc.orientation.y, + z = npc.orientation.z, + }, + cell = homeData.ogPlaceName + } + + tes3.positionCell({ + cell = homeData.home, + reference = homeData.npc, + position = homeData.homePosition, + orientation = homeData.homeOrientation + }) + + log(common.logLevels.medium, "Moving %s to home %s (%s, %s, %s)", homeData.npc.object.name, homeData.home.id, + homeData.homePosition.x, homeData.homePosition.y, homeData.homePosition.z) +end + +this.putNPCsBack = function() + for i = #common.runtimeData.movedNPCs, 1, -1 do + local data = table.remove(common.runtimeData.movedNPCs, i) + log(common.logLevels.medium, "Moving %s back outside to %s (%s, %s, %s)", data.npc.object.name, data.ogPlace.id, + data.ogPosition.x, data.ogPosition.y, data.ogPosition.z) + + -- unset NPC data so we don't try to move them on load + data.npc.data.NPCsGoHome = nil + + -- and put them back + tes3.positionCell({ + cell = data.ogPlace, + reference = data.npc, + position = data.ogPosition, + orientation = data.ogPlace + }) + end + interop.setMovedNPCTable(common.runtimeData.movedNPCs) +end + +this.processNPCs = function(cell) + -- todo: move this check somewhere else, so that disabled NPCs will be re-enabled even if the option is off + if not config.disableNPCs then return end + + log(common.logLevels.small, "Looking for NPCs to process in cell:%s", cell.id) + + -- iterate NPCs in the cell, move them to their homes, and keep track of moved NPCs so we can move them back later + for npc in cell:iterateReferences(tes3.objectType.npc) do + -- for npc, _ in pairs(cellsInMemory[cell].npcs) do + if not checks.isIgnoredNPC(npc) then + log(common.logLevels.large, "People change") + -- if not npc.data.NPCsGoHome then npc.data.NPCsGoHome = {} end + + -- find NPC homes + local npcHome = config.moveNPCs and housing.pickHomeForNPC(cell, npc) or nil + + local tmpLogLevelNPCHome = npcHome and common.logLevels.medium or common.logLevels.large + log(tmpLogLevelNPCHome, "%s %s %s%s", npc.object.name, + npcHome and (npcHome.isHome and "lives in" or "goes to") or "lives", + npcHome and npcHome.home or "nowhere", npcHome and (npcHome.isHome and "." or " at night.") or ".") + + -- disable or move NPCs + if (checks.checkTime() or + (checks.checkWeather(cell) and + (not checks.isBadWeatherNPC(npc) or (checks.isBadWeatherNPC(npc) and not config.keepBadWeatherNPCs)))) then + if npcHome then + this.moveNPC(npcHome) + -- elseif not npc.data.NPCsGoHome.modified then + elseif not npc.disabled then + log(common.logLevels.medium, "Disabling homeless %s", npc.object.name) + -- npc:disable() -- ! this one sometimes causes crashes + mwscript.disable({reference = npc}) -- ! this one is deprecated + -- tes3.setEnabled({reference = npc, enabled = false}) -- ! but this one causes crashes too + -- npc.data.NPCsGoHome.modified = true + else + log(common.logLevels.medium, "Didn't do anything with %s", npc.object.name) + end + else + -- if not npcHome and npc.data.modified then + if not npcHome and npc.disabled then + log(common.logLevels.medium, "Enabling homeless %s", npc.object.name) + -- npc:enable() + mwscript.enable({reference = npc}) + -- tes3.setEnabled({reference = npc, enabled = true}) + -- npc.data.NPCsGoHome.modified = false + end + end + end + end + + -- now put NPCs back + -- if not (checks.checkTime() or checks.checkWeather(cell)) and #movedNPCs > 0 then putNPCsBack() end + if not (checks.checkTime() or checks.checkWeather(cell)) then this.putNPCsBack() end +end + +this.processSiltStriders = function(cell) + if not config.disableNPCs then return end + + log(common.logLevels.small, "Looking for silt striders to process in cell:%s", cell.name) + for activator in cell:iterateReferences(tes3.objectType.activator) do + log(common.logLevels.large, "Is %s a silt strider??", activator.object.id) + if activator.object.id:match("siltstrider") then + if checks.checkTime() or (checks.checkWeather(cell) and not config.keepBadWeatherNPCs) then + if not activator.disabled then + log(common.logLevels.medium, "Disabling silt strider %s!", activator.object.name) + mwscript.disable({reference = activator}) + -- activator:disable() + -- tes3.setEnabled({reference = activator, enabled = false}) + end + else + if activator.disabled then + log(common.logLevels.medium, "Enabling silt strider %s!", activator.object.name) + mwscript.enable({reference = activator}) + -- activator:enable() + -- tes3.setEnabled({reference = activator, enabled = true}) + end + end + end + end + log(common.logLevels.large, "Done with silt striders") +end + +-- deal with trader's guars, and other npc linked creatures/whatever +this.processPets = function(cell) + if not config.disableNPCs then return end + + log(common.logLevels.small, "Looking for NPC pets to process in cell:%s", cell.name) + + for creature in cell:iterateReferences(tes3.objectType.creature) do + if checks.isNPCPet(creature) then + if checks.checkTime() then + -- disable + if not creature.disabled then + log(common.logLevels.medium, "Disabling NPC Pet %s!", creature.object.id) + mwscript.disable({reference = creature }) + end + else + -- enable + if creature.disabled then + log(common.logLevels.medium, "Enabling NPC Pet %s!", creature.object.id) + mwscript.enable({reference = creature }) + end + end + end + end +end + +this.processDoors = function(cell) + if not config.lockDoors then return end + + log(common.logLevels.small, "Looking for doors to process in cell:%s", cell.id) + + for door in cell:iterateReferences(tes3.objectType.door) do + if not door.data.NPCsGoHome then door.data.NPCsGoHome = {} end + + if not checks.isIgnoredDoor(door, cell.id) then + -- don't mess around with doors that are already locked + if door.data.NPCsGoHome.alreadyLocked == nil then + door.data.NPCsGoHome.alreadyLocked = tes3.getLocked({reference = door}) + end + + log(common.logLevels.large, "Found %slocked %s with destination %s", + door.data.NPCsGoHome.alreadyLocked and "" or "un", door.id, door.destination.cell.id) + + if checks.checkTime() then + if not door.data.NPCsGoHome.alreadyLocked then + log(common.logLevels.medium, "locking: %s to %s", door.object.name, door.destination.cell.id) + + local lockLevel = math.random(25, 100) + tes3.lock({reference = door, level = lockLevel}) + door.data.NPCsGoHome.modified = true + end + else + -- only unlock doors that we locked before + if door.data.NPCsGoHome.modified then + door.data.NPCsGoHome.modified = false + + tes3.setLockLevel({reference = door, level = 0}) + tes3.unlock({reference = door}) + + log(common.logLevels.medium, "unlocking: %s to %s", door.object.name, door.destination.cell.id) + end + end + + log(common.logLevels.large, "Now locked Status: %s", tes3.getLocked({reference = door})) + end + end + log(common.logLevels.large, "Done with doors") +end + +return this diff --git a/MWSE/mods/celediel/NPCsGoHome/main.lua b/MWSE/mods/celediel/NPCsGoHome/main.lua index 17699f0..a1664c0 100644 --- a/MWSE/mods/celediel/NPCsGoHome/main.lua +++ b/MWSE/mods/celediel/NPCsGoHome/main.lua @@ -2,43 +2,18 @@ -- ? could probably split this file out to others as well local config = require("celediel.NPCsGoHome.config").getConfig() local common = require("celediel.NPCsGoHome.common") -local interop = require("celediel.NPCsGoHome.interop") -local positions = require("celediel.NPCsGoHome.positions") +local checks = require("celediel.NPCsGoHome.functions.checks") +local housing = require("celediel.NPCsGoHome.functions.housing") +local processors = require("celediel.NPCsGoHome.functions.processors") -- }}} -- {{{ variables and such --- Waistworks string match -local waistworks = { - "[Cc]analworks", -- These will match Vivec and Molag Mar - "[Ww]aistworks" -- and Almas Thirr from Tamriel Rebuilt -} --- these are separate because doors to underworks should be ignored --- but NPCs in underworks should not be disabled -local underworks = "[Uu]nderworks" -- timers local updateTimer --- NPC homes -local publicHouses = {} -local homes = { - byName = {}, -- used to ensure duplicate homes are not created - byCell = {} -- used for cellChange events -} - --- city name if cell.name is nil -local wilderness = "Wilderness" --- maybe this shouldn't be hardcoded -local publicHouseTypes = {inns = "Inns", guildhalls = "Guildhalls", temples = "Temples", houses = "Houses"} -local movedNPCs = {} - --- build a list of followers on cellChange -local followers = {} - -local zeroVector = tes3vector3.new(0, 0, 0) - --- animated morrowind NPCs are contextual -local contextualNPCs = {"^AM_"} +-- references to common.runtimeData +local publicHouses, homes, movedNPCs, followers -- }}} @@ -46,63 +21,7 @@ local contextualNPCs = {"^AM_"} local function log(level, ...) if config.logLevel >= level then common.log(...) end end local function message(...) if config.showMessages then tes3.messageBox(...) end end -local function checkModdedCell(cellId) - local id - - if cellId == "Balmora, South Wall Cornerclub" and tes3.isModActive("South Wall.ESP") then - id = "Balmora, South Wall Den Of Iniquity" - elseif cellId == "Balmora, Eight Plates" and tes3.isModActive("Eight Plates.esp") then - id = "Balmora, Seedy Eight Plates" - elseif cellId == "Hla Oad, Fatleg's Drop Off" and tes3.isModActive("Clean DR115_TheDropoff_HlaOadDocks.ESP") then - id = "Hla Oad, The Drop Off" - else - id = cellId - end - - return id -end - -local function isInteriorCell(cell) - if not cell then return end - - log(common.logLevels.large, "Cell %s: interior: %s, behaves as exterior: %s therefore returning %s", - cell.id, cell.isInterior, cell.behavesAsExterior, cell.isInterior and not cell.behavesAsExterior) - - return cell.isInterior and not cell.behavesAsExterior -end - --- patented by Merlord -local yeet = function(reference) - -- tes3.positionCell({reference = reference, position = {0, 0, 10000}}) - reference:disable() - timer.delayOneFrame(function() mwscript.setDelete({reference = reference}) end) -end - --- very todd workaround -local function getFightFromSpawnedReference(id) - -- Spawn a reference of the given id in toddtest - local toddTest = tes3.getCell("toddtest") - - log(common.logLevels.medium, "Spawning %s in %s", id, toddTest.id) - - local ref = tes3.createReference({ - object = id, - -- cell = toddTest, - cell = tes3.getPlayerCell(), - -- position = zeroVector, - position = {0, 0, 10000}, - orientation = zeroVector - }) - - local fight = ref.mobile.fight - - log(common.logLevels.medium, "Got fight of %s, time to yeet %s", fight, id) - - yeet(ref) - - return fight -end - +-- build a list of followers on cellChange local function buildFollowerList() local f = {} -- build our followers list @@ -115,499 +34,6 @@ local function buildFollowerList() return f end --- {{{ npc evaluators - --- NPCs barter gold + value of all inventory items -local function calculateNPCWorth(npc, merchantCell) - local worth = npc.object.barterGold - local obj = npc.baseObject and npc.baseObject or npc.object - - if npc.object.inventory then - for _, item in pairs(npc.object.inventory) do worth = worth + (item.object.value or 0) end - end - - if merchantCell then -- if we pass a cell argument - for box in merchantCell:iterateReferences(tes3.objectType.container) do -- loop over each container - if box.inventory then -- if it's not empty - for item in tes3.iterate(box.inventory) do -- loop over its items - if obj:tradesItemType(item.objectType) then -- if the NPC sells that type - worth = worth + item.object.value -- add its value to the NPCs total value - end - end - end - end - end - - return worth -end - --- }}} - --- {{{ housing - --- ? I honestly don't know if there are any wandering NPCs that "live" in close-by manors, but I wrote this anyway -local function checkManor(cellName, npcName) - if not cellName or (cellName and not string.find(cellName, "Manor")) then return end - - local splitName = common.split(npcName) - local given = splitName[1] - local sur = splitName[2] - - -- surnameless peasants don't live in manors - if not sur then return end - - log(common.logLevels.large, "Checking if %s %s lives in %s", given, sur, cellName) - return string.match(cellName, sur) -end - --- todo: pick this better -local function pickPublicHouseType(cell) - if cell.id:match("Guild") then - return publicHouseTypes.guildhalls - elseif cell.id:match("Temple") then - return publicHouseTypes.temples - -- elseif cell.id:match("House") then - -- return publicHouseTypes.houses - else - return publicHouseTypes.inns - end -end - -local function pickInnForNPC(npc, city) - -- todo: pick in Inn intelligently ? - -- high class inns for nobles and rich merchants and such - -- lower class inns for middle class npcs and merchants - -- temple for commoners and the poorest people - -- ? pick based on barterGold and value of equipment for merchants ? - -- ? for others, pick based on value of equipment - - -- but for now pick one at random - if publicHouses[city] and publicHouses[city][publicHouseTypes.inns] then - local choice = table.choice(publicHouses[city][publicHouseTypes.inns]) - if not choice then return end - log(common.logLevels.medium, "Picking inn %s, %s for %s", choice.city, choice.name, npc.object.name) - return choice.cell - end -end - -local function pickPublicHouseForNPC(npc, city) - -- look for wandering guild members - if publicHouses[city] and publicHouses[city][publicHouseTypes.guildhalls] then - for _, data in pairs(publicHouses[city][publicHouseTypes.guildhalls]) do - -- if npc's faction and proprietor's faction match, pick that one - if npc.object.faction == data.proprietor.object.faction then - log(common.logLevels.medium, "Picking %s for %s based on faction", data.cell.id, npc.object.name) - return data.cell - end - end - end - - -- temple members go to the temple - if publicHouses[city] and publicHouses[city][publicHouseTypes.temples] then - for _, data in pairs(publicHouses[city][publicHouseTypes.temples]) do - if npc.object.faction == data.proprietor.object.faction then - log(common.logLevels.medium, "Picking temple %s for %s based on faction", data.cell.id, npc.object.name) - return data.cell - end - end - end - - -- found nothing so pick an inn - return pickInnForNPC(npc, city) -end - -local function createHomedNPCTableEntry(npc, home, startingPlace, isHome, position, orientation) - if npc.object and (npc.object.name == nil or npc.object.name == "") then return end - - local pickedPosition, pickedOrientation, pos, ori - - -- mod support for different positions in cells - local id = checkModdedCell(home.id) - - log(common.logLevels.medium, "Found %s for %s: %s... adding it to in memory table...", - isHome and "home" or "public house", npc.object.name, id) - - if isHome and positions.npcs[npc.object.name] then - pos = positions.npcs[npc.object.name].position - ori = positions.npcs[npc.object.name].orientation - -- pickedPosition = positions.npcs[npc.object.name] and tes3vector3.new(p[1], p[2], p[3]) or zeroVector:copy() - -- pickedOrientation = positions.npcs[npc.object.name] and tes3vector3.new(o[1], o[2], o[3]) or zeroVector:copy() - elseif positions.cells[id] then - pos = table.choice(positions.cells[id]).position - ori = table.choice(positions.cells[id]).orientation - -- pickedPosition = positions.cells[id] and tes3vector3.new(p[1], p[2], p[3]) or zeroVector:copy() - -- pickedOrientation = positions.cells[id] and tes3vector3.new(o[1], o[2], o[3]) or zeroVector:copy() - -- pickedPosition = tes3vector3.new(p[1], p[2], p[3]) - -- pickedOrientation = tes3vector3.new(o[1], o[2], o[3]) - else - pos = {0,0,0} - ori = {0,0,0} - -- pickedPosition = zeroVector:copy() - -- pickedOrientation = zeroVector:copy() - end - - pickedPosition = tes3vector3.new(pos[1], pos[2], pos[3]) - pickedOrientation = tes3vector3.new(ori[1], ori[2], ori[3]) - - local ogPosition = position and - (tes3vector3.new(position.x, position.y, position.z)) or - (npc.position and npc.position:copy() or zeroVector:copy()) - - local ogOrientation = orientation and - (tes3vector3.new(orientation.x, orientation.y, orientation.z)) or - (npc.orientation and npc.orientation:copy() or zeroVector:copy()) - - local this = { - name = npc.object.name, -- string - npc = npc, -- tes3npc - isHome = isHome, -- bool - home = home, -- tes3cell - homeName = home.id, -- string - ogPlace = startingPlace, -- tes3cell - ogPlaceName = startingPlace.id, - ogPosition = ogPosition, -- tes3vector3 - ogOrientation = ogOrientation, -- tes3vector3 - homePosition = pickedPosition, -- tes3vector3 - homeOrientation = pickedOrientation, -- tes3vector3 - worth = calculateNPCWorth(npc) -- int - } - - homes.byName[npc.object.name] = this - if isHome then homes.byCell[home.id] = this end - - interop.setHomedNPCTable(homes.byName) - - return this -end - -local function createPublicHouseTableEntry(publicCell, proprietor, city, name) - local typeOfPub = pickPublicHouseType(publicCell) - - local worth = 0 - - -- for houses, worth is equal to NPC who lives there - -- if typeOfPub == publicHouseTypes.houses then - -- worth = calculateNPCWorth(proprietor) - -- else - -- for other types, worth is combined worth of all NPCs - for innard in publicCell:iterateReferences(tes3.objectType.npc) do - if innard == proprietor then - worth = worth + calculateNPCWorth(innard, publicCell) - else - worth = worth + calculateNPCWorth(innard) - end - end - -- end - - if not publicHouses[city] then publicHouses[city] = {} end - if not publicHouses[city][typeOfPub] then publicHouses[city][typeOfPub] = {} end - - publicHouses[city][typeOfPub][publicCell.id] = { - name = name, - city = city, - cell = publicCell, - proprietor = proprietor, - proprietorName = proprietor.object.name, - worth = worth - } - - interop.setPublicHouseTable(publicHouses) -end - --- looks through doors to find a cell that matches a wandering NPCs name -local function pickHomeForNPC(cell, npc) - -- wilderness cells don't have name - if not cell.name then return end - - -- don't move contextual, such as Animated Morrowind, NPCs at all - for _, str in pairs(contextualNPCs) do if npc.object.id:match(str) then return end end - - local name = npc.object.name - local city = common.split(cell.name, ",")[1] - for door in cell:iterateReferences(tes3.objectType.door) do - if door.destination then - local dest = door.destination.cell - - -- essentially, if npc full name, or surname matches the cell name - if dest.id:match(name) or checkManor(dest.name, name) then - if homes.byName[name] then -- already have a home, don't create the table entry again - return homes.byName[name] - else - return createHomedNPCTableEntry(npc, dest, cell, true) - end - end - end - end - - -- haven't found a home, so put them in an inn or guildhall - if config.homelessWanderersToPublicHouses then - log(common.logLevels.medium, "Didn't find a home for %s, trying inns", npc.object.name) - local dest = pickPublicHouseForNPC(npc, city) - -- return createHomedNPCTableEntry(npc, dest, door) - if dest then return createHomedNPCTableEntry(npc, dest, cell, false) end - end - - return nil -end - --- }}} - --- {{{ checks - -local function isCityCell(internalCellId, externalCellId) - -- easy mode - if string.match(internalCellId, externalCellId) then - log(common.logLevels.large, "easy mode city: %s in %s", internalCellId, externalCellId) - return true - end - - local cityMatch = "^(%w+), (.*)" - -- check for "advanced" cities - local _, _, internalCity = string.find(internalCellId, cityMatch) - local _, _, externalCity = string.find(externalCellId, cityMatch) - - if externalCity and externalCity == internalCity then - log(common.logLevels.large, "hard mode city: %s in %s, %s == %s", - internalCellId, externalCellId, externalCity, internalCity) - return true - end - - log(common.logLevels.large, "hard mode not city: %s not in %s, %s ~= %s or both are nil", - internalCellId, externalCellId, externalCity, internalCity) - return false -end - -local function isIgnoredCell(cell) - log(common.logLevels.large, "%s is %s, %s is %s", cell.id, config.ignored[cell.id] and "ignored" or "not ignored", - cell.sourceMod, config.ignored[cell.sourceMod] and "ignored" or "not ignored") - - -- don't do things in the wilderness - -- local wilderness = false - -- if not cell.name then wilderness = true end - - return config.ignored[cell.id] or config.ignored[cell.sourceMod] -- or wilderness -end - -local function isCantonCell(cellName) - for _, str in pairs(waistworks) do if cellName:match(str) then return true end end - return false -end - -local function fargothCheck() - local fargothJournal = tes3.getJournalIndex({id = "MS_Lookout"}) - if not fargothJournal then return false end - - -- only disable Fargoth before speaking to Hrisskar, and after observing Fargoth sneak - log(common.logLevels.large, "Fargoth journal check %s: %s", fargothJournal, - fargothJournal > 10 and fargothJournal <= 30) - - return fargothJournal > 10 and fargothJournal <= 30 -end - -local function isIgnoredNPC(npc) - local obj = npc.baseObject and npc.baseObject or npc.object - - -- ignore dead, attack on sight NPCs, and vampires - local isDead = false - local isHostile = false - local isVampire = false - - if npc.mobile then - if npc.mobile.health.current <= 0 or npc.mobile.isDead then isDead = true end - if npc.mobile.fight > 70 then isHostile = true end - isVampire = tes3.isAffectedBy({reference = npc, effect = tes3.effect.vampirism}) - else - -- local fight = getFightFromSpawnedReference(obj.id) -- ! calling this hundreds of times is bad for performance lol - -- if (fight or 0) > 70 then isHostile = true end - isVampire = obj.head and (obj.head.vampiric and true or false) or false - if obj.id:match("[Dd]ead") or obj.name:match("[Dd]ead") then isDead = true end - end - - local isFargothActive = obj.id:match("fargoth") and fargothCheck() or false - - -- todo: non mwscript version of this - local isWerewolf = mwscript.getSpellEffects({reference = npc, spell = "werewolf vision"}) - -- local isVampire = mwscript.getSpellEffects({reference = npc, spell = "vampire sun damage"}) - - -- this just keeps getting uglier but it's debug logging so whatever I don't care - log(common.logLevels.large, ("Checking NPC:%s (%s or %s): id blocked:%s, %s blocked:%s " .. -- - "guard:%s dead:%s vampire:%s werewolf:%s dreamer:%s follower:%s hostile:%s %s%s"), -- - obj.name, npc.object.id, npc.object.baseObject and npc.object.baseObject.id or "nil", -- - config.ignored[obj.id:lower()], obj.sourceMod, config.ignored[obj.sourceMod:lower()], -- - obj.isGuard, isDead, isVampire, isWerewolf, (obj.class and obj.class.id == "Dreamers"), -- - followers[npc.object.id], isHostile, obj.id:match("fargoth") and "fargoth:" or "", -- - obj.id:match("fargoth") and isFargothActive or "") - - return config.ignored[obj.id:lower()] or -- - config.ignored[obj.sourceMod:lower()] or -- - obj.isGuard or -- - isFargothActive or -- - isDead or -- don't move dead NPCS - isHostile or -- - followers[npc.object.id] or -- ignore followers - isVampire or -- - isWerewolf or -- - (obj.class and obj.class.id == "Dreamers") -- -end - -local function isNPCPet(creature) - local obj = creature.baseObject and creature.baseObject or creature.object - - -- todo: more pets? - if obj.id:match("guar") and obj.mesh:match("pack") then - return true - else - return false - end -end - --- checks NPC class and faction in cells for block list and adds to publicHouse list --- todo: rewrite this -local function isPublicHouse(cell) - -- only interior cells are public "houses" - if not isInteriorCell(cell) then return false end - - local typeOfPub = pickPublicHouseType(cell) - local city, publicHouseName - - if cell.name and string.match(cell.name, ",") then - city = common.split(cell.name, ",")[1] - publicHouseName = common.split(cell.name, ",")[2]:gsub("^%s", "") - else - city = wilderness - publicHouseName = cell.id - end - - -- don't iterate NPCs in the cell if we've already marked it public - if publicHouses[city] and (publicHouses[city][typeOfPub] and publicHouses[city][typeOfPub][cell.id]) then return true end - - local npcs = {factions = {}, total = 0} - for npc in cell:iterateReferences(tes3.objectType.npc) do - -- Check for NPCS of ignored classes first - if not isIgnoredNPC(npc) then - if npc.object.class and config.ignored[npc.object.class.id] then - log(common.logLevels.medium, "NPC:\'%s\' of class:\'%s\' made %s public", npc.object.name, - npc.object.class and npc.object.class.id or "none", cell.name) - - createPublicHouseTableEntry(cell, npc, city, publicHouseName) - - return true - end - - local faction = npc.object.faction - - if faction then - if not npcs.factions[faction] then npcs.factions[faction] = {total = 0, percentage = 0} end - - if not npcs.factions[faction].master or npcs.factions[faction].master.object.factionIndex < - npc.object.factionIndex then npcs.factions[faction].master = npc end - - npcs.factions[faction].total = npcs.factions[faction].total + 1 - end - - npcs.total = npcs.total + 1 - end - end - - -- no NPCs of ignored classes, so let's check out factions - for faction, info in pairs(npcs.factions) do - info.percentage = (info.total / npcs.total) * 100 - log(common.logLevels.large, - "No NPCs of ignored class in %s, checking faction %s (ignored: %s, player joined: %s) with %s (%s%%) vs total %s", - cell.name, faction, config.ignored[faction.id], faction.playerJoined, info.total, info.percentage, - npcs.total) - - -- less than 3 NPCs can't possibly be a public house unless it's a Blades house - if (config.ignored[faction.id] or faction.playerJoined) and - (npcs.total >= config.minimumOccupancy or faction == "Blades") and info.percentage >= - config.factionIgnorePercentage then - log(common.logLevels.medium, "%s is %s%% faction %s, marking public.", cell.name, info.percentage, faction) - - createPublicHouseTableEntry(cell, npcs.factions[faction].master, city, publicHouseName) - return true - end - end - - log(common.logLevels.large, "%s isn't public", cell.name) - return false -end - --- doors that lead to ignored, exterior, canton, unoccupied, or public cells, and doors that aren't in cities -local function isIgnoredDoor(door, homeCellId) - -- don't lock non-cell change doors - if not door.destination then - log(common.logLevels.large, "Non-Cell-change door %s, ignoring", door.id) - return true - end - - -- we use this a lot, so set a reference to it - local dest = door.destination.cell - - -- Only doors in cities and towns (interior cells with names that contain the exterior cell) - local inCity = isCityCell(dest.id, homeCellId) - - -- peek inside doors to look for guild halls, inns and clubs - local leadsToPublicCell = isPublicHouse(dest) - - -- don't lock unoccupied cells - local hasOccupants = false - for npc in dest:iterateReferences(tes3.objectType.npc) do - if not isIgnoredNPC(npc) then - hasOccupants = true - break - end - end - - -- don't lock doors to underworks in addition to other canton cells - local isCanton = isCantonCell(dest.id) or dest.id:match(underworks) - - log(common.logLevels.large, "%s is %s, (%sin a city, is %spublic, %soccupied)", -- - dest.id, isIgnoredCell(dest) and "ignored" or "not ignored", -- destination is ignored - inCity and "" or "not ", leadsToPublicCell and "" or "not ", hasOccupants and "" or "un") -- in a city, is public, is ocupado - - return isIgnoredCell(dest) or - not isInteriorCell(dest) or - isCanton or - not inCity or - leadsToPublicCell or - not hasOccupants -end - --- AT NIGHT -local function checkTime() - log(common.logLevels.large, "Current time is %s, things are closed between %s and %s", - tes3.worldController.hour.value, config.closeTime, config.openTime) - return tes3.worldController.hour.value >= config.closeTime or tes3.worldController.hour.value <= config.openTime -end - --- inclement weather -local function checkWeather(cell) - if not cell.region then return end - - log(common.logLevels.large, "Weather: %s >= %s == %s", cell.region.weather.index, config.worstWeather, - cell.region.weather.index >= config.worstWeather) - - return cell.region.weather.index >= config.worstWeather -end - --- travel agents, their steeds, and argonians stick around -local function isBadWeatherNPC(npc) - local obj = npc.baseObject and npc.baseObject or npc.object - if not obj then return end - - log(common.logLevels.large, "NPC Inclement Weather: %s is %s, %s", npc.object.name, npc.object.class.name, - npc.object.race.id) - - -- todo: better detection of NPCs who offer travel services - -- found a rogue "shipmaster" in molag mar - return obj.class.name == "Caravaner" or - obj.class.name == "Gondolier" or - obj.class.name == "Shipmaster" or - obj.race.id == "Argonian" -end - --- }}} - -- {{{ cell change checks local function checkEnteredNPCHome(cell) @@ -620,7 +46,7 @@ local function checkEnteredNPCHome(cell) end local function checkEnteredPublicHouse(cell, city) - local typeOfPub = pickPublicHouseType(cell) + local typeOfPub = housing.pickPublicHouseType(cell) local publicHouse = publicHouses[city] and (publicHouses[city][typeOfPub] and publicHouses[city][typeOfPub][cell.name]) @@ -635,255 +61,24 @@ end -- }}} --- }}} - --- {{{ real meat and potatoes functions -local function moveNPC(homeData) - -- add to in memory table - table.insert(movedNPCs, homeData) - interop.setMovedNPCTable(movedNPCs) - - -- set npc data, so we can move NPCs back after a load - local npc = homeData.npc - npc.data.NPCsGoHome = { - position = { - x = npc.position.x, - y = npc.position.y, - z = npc.position.z, - }, - orientation = { - x = npc.orientation.x, - y = npc.orientation.y, - z = npc.orientation.z, - }, - cell = homeData.ogPlaceName - } - - tes3.positionCell({ - cell = homeData.home, - reference = homeData.npc, - position = homeData.homePosition, - orientation = homeData.homeOrientation - }) - - log(common.logLevels.medium, "Moving %s to home %s (%s, %s, %s)", homeData.npc.object.name, homeData.home.id, - homeData.homePosition.x, homeData.homePosition.y, homeData.homePosition.z) -end - -local function putNPCsBack() - for i = #movedNPCs, 1, -1 do - local data = table.remove(movedNPCs, i) - log(common.logLevels.medium, "Moving %s back outside to %s (%s, %s, %s)", data.npc.object.name, data.ogPlace.id, - data.ogPosition.x, data.ogPosition.y, data.ogPosition.z) - - -- unset NPC data so we don't try to move them on load - data.npc.data.NPCsGoHome = nil - - -- and put them back - tes3.positionCell({ - cell = data.ogPlace, - reference = data.npc, - position = data.ogPosition, - orientation = data.ogPlace - }) - end - interop.setMovedNPCTable(movedNPCs) -end - --- search in a specific cell for moved NPCs -local function checkForMovedNPCs(cell) - -- NPCs don't get moved to exterior cells, so no need to check them for moved NPCs - if not isInteriorCell(cell) then return end - - log(common.logLevels.medium, "Looking for moved NPCs in cell %s", cell.id) - for npc in cell:iterateReferences(tes3.objectType.npc) do - if npc.data and npc.data.NPCsGoHome then - createHomedNPCTableEntry(npc, cell, tes3.getCell(npc.data.NPCsGoHome.cell), true, npc.data.NPCsGoHome.position, npc.data.NPCsGoHome.orientation) - end - end -end - -local function searchCellsForNPCs() - for _, cell in pairs(tes3.getActiveCells()) do - -- check active cells - checkForMovedNPCs(cell) - for door in cell:iterateReferences(tes3.objectType.door) do - if door.destination then - -- then check cells attached to active cells - checkForMovedNPCs(door.destination.cell) - end - end - end -end - -local function processNPCs(cell) - -- todo: move this check somewhere else, so that disabled NPCs will be re-enabled even if the option is off - if not config.disableNPCs then return end - - log(common.logLevels.small, "Looking for NPCs to process in cell:%s", cell.id) - - -- iterate NPCs in the cell, move them to their homes, and keep track of moved NPCs so we can move them back later - for npc in cell:iterateReferences(tes3.objectType.npc) do - -- for npc, _ in pairs(cellsInMemory[cell].npcs) do - if not isIgnoredNPC(npc) then - log(common.logLevels.large, "People change") - -- if not npc.data.NPCsGoHome then npc.data.NPCsGoHome = {} end - - -- find NPC homes - local npcHome = config.moveNPCs and pickHomeForNPC(cell, npc) or nil - - local tmpLogLevelNPCHome = npcHome and common.logLevels.medium or common.logLevels.large - log(tmpLogLevelNPCHome, "%s %s %s%s", npc.object.name, - npcHome and (npcHome.isHome and "lives in" or "goes to") or "lives", - npcHome and npcHome.home or "nowhere", npcHome and (npcHome.isHome and "." or " at night.") or ".") - - -- disable or move NPCs - if (checkTime() or - (checkWeather(cell) and - (not isBadWeatherNPC(npc) or (isBadWeatherNPC(npc) and not config.keepBadWeatherNPCs)))) then - if npcHome then - moveNPC(npcHome) - -- elseif not npc.data.NPCsGoHome.modified then - elseif not npc.disabled then - log(common.logLevels.medium, "Disabling homeless %s", npc.object.name) - -- npc:disable() -- ! this one sometimes causes crashes - mwscript.disable({reference = npc}) -- ! this one is deprecated - -- tes3.setEnabled({reference = npc, enabled = false}) -- ! but this one causes crashes too - -- npc.data.NPCsGoHome.modified = true - else - log(common.logLevels.medium, "Didn't do anything with %s", npc.object.name) - end - else - -- if not npcHome and npc.data.modified then - if not npcHome and npc.disabled then - log(common.logLevels.medium, "Enabling homeless %s", npc.object.name) - -- npc:enable() - mwscript.enable({reference = npc}) - -- tes3.setEnabled({reference = npc, enabled = true}) - -- npc.data.NPCsGoHome.modified = false - end - end - end - end - - -- now put NPCs back - -- if not (checkTime() or checkWeather(cell)) and #movedNPCs > 0 then putNPCsBack() end - if not (checkTime() or checkWeather(cell)) then putNPCsBack() end -end - -local function processSiltStriders(cell) - if not config.disableNPCs then return end - - log(common.logLevels.small, "Looking for silt striders to process in cell:%s", cell.name) - for activator in cell:iterateReferences(tes3.objectType.activator) do - log(common.logLevels.large, "Is %s a silt strider??", activator.object.id) - if activator.object.id:match("siltstrider") then - if checkTime() or (checkWeather(cell) and not config.keepBadWeatherNPCs) then - if not activator.disabled then - log(common.logLevels.medium, "Disabling silt strider %s!", activator.object.name) - mwscript.disable({reference = activator}) - -- activator:disable() - -- tes3.setEnabled({reference = activator, enabled = false}) - end - else - if activator.disabled then - log(common.logLevels.medium, "Enabling silt strider %s!", activator.object.name) - mwscript.enable({reference = activator}) - -- activator:enable() - -- tes3.setEnabled({reference = activator, enabled = true}) - end - end - end - end - log(common.logLevels.large, "Done with silt striders") -end - --- deal with trader's guars, and other npc linked creatures/whatever -local function processPets(cell) - if not config.disableNPCs then return end - - log(common.logLevels.small, "Looking for NPC pets to process in cell:%s", cell.name) - - for creature in cell:iterateReferences(tes3.objectType.creature) do - if isNPCPet(creature) then - if checkTime() then - -- disable - if not creature.disabled then - log(common.logLevels.medium, "Disabling NPC Pet %s!", creature.object.id) - mwscript.disable({reference = creature }) - end - else - -- enable - if creature.disabled then - log(common.logLevels.medium, "Enabling NPC Pet %s!", creature.object.id) - mwscript.enable({reference = creature }) - end - end - end - end -end - -local function processDoors(cell) - if not config.lockDoors then return end - - log(common.logLevels.small, "Looking for doors to process in cell:%s", cell.id) - - for door in cell:iterateReferences(tes3.objectType.door) do - if not door.data.NPCsGoHome then door.data.NPCsGoHome = {} end - - if not isIgnoredDoor(door, cell.id) then - -- don't mess around with doors that are already locked - if door.data.NPCsGoHome.alreadyLocked == nil then - door.data.NPCsGoHome.alreadyLocked = tes3.getLocked({reference = door}) - end - - log(common.logLevels.large, "Found %slocked %s with destination %s", - door.data.NPCsGoHome.alreadyLocked and "" or "un", door.id, door.destination.cell.id) - - if checkTime() then - if not door.data.NPCsGoHome.alreadyLocked then - log(common.logLevels.medium, "locking: %s to %s", door.object.name, door.destination.cell.id) - - local lockLevel = math.random(25, 100) - tes3.lock({reference = door, level = lockLevel}) - door.data.NPCsGoHome.modified = true - end - else - -- only unlock doors that we locked before - if door.data.NPCsGoHome.modified then - door.data.NPCsGoHome.modified = false - - tes3.setLockLevel({reference = door, level = 0}) - tes3.unlock({reference = door}) - - log(common.logLevels.medium, "unlocking: %s to %s", door.object.name, door.destination.cell.id) - end - end - - log(common.logLevels.large, "Now locked Status: %s", tes3.getLocked({reference = door})) - end - end - log(common.logLevels.large, "Done with doors") -end - local function applyChanges(cell) if not cell then cell = tes3.getPlayerCell() end - if isIgnoredCell(cell) then return end + if checks.isIgnoredCell(cell) then return end -- Interior cell, except Canton cells, don't do anything - if isInteriorCell(cell) and not (config.waistWorks and isCantonCell(cell.id)) then return end + if checks.isInteriorCell(cell) and not (config.waistWorks and checks.isCantonCell(cell.id)) then return end -- don't do anything to public houses - if isPublicHouse(cell) then return end + if checks.isPublicHouse(cell) then return end -- Deal with NPCs and mounts in cell - processNPCs(cell) - processPets(cell) - processSiltStriders(cell) + processors.processNPCs(cell) + processors.processPets(cell) + processors.processSiltStriders(cell) -- check doors in cell, locking those that aren't inns/clubs - processDoors(cell) + processors.processDoors(cell) end local function updateCells() @@ -902,8 +97,8 @@ local function updatePlayerTrespass(cell, previousCell) local inCity = previousCell and (previousCell.id:match(cell.id) or cell.id:match(previousCell.id)) - if isInteriorCell(cell) and not isIgnoredCell(cell) and not isPublicHouse(cell) and inCity then - if checkTime() then + if checks.isInteriorCell(cell) and not checks.isIgnoredCell(cell) and not checks.isPublicHouse(cell) and inCity then + if checks.checkTime() then tes3.player.data.NPCsGoHome.intruding = true else tes3.player.data.NPCsGoHome.intruding = false @@ -922,7 +117,7 @@ local function onActivated(e) return end - if tes3.player.data.NPCsGoHome.intruding and not isIgnoredNPC(e.target) then + if tes3.player.data.NPCsGoHome.intruding and not checks.isIgnoredNPC(e.target) then tes3.messageBox(string.format("%s: Get out before I call the guards!", e.target.object.name)) return false end @@ -932,7 +127,7 @@ local function onLoaded() tes3.player.data.NPCsGoHome = tes3.player.data.NPCsGoHome or {} -- tes3.player.data.NPCsGoHome.movedNPCs = tes3.player.data.NPCsGoHome.movedNPCs or {} -- movedNPCs = tes3.player.data.NPCsGoHome.movedNPCs or {} - if tes3.player.cell then searchCellsForNPCs() end + if tes3.player.cell then processors.searchCellsForNPCs() end followers = buildFollowerList() @@ -957,13 +152,24 @@ local function onCellChanged(e) end -- }}} --- {{{ event registering -event.register("loaded", onLoaded) -event.register("cellChanged", onCellChanged) +-- {{{ init +local function onInitialized() + -- set up runtime data references + publicHouses = common.runtimeData.publicHouses + homes = common.runtimeData.homes + movedNPCs = common.runtimeData.movedNPCs + followers = common.runtimeData.followers -event.register("activate", onActivated) + -- Register events + event.register("loaded", onLoaded) + event.register("cellChanged", onCellChanged) + event.register("activate", onActivated) -event.register("modConfigReady", function() mwse.mcm.register(require("celediel.NPCsGoHome.mcm")) end) + -- MCM + event.register("modConfigReady", function() mwse.mcm.register(require("celediel.NPCsGoHome.mcm")) end) +end + +event.register("initialized", onInitialized) -- }}} -- vim:fdm=marker