diff --git a/MWSE/mods/celediel/NPCsGoHome/common.lua b/MWSE/mods/celediel/NPCsGoHome/common.lua new file mode 100644 index 0000000..3956033 --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/common.lua @@ -0,0 +1,29 @@ +local inspect = require("inspect") + +local this = {} + +this.modName = "NPCs Go Home (At Night)" +this.author = "OEA/Celediel" +this.version = "0.0.1" +this.modInfo = "Move NPCs to their homes, or public houses (or just disable them), lock doors, " .. + "and prevent interaction after hours, selectively disable NPCs in inclement weather" +this.configPath = "NPCSGOHOME" + +this.logLevels = {none = 0, small = 1, medium = 2, large = 3} + +this.split = function(input, sep) + if not input then return end + if not sep then sep = "%s" end + local output = {} + for str in string.gmatch(input, "([^" .. sep .. "]+)") do table.insert(output, str) end + return output +end + +this.log = function(...) mwse.log("[%s] %s", this.modName, string.format(...)) end + +this.inspect = function(thing) + this.log("Inspecting a %s", thing) + this.log(inspect(thing)) +end + +return this diff --git a/MWSE/mods/celediel/NPCsGoHome/config.lua b/MWSE/mods/celediel/NPCsGoHome/config.lua new file mode 100644 index 0000000..b8723eb --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/config.lua @@ -0,0 +1,36 @@ +local common = require("celediel.NPCsGoHome.common") + +-- todo: clean this up +local defaultConfig = { + disableNPCs = true, + lockDoors = true, + disableInteraction = true, + timerInterval = 7, + ignored = { + ["fargoth"] = true, + ["Balmora, Caius Cosades' House"] = true, + ["Publican"] = true, + ["Healer Service"] = true, + }, + worstWeather = tes3.weather.thunder, + keepBadWeatherNPCs = true, + closeTime = 21, + openTime = 7, + minimumOccupancy = 3, + waistWorks = true, + moveNPCs = false, + homelessWanderersToPublicHouses = false, + logLevel = common.logLevels.none, + factionIgnorePercentage = 0.67 +} + +local currentConfig + +local this = {} + +function this.getConfig() + currentConfig = currentConfig or mwse.loadConfig(common.configPath, defaultConfig) + return currentConfig +end + +return this diff --git a/MWSE/mods/celediel/NPCsGoHome/interop.lua b/MWSE/mods/celediel/NPCsGoHome/interop.lua new file mode 100644 index 0000000..b688f07 --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/interop.lua @@ -0,0 +1,21 @@ +local this = {} + +local homedNPCs = {} + +this.setHomedNPCTable = function(t) homedNPCs = t end + +this.getHomedNPCTable = function() return homedNPCs end + +local inns = {} + +this.setInnTable = function(t) inns = t end + +this.getInnTable = function() return inns end + +local movedNPCs = {} + +this.setMovedNPCsTable = function(t) movedNPCs = t end + +this.getMovedNPCsTable = function() return movedNPCs end + +return this diff --git a/MWSE/mods/celediel/NPCsGoHome/main.lua b/MWSE/mods/celediel/NPCsGoHome/main.lua new file mode 100644 index 0000000..6fbbdb8 --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/main.lua @@ -0,0 +1,669 @@ +-- {{{ other files +-- ? 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") +-- }}} + +-- {{{ variables and such +-- Waistworks string match +-- I'm probably trying too hard to avoid false positives +local waistworks = {"^[Vv]ivec,?.*[Ww]aist", "[Cc]analworks", "[Ww]aistworks"} + +-- timers +local updateTimer + +-- NPC homes +local homedNPCS = {} +local publicHouses = {} +-- 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_"} + +-- }}} + +-- {{{ helper functions +local function log(level, ...) if config.logLevel >= level then common.log(...) end end + +-- {{{ npc evaluators + +-- NPCs barter gold + value of all inventory items +local function calculateNPCWorth(npc) + local worth = npc.object.barterGold + + if npc.object.inventory then + for _, item in pairs(npc.object.inventory) do worth = worth + (item.object.value or 0) 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 checkIfManor(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 + +local function pickPublicHouseType(cellName) + if cellName:match("Guild") then + return publicHouseTypes.guildhalls + elseif cellName:match("Temple") then + return publicHouseTypes.temples + elseif cellName: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 commoners and poor merchants + -- ? 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 + + 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) + if npc.object and (npc.object.name == nil or npc.object.name == "") then return end + log(common.logLevels.medium, "Found home for %s: %s... adding it to in memory table...", npc.object.name, home.id) + + local pickedPosition, pickedOrientation, p, o + + if isHome and positions.npcs[npc.object.name] then + p = positions.npcs[npc.object.name].position + o = 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[home.id] then + p = table.choice(positions.cells[home.id]).position + o = table.choice(positions.cells[home.id]).orientation + pickedPosition = positions.cells[home.id] and tes3vector3.new(p[1], p[2], p[3]) or zeroVector:copy() + pickedOrientation = positions.cells[home.id] and tes3vector3.new(o[1], o[2], o[3]) or zeroVector:copy() + else + pickedPosition = zeroVector:copy() + pickedOrientation = zeroVector:copy() + end + + local this = { + name = npc.object.name, + npc = npc, + isHome = isHome, + home = home, + homeName = home.id, + ogPlace = startingPlace, + ogPlaceName = startingPlace.id, + ogPosition = npc.position and npc.position:copy() or zeroVector:copy(), + ogOrientation = npc.orientation and npc.orientation:copy() or zeroVector:copy(), + homePosition = pickedPosition, + homeOrientation = pickedOrientation, + worth = calculateNPCWorth(npc) + } + + homedNPCS[home.id] = this + + interop.setHomedNPCTable(homedNPCS) + + return this +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 + if dest.id:match(name) or checkIfManor(dest.name, name) then + return createHomedNPCTableEntry(npc, dest, cell, true) + 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 isIgnoredNPC(npc) + local obj + if npc.object.baseObject then + obj = npc.object.baseObject + else + obj = npc.object + end + + -- ignore dead or attack on sight NPCs + local isDead = false + local isHostile = false + if npc.mobile then + if npc.mobile.health.current <= 0 then isDead = true end + if npc.mobile.fight > 70 then isHostile = true end + end + + -- todo: non mwscript version of these + local isVampire = mwscript.getSpellEffects({reference = npc, spell = "vampire sun damage"}) + local isWerewolf = mwscript.getSpellEffects({reference = npc, spell = "werewolf vision"}) + + log(common.logLevels.large, ("Checking NPC:%s (%s or %s): id blocked:%s, mod blocked:%s " .. + "guard:%s dead:%s vampire:%s werewolf:%s dreamer:%s follower:%s hostile:%s"), obj.name, npc.object.id, + npc.object.baseObject and npc.object.baseObject.id or "nil", config.ignored[obj.id], + config.ignored[obj.sourceMod], npc.object.isGuard, isDead, isVampire, isWerewolf, + obj.class.id == "Dreamers", followers[obj.id], isHostile) + + return config.ignored[obj.id] or -- + config.ignored[obj.sourceMod] or -- + npc.object.isGuard or -- + isDead or -- don't move dead NPCS + isHostile or -- + followers[obj.id] or -- ignore followers + isVampire or -- + isWerewolf or -- + obj.class.id == "Dreamers" or obj.class.id == "Witchhunter" -- +end + +-- checks NPC class and faction in cells for block list and adds to publicHouse list +local function isPublicHouse(cell) + 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) and (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) + + local city, publicHouseName + + if cell.name then + city = common.split(cell.name, ",")[1] + publicHouseName = common.split(cell.name, ",")[2]:gsub("^%s", "") + else + city = wilderness + publicHouseName = cell.id + end + local type = pickPublicHouseType(cell.name) + + if not publicHouses[city] then publicHouses[city] = {} end + if not publicHouses[city][type] then publicHouses[city][type] = {} end + + publicHouses[city][type][cell.name] = { + name = publicHouseName, + city = city, + cell = cell, + proprietor = npc, + worth = calculateNPCWorth(npc) + -- positions = positions.cells[cell.id] or {position = zeroVector, orientation = zeroVector} + } + + interop.setInnTable(publicHouses) + + return true + end + + local faction = npc.object.faction and npc.object.faction.id + if faction then + if not npcs.factions[faction] then npcs.factions[faction] = {total = 0, percentage = 0} end + npcs.factions[faction].total = npcs.factions[faction].total + 1 + end + + npcs.total = npcs.total + 1 + + 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 + log(common.logLevels.large, + "No NPCs of ignored class in %s, checking faction %s (ignored: %s) with %s (%s%%) vs total %s", cell.name, + faction, config.ignored[faction], info.total, info.percentage * 100, npcs.total) + + -- less than 3 NPCs can't possibly be a public house unless it's a Blades house + if config.ignored[faction] 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 * 100, + faction) + return true + end + end + + log(common.logLevels.large, "%s isn't public", cell.name) + return false +end + +-- todo: check cell contents to decide if it should be locked +local function isIgnoredDoor(door, homeCellId) + -- don't lock non-cell change doors, and don't lock doors to outside + if not door.destination then + log(common.logLevels.large, "Non-Cell-change door %s, ignoring", door.id) + return true + end + + -- Only doors in cities and towns (cells that share the same first characters) return true + local inCity = string.sub(homeCellId, 1, 4) == string.sub(door.destination.cell.id, 1, 4) + + -- peek inside doors to look for guild halls, inns and clubs + local isPublic = isPublicHouse(door.destination.cell) + + local hasOccupants = false + for npc in door.destination.cell:iterateReferences(tes3.objectType.npc) do + if not isIgnoredNPC(npc) then + hasOccupants = true + -- break + end + end + + log(common.logLevels.large, "%s is %s, %s is %s (%sin a city, is %spublic)", door.destination.cell.id, + config.ignored[door.destination.cell.id] and "ignored" or "not ignored", door.destination.cell.sourceMod, + config.ignored[door.destination.cell.sourceMod] and "ignored" or "not ignored", inCity and "" or "not ", + isPublic and "" or "not ") + + return config.ignored[door.destination.cell.id] or config.ignored[door.destination.cell.sourceMod] or not inCity or + isPublicHouse or not hasOccupants +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 checkInteriorCell(cell) + if not cell then return end + + log(common.logLevels.large, "Cell: interior: %s, behaves as exterior: %s therefore returning %s", cell.isInterior, + cell.behavesAsExterior, cell.isInterior and not cell.behavesAsExterior) + + return cell.isInterior and not cell.behavesAsExterior +end + +local function checkCantonCell(cellName) + for _, str in pairs(waistworks) do if cellName:match(str) then return true end end + return false +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 badWeatherNPC(npc) + if not npc.object 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) + + return npc.object.class.name == "Caravaner" or npc.object.class.name == "Gondolier" or npc.object.race.id == + "Argonian" +end + +-- }}} + +-- {{{ cell change checks + +local function checkEnteredSpawnedNPCHome(cell) + local home = homedNPCS[cell.id] + if home then log(common.logLevels.medium, "Entering home of %s, %s", home.name, home.homeName) end +end + +local function checkEnteredPublicHouse(cell, city) + local type = pickPublicHouseType(cell.name) + + local publicHouse = publicHouses[city] and (publicHouses[city][type] and publicHouses[city][type][cell.name]) + if publicHouse then + log(common.logLevels.medium, "Entering public space %s, %s, in the city of %s. Talk to %s, %s for services.", + publicHouse.name, publicHouse.type, publicHouse.city, publicHouse.proprietor.object.name, + publicHouse.proprietor.object.class) + end +end + +-- }}} + +-- }}} + +-- {{{ real meat and potatoes functions +local function moveNPC(data) + -- movedNPCs[#movedNPCs + 1] = data + -- table.insert(movedNPCs, data) + -- interop.setMovedNPCsTable(movedNPCs) + table.insert(tes3.player.data.NPCsGoHome.movedNPCs, data) + interop.setMovedNPCsTable(tes3.player.data.NPCsGoHome.movedNPCs) + + tes3.positionCell({ + cell = data.home, + reference = data.npc, + position = data.homePosition, + orientation = data.homeOrientation + }) + + log(common.logLevels.medium, "Moving %s to home %s (%s, %s, %s)", data.npc.object.name, data.home.id, + data.homePosition.x, data.homePosition.y, data.homePosition.z) +end + +local function putNPCsBack() + -- for i = #movedNPCs, 1, -1 do + for i = #tes3.player.data.NPCsGoHome.movedNPCs, 1, -1 do + -- local data = table.remove(movedNPCs, i) + local data = table.remove(tes3.player.data.NPCsGoHome.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) + tes3.positionCell({ + cell = data.ogPlace, + reference = data.npc, + position = data.ogPosition, + orientation = data.ogPlace + }) + -- interop.setMovedNPCsTable(movedNPCs) + interop.setMovedNPCsTable(tes3.player.data.NPCsGoHome.movedNPCs) + end +end + +-- todo: rename to toggleNPCs(cell, state = true|false) +-- todo: using tes3.setEnabled({ enabled = state }) +local function disableNPCs(cell) + -- 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 config.disableNPCs and not isIgnoredNPC(npc) then + log(common.logLevels.large, "People change") + + -- find NPC homes + local npcHome = config.moveNPCs and pickHomeForNPC(cell, npc) or nil + + local tmpLogLevelNPCHome = npcHome and common.logLevels.small or common.logLevels.medium + 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.")) + + -- disable or move NPCs + if (checkTime() or + (checkWeather(cell) and + (not badWeatherNPC(npc) or (badWeatherNPC(npc) and not config.keepBadWeatherNPCs)))) then + if npcHome then + moveNPC(npcHome) + else + 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}) + end + else + if not npcHome then + log(common.logLevels.medium, "Enabling homeless %s", npc.object.name) + -- npc:enable() + mwscript.enable({reference = npc}) + -- tes3.setEnabled({reference = npc, enabled = true}) + 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)) and #tes3.player.data.NPCsGoHome.movedNPCs > 0 then putNPCsBack() end +end + +local function disableSiltStriders(cell) + log(common.logLevels.large, "Looking for silt striders") + 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 + log(common.logLevels.medium, "Disabling silt strider %s!", activator.object.name) + mwscript.disable({reference = activator}) + -- activator:disable() + -- tes3.setEnabled({reference = activator, enabled = false}) + else + 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 + log(common.logLevels.large, "Done with silt striders") +end + +local function processDoors(cell) + log(common.logLevels.large, "Checking out doors") + + for door in cell:iterateReferences(tes3.objectType.door) do + if not door.data.NPCsGoHome then door.data.NPCsGoHome = {} end + log(common.logLevels.large, "Door has destination? %s", door.destination ~= nil) + + if config.lockDoors and not isIgnoredDoor(door, cell.id) then + log(common.logLevels.large, "It knows there's a door") + + local alreadyLocked = tes3.getLocked({reference = door}) + door.data.NPCsGoHome.alreadyLocked = alreadyLocked + log(common.logLevels.large, "Locked Status: %s", alreadyLocked) + + if checkTime() then + if not door.data.NPCsGoHome.alreadyLocked then + log(common.logLevels.large, "It should lock now") + log(common.logLevels.large, "What door is this anyway: %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 + if door.data.NPCsGoHome and door.data.NPCsGoHome.modified then + door.data.NPCsGoHome.modified = false + tes3.setLockLevel({reference = door, level = 0}) + tes3.unlock({reference = door}) + + log(common.logLevels.large, "It should unlock now") + log(common.logLevels.large, "What unlocked door is this anyway: %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 + + -- build our followers list + for friend in tes3.iterate(tes3.mobilePlayer.friendlyActors) do + local obj + if friend.object.baseObject then + obj = friend.object.baseObject + else + obj = friend.object + end + followers[obj.id] = true + log(common.logLevels.large, "%s is follower", obj.id) + end + + if isIgnoredCell(cell) then return end + + -- Interior cell, except Waistworks, don't do anything + if checkInteriorCell(cell) and not (config.waistWorks and checkCantonCell(cell.name)) then return end + + log(common.logLevels.medium, "The hour is: %s", tes3.worldController.hour.value) + + -- Disable NPCs in cell + disableNPCs(cell) + disableSiltStriders(cell) + + -- check doors in cell, locking those that aren't inns/clubs + processDoors(cell) +end + +local function updateCells() + log(common.logLevels.medium, "Updating active cells!") + + for _, cell in pairs(tes3.getActiveCells()) do + log(common.logLevels.large, "Applying changes to cell %s", cell.id) + applyChanges(cell) + end +end + +local function updatePlayerTrespass(cell) + cell = cell or tes3.getPlayerCell() + + if checkInteriorCell(cell) and not isIgnoredCell(cell) and not isPublicHouse(cell) then + if config.disableInteraction and checkTime() then + tes3.player.data.NPCsGoHome.intruding = true + else + tes3.player.data.NPCsGoHome.intruding = false + end + else + tes3.player.data.NPCsGoHome.intruding = false + end + log(common.logLevels.small, "Updating player trespass status to %s", tes3.player.data.NPCsGoHome.intruding) +end + +-- }}} + +-- {{{ event functions +local function onActivated(e) + if e.activator ~= tes3.player and e.target.object.objectType ~= tes3.objectType.npc then return end + + if tes3.player.data.NPCsGoHome.intruding and not isIgnoredNPC(e.target) then + tes3.messageBox(string.format("%s: Get out before I call the guards!", e.target.object.name)) + return false + end +end + +local function onLoaded() + if not tes3.player.data.NPCsGoHome then tes3.player.data.NPCsGoHome = {} end + if not tes3.player.data.NPCsGoHome.movedNPCs then tes3.player.data.NPCsGoHome.movedNPCs = {} end + -- movedNPCs = {} + + if not updateTimer or (updateTimer and updateTimer.state ~= timer.active) then + updateTimer = timer.start({ + type = timer.simulate, + duration = config.timerInterval, + iterations = -1, + callback = updateCells + }) + end +end + +local function onCellChanged(e) + updateCells() + updatePlayerTrespass(e.cell) + checkEnteredSpawnedNPCHome(e.cell) + if e.cell.name then -- exterior wilderness cells don't have name + checkEnteredPublicHouse(e.cell, common.split(e.cell.name, ",")[1]) + end + + --[[ + -- ! delete this + if config.logLevel == common.logLevels.none then + if (e.previousCell and e.previousCell.name and e.previousCell.name ~= e.cell.name) then + mwse.log("}\n[\"%s\"] = {", e.cell.id) + elseif not e.previousCell then + mwse.log("[\"%s\"] = {", e.cell.id) + end + end + -- ! ]] +end +-- }}} + +-- {{{ event registering +event.register("loaded", onLoaded) +event.register("cellChanged", onCellChanged) + +event.register("activate", onActivated) + +event.register("modConfigReady", function() mwse.mcm.register(require("celediel.NPCsGoHome.mcm")) end) +-- }}} + +-- vim:fdm=marker diff --git a/MWSE/mods/celediel/NPCsGoHome/mcm.lua b/MWSE/mods/celediel/NPCsGoHome/mcm.lua new file mode 100644 index 0000000..826994d --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/mcm.lua @@ -0,0 +1,188 @@ +local config = require("celediel.NPCsGoHome.config").getConfig() +local common = require("celediel.NPCsGoHome.common") + +local function createTableVar(id) + return mwse.mcm.createTableVariable({id = id, table = config}) +end + +local template = mwse.mcm.createTemplate({name = common.modName}) +template:saveOnClose(common.configPath, config) + +local page = template:createSideBarPage({ + label = "Main Options", + description = string.format("%s v%s by %s\n\n%s\n\n", common.modName, common.version, common.author, common.modInfo) +}) + +-- todo: categorize the options +local category = page:createCategory(common.modName) + +category:createYesNoButton({ + label = "Lock doors and containers at night?", + variable = createTableVar("lockDoors") +}) + +category:createYesNoButton({ + label = "Disable non-Guard NPCs at night?", + variable = createTableVar("disableNPCs") +}) + +category:createYesNoButton({ + label = "Move NPCs with homes instead of disabling them?", + variable = createTableVar("moveNPCs") +}) + +category:createYesNoButton({ + label = "Move \"homeless\" NPCs to Inns at night and in bad weather instead of disabling them?", + variable = createTableVar("homelessWanderersToPublicHouses") +}) + +category:createYesNoButton({ + label = "Prevent dialogue in interiors at night?", + variable = createTableVar("disableInteraction") +}) + +category:createYesNoButton({ + label = "Treat Canton waistworks and canalworks as exteriors (lock doors and disable NPCs)", + variable = createTableVar("waistWorks") +}) + +category:createYesNoButton({ + label = "Keep Caravaners, their Silt Striders, and Argonians enabled in inclement weather?", + variable = createTableVar("keepBadWeatherNPCs") +}) + +category:createDropdown({ + label = "NPC Inclement Weather Cutoff Point", + description = "NPCs \"go home\" in this weather or worse", + options = { + {label = "None", value = 10}, + {label = "Clear", value = tes3.weather.clear}, + {label = "Cloudy", value = tes3.weather.cloudy}, + {label = "Foggy", value = tes3.weather.foggy}, + {label = "Overcast", value = tes3.weather.overcast}, + {label = "Rain", value = tes3.weather.rain}, + {label = "Thunderstorm", value = tes3.weather.thunder}, + {label = "Ashstorm", value = tes3.weather.ash}, + {label = "Blight", value = tes3.weather.blight}, + {label = "Snow", value = tes3.weather.snow}, + {label = "Blizzard", value = tes3.weather.blizzard} + }, + defaultSetting = tes3.weather.thunder, + variable = createTableVar("worstWeather") +}) + +category:createSlider({ + label = "Close Time", + description = "Time when people \"go home\" and doors lock", + min = 0, + max = 24, + step = 1, + jump = 2, + variable = createTableVar("closeTime") +}) + +category:createSlider({ + label = "Open Time", + description = "Time when people \"wake up\" and doors unlock", + min = 0, + max = 24, + step = 1, + jump = 2, + variable = createTableVar("openTime") +}) + +category:createSlider({ + label = "Minimum number of occupants for public house", + description = "Cells with less than this number of occupants won't even be considered for \"public house\" status.\n\n" .. + "Blades (if on the ignore list) are an exception to this rule, because Blades trainers don't mind if you come in.", + min = 1, + max = 20, + step = 1, + jump = 4, + variable = createTableVar("minimumOccupancy") +}) + +category:createSlider({ + label = "Faction Ignore Percentage", + description = "Cells whose occupants are this % or more of one faction will be marked public if that faction is on the ignored list.", + min = 0, + max = 100, + step = 5, + jump = 10, + variable = createTableVar("factionIgnorePercentage") +}) + +category:createSlider({ + label = "Update Timer", + description = [[How often the update timer fires, in seconds. Updates are also triggered on cell change.]], + min = 1, + max = 60, + step = 1, + jump = 2, + -- todo: button or something to reset the timer to the new interval + restartRequired = true, + variable = createTableVar("timerInterval") +}) + +category:createDropdown({ + label = "Debug log level", + description = [[Enable this if you want to flood mwse.log with nonsense. Even small is huge.]], + options = { + {label = "None", value = common.logLevels.none}, + {label = "Small", value = common.logLevels.small}, + {label = "Medium", value = common.logLevels.medium}, + {label = "Large", value = common.logLevels.large} + }, + variable = createTableVar("logLevel") +}) + +template:createExclusionsPage({ + label = "Ignored things", + description = ("NPCs on the Ignored list will not disappear at night, and will be available to talk to if indoors. " .. + "Interior Cells on the Ignored list will not have the doors to them locked. Exterior cells will have neither doors nor NPCs in them affected. " .. + "Many exterior cells have the same name, and so you will need to use trial and error to disable the correct ones. " .. + "For Plugins, all the above applies to all applicable data from the mod. " .. + "For classes any cell that contains at least one NPC of said class will be considered \"public\", " .. + "and will not be locked or have its NPCS disabled at night. Exterior NPCs of this class or faction will not be disabled. " .. + "For factions, at least 75% of the cell's occupants need to be a part of said faction." .. + "Best used with Guilds and Publican classes."), + showAllBlocked = false, + variable = createTableVar("ignored"), + + filters = { + {label = "Plugins", type = "Plugin"}, + {label = "NPCs", type = "Object", objectType = tes3.objectType.npc}, + { + label = "Cells", + callback = (function() + local CellNames = {} + for cell, _ in pairs(tes3.dataHandler.nonDynamicData.cells) do + table.insert(CellNames, cell) + end + return CellNames + end) + }, + { + label = "Factions", + callback = function() + local factions = {} + for _, faction in pairs(tes3.dataHandler.nonDynamicData.factions) do + table.insert(factions, faction.id) + end + return factions + end + }, + { + label = "Classes", + callback = function() + local classes = {} + for _, class in pairs(tes3.dataHandler.nonDynamicData.classes) do + table.insert(classes, class.id) + end + return classes + end + } + } +}) + +return template diff --git a/MWSE/mods/celediel/NPCsGoHome/positions.lua b/MWSE/mods/celediel/NPCsGoHome/positions.lua new file mode 100644 index 0000000..931f3fb --- /dev/null +++ b/MWSE/mods/celediel/NPCsGoHome/positions.lua @@ -0,0 +1,125 @@ +-- for spawning NPCs into cells or their homes +return { + -- home positions for NPCS + npcs = { + -- Balmora, vanilla + ["Rarayn Radarys"] = {position = {136.23, 132.69, 7.00}, orientation = {0.00, 0.00, -3.14}}, + ["Dralosa Athren"] = {position = {190.74, 91.01, 7.00}, orientation = {0.00, 0.00, -1.55}}, + ["Balyn Omarel"] = {position = {0, 0, 0}, orientation = {0, 0, 0}}, + ["Dralcea Arethi"] = {position = {0, 0, 0}, orientation = {0, 0, 0}} + }, + -- positions picked from a list for public houses + cells = { + ["Balmora, Lucky Lockup"] = { + {position = {222.87, 1290.63, -505.00}, orientation = {0.00, 0.00, -2.54}}, + {position = {-10.30, 757.07, -505.00}, orientation = {0.00, 0.00, -0.04}}, + {position = {5.55, 996.71, -504.11}, orientation = {0.00, 0.00, -0.01}}, + {position = {221.29, 1358.15, -505.00}, orientation = {0.00, 0.00, -3.10}}, + {position = {334.71, 913.76, -505.00}, orientation = {0.00, 0.00, -3.10}} + }, + ["Balmora, Council Club"] = { + {position = {-62.33, -31.99, 7.00}, orientation = {0.00, 0.00, 0.82}}, + {position = {579.51, -1.70, -249.00}, orientation = {0.00, 0.00, -0.64}}, + {position = {406.88, 590.81, -249.00}, orientation = {0.00, 0.00, 3.09}}, + {position = {-36.75, 269.06, -249.00}, orientation = {0.00, 0.00, 1.05}}, + {position = {-716.66, 719.97, -505.00}, orientation = {0.00, 0.00, 1.42}}, + {position = {-1.61, -200.41, -249.00}, orientation = {0.00, 0.00, -0.02}}, + {position = {-272.50, 71.34, -249.00}, orientation = {0.00, 0.00, 1.28}} + }, + ["Balmora, Eight Plates"] = { + {position = {463.41, -333.82, -249.00}, orientation = {0.00, 0.00, -0.50}}, + {position = {426.63, -320.01, -249.00}, orientation = {0.00, 0.00, -0.92}}, + {position = {-215.54, 486.13, -249.00}, orientation = {0.00, 0.00, 0.96}}, + {position = {-236.28, 820.92, -249.00}, orientation = {0.00, 0.00, -0.03}}, + {position = {-109.68, 823.78, -249.00}, orientation = {0.00, 0.00, -0.07}}, + {position = {-28.39, 822.93, -249.00}, orientation = {0.00, 0.00, 0.02}}, + {position = {59.99, 823.30, -249.00}, orientation = {0.00, 0.00, -0.02}}, + {position = {187.22, 919.89, -249.00}, orientation = {0.00, 0.00, -1.50}}, + {position = {185.86, 984.72, -249.00}, orientation = {0.00, 0.00, -1.55}}, + {position = {187.40, 1075.68, -249.00}, orientation = {0.00, 0.00, -1.65}}, + {position = {190.35, 1164.61, -249.00}, orientation = {0.00, 0.00, -1.53}}, + {position = {404.83, 1090.28, -249.00}, orientation = {0.00, 0.00, -0.61}}, + {position = {587.57, 1348.27, -249.00}, orientation = {0.00, 0.00, -2.48}}, + {position = {118.16, -274.01, 7.00}, orientation = {0.00, 0.00, -1.56}}, + {position = {61.89, -207.22, 7.00}, orientation = {0.00, 0.00, 1.49}} + }, + ["Balmora, South Wall Cornerclub"] = { + {position = {32.41, 586.87, 7.00}, orientation = {0.00, 0.00, 2.96}}, + {position = {-278.73, -77.59, -249.00}, orientation = {0.00, 0.00, 0.44}}, + {position = {301.04, -21.29, -249.00}, orientation = {0.00, 0.00, -0.69}}, + {position = {382.68, 300.45, -249.00}, orientation = {0.00, 0.00, -0.12}}, + {position = {468.77, 465.61, -249.00}, orientation = {0.00, 0.00, -1.59}}, + {position = {467.71, 588.84, -249.00}, orientation = {0.00, 0.00, -1.59}}, + {position = {446.45, 716.87, -249.00}, orientation = {0.00, 0.00, -2.51}}, + {position = {820.65, 692.28, -249.00}, orientation = {0.00, 0.00, -1.65}}, + {position = {806.36, 296.22, -217.31}, orientation = {0.00, 0.00, -1.11}}, + {position = {337.37, 737.83, -249.00}, orientation = {0.00, 0.00, 3.09}} + }, + ["Balmora, South Wall Cornerclub (mod)"] = { + {position = {239.72, 589.39, -249.00}, orientation = {0.00, 0.00, -2.86}}, + {position = {241.20, 588.73, -249.00}, orientation = {0.00, 0.00, -1.62}}, + {position = {245.71, 471.54, -249.00}, orientation = {0.00, 0.00, -1.52}}, + {position = {158.42, 334.12, -249.00}, orientation = {0.00, 0.00, -0.14}}, + {position = {544.37, 441.53, -249.00}, orientation = {0.00, 0.00, 0.65}}, + {position = {640.98, 786.91, -248.08}, orientation = {0.00, 0.00, 1.56}}, + {position = {581.22, 644.53, -248.08}, orientation = {0.00, 0.00, 2.71}} + }, + ["Gnisis, Madach Tradehouse"] = { + {position = {-625.02, 305.07, -894.00}, orientation = {0.00, 0.00, 2.46}}, + {position = {100.10, -49.81, -894.00}, orientation = {0.00, 0.00, -2.84}}, + {position = {-420.28, -723.51, -894.00}, orientation = {0.00, 0.00, 0.29}}, + {position = {575.21, -719.01, -894.00}, orientation = {0.00, 0.00, -0.74}}, + {position = {873.63, 173.11, -894.00}, orientation = {0.00, 0.00, 2.76}}, + {position = {659.33, 225.69, -894.00}, orientation = {0.00, 0.00, -2.37}}, + {position = {-237.97, 153.85, -126.00}, orientation = {0.00, 0.00, -1.77}}, + {position = {-238.99, 253.70, -126.00}, orientation = {0.00, 0.00, -1.66}}, + {position = {-242.53, 359.42, -126.00}, orientation = {0.00, 0.00, -2.30}}, + {position = {25.28, 568.43, -126.00}, orientation = {0.00, 0.00, -3.09}}, + {position = {231.21, 241.74, -126.00}, orientation = {0.00, 0.00, -1.47}}, + {position = {-23.55, 250.11, -382.00}, orientation = {0.00, 0.00, -1.47}} + }, + ["Gnisis, Fort Darius"] = { + {position = {-53.93, 519.00, -126.00}, orientation = {0.00, 0.00, 3.09}}, + {position = {14.06, 231.89, -382.00}, orientation = {0.00, 0.00, 1.37}}, + {position = {276.92, 314.82, -371.95}, orientation = {0.00, 0.00, 2.76}}, + {position = {185.69, 240.55, -371.95}, orientation = {0.00, 0.00, 2.08}}, + {position = {269.97, 531.29, -382.00}, orientation = {0.00, 0.00, -2.14}}, + {position = {-265.57, 886.74, -382.00}, orientation = {0.00, 0.00, 1.37}}, + {position = {-6.38, 773.91, -382.00}, orientation = {0.00, 0.00, -1.36}}, + {position = {693.12, 279.94, 130.00}, orientation = {0.00, 0.00, -1.12}}, + {position = {-1166.08, 319.61, -254.00}, orientation = {0.00, 0.00, 3.12}}, + {position = {-1368.90, 232.95, -254.00}, orientation = {0.00, 0.00, -0.39}}, + {position = {-1728.43, 172.37, -254.00}, orientation = {0.00, 0.00, 0.91}} + }, + ["Ald-ruhn, The Rat In The Pot"] = { + {position = {154.18, 425.84, 2.00}, orientation = {0.00, 0.00, 0.64}}, + {position = {19.49, 429.50, 2.00}, orientation = {0.00, 0.00, -1.60}}, + {position = {16.32, 517.69, 2.00}, orientation = {0.00, 0.00, -1.49}}, + {position = {18.28, 643.34, 2.00}, orientation = {0.00, 0.00, -1.52}}, + {position = {19.79, 429.49, 2.00}, orientation = {0.00, 0.00, -1.60}}, + {position = {-3.88, 281.16, 2.00}, orientation = {0.00, 0.00, -0.44}}, + {position = {-117.24, 278.73, 2.00}, orientation = {0.00, 0.00, 0.02}}, + {position = {-304.90, 274.83, 2.00}, orientation = {0.00, 0.00, -0.02}}, + {position = {-394.37, 279.30, 2.00}, orientation = {0.00, 0.00, -0.05}}, + {position = {-110.95, 308.02, -254.00}, orientation = {0.00, 0.00, -0.09}}, + {position = {-347.46, 652.76, -254.00}, orientation = {0.00, 0.00, 1.93}}, + {position = {-112.87, 694.26, -254.00}, orientation = {0.00, 0.00, -1.68}}, + {position = {-99.06, 802.53, -254.00}, orientation = {0.00, 0.00, 1.56}}, + {position = {139.54, 812.52, -254.00}, orientation = {0.00, 0.00, -1.63}}, + {position = {111.24, 694.92, -254.00}, orientation = {0.00, 0.00, -1.63}}, + {position = {701.44, -91.77, -510.00}, orientation = {0.00, 0.00, -1.82}}, + {position = {402.54, -694.76, -510.00}, orientation = {0.00, 0.00, -0.21}}, + {position = {-594.88, -618.74, -510.00}, orientation = {0.00, 0.00, 0.34}}, + {position = {-703.68, 16.55, -510.00}, orientation = {0.00, 0.00, 1.66}}, + {position = {-347.25, 403.67, -510.00}, orientation = {0.00, 0.00, 2.75}}, + {position = {485.94, 416.56, -510.00}, orientation = {0.00, 0.00, -2.32}}, + {position = {625.61, 414.57, -510.00}, orientation = {0.00, 0.00, 2.88}}, + {position = {-35.49, 731.88, -510.00}, orientation = {0.00, 0.00, -1.53}}, + {position = {-211.77, 641.18, -510.00}, orientation = {0.00, 0.00, 1.48}}, + {position = {-680.81, 211.37, -482.86}, orientation = {0.00, 0.00, 1.72}}, + {position = {-682.71, 335.97, -482.86}, orientation = {0.00, 0.00, 2.12}}, + {position = {-595.91, 435.92, -482.86}, orientation = {0.00, 0.00, 2.63}}, + {position = {-482.90, 419.23, -482.86}, orientation = {0.00, 0.00, -3.03}} + } + } +}