Module:BassaridiaForecast: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(40 intermediate revisions by the same user not shown)
Line 1: Line 1:
---------------------------------------------------------
---------------------------------------------------------
-- Module:WeatherForecastTable
-- Module:BassaridiaForecast  (FULL CITY COVERAGE + CITY FLAGS)
-- Generates a daily weather forecast table with:
--
--  * PSSC date system (Atosiel, Thalassiel, Opsitheiel)
-- Updates embedded:
--  * spelled-out climates
--  • Includes EVERY city on the canonical list (54) from List_of_cities_in_Bassaridia_Vaeringheim,
--  * random daily events (10 per climate-season)
--    plus two climate-table-only Caledonian entries (Skøda, Krlsgorod) for completeness.
--  * color-coded event cell (by keywords)
--  • Adds a City Flag icon in the City column.
-- * High/Low in °C(F)
--  • Flag standard: if the city flag file can't be found, show the Imperial Trade Union flag.
-- * Humidity, Chance of Rain, Rainfall (mm)
--  • Deterministic forecast per (city, year, day) so other modules can read the same signal.
-- * Wind Direction and Wind Speed
--  • Exposes getOpsData(city, year, dayOfYear) for War League / Hatch / Missionary modules.
-- * Natural Disaster Advisory per city
--
-- Usage:
--   {{#invoke:BassaridiaForecast|weatherForecast}}
--   {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}
--   {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}
--  {{#invoke:BassaridiaForecast|starsForTonight}}
--
---------------------------------------------------------
---------------------------------------------------------


Line 15: Line 21:


---------------------------------------------------------
---------------------------------------------------------
-- 1. Calendar System
-- 0) Calendar integration (Module:BassaridianCalendar)
---------------------------------------------------------
---------------------------------------------------------
local calOk, cal = pcall(require, "Module:BassaridianCalendar")
local DAYS_IN_YEAR = 183


local function getCurrentDateInfo()
local function getCurrentDateInfo_fallback()
    local startDate = os.time({year = 1999, month = 8, day = 6})
  local startDate   = os.time({year=1999, month=8, day=6})
    local secondsInDay = 86400
  local secondsInDay = 86400
     local daysPerYear = 183
  local currentTime  = os.time()
  local totalDays    = math.floor((currentTime - startDate) / secondsInDay)
  local psscYear  = math.floor(totalDays / DAYS_IN_YEAR)
  local dayOfYear = (totalDays % DAYS_IN_YEAR) + 1
  return { psscYear = psscYear, dayOfYear = dayOfYear }
end
 
local function parseCalendarString(s)
  s = tostring(s or "")
  local day, year = s:match("^(%d+)%s*,.-,%s*(%d+)%s*PSSC")
  if day and year then return tonumber(year), tonumber(day) end
  year = s:match("(%d+)%s*PSSC")
  day  = s:match("^(%d+)")
  if year and day then return tonumber(year), tonumber(day) end
  return nil, nil
end
 
local function getDateInfo(args)
  args = args or {}
  local yy = tonumber(args.year)
  local dd = tonumber(args.day)
  if yy and dd and dd >= 1 and dd <= DAYS_IN_YEAR then
    return { year = yy, dayOfYear = dd, source = "args" }
  end
  if calOk and cal and type(cal.getCurrentDate) == "function" then
    local raw = cal.getCurrentDate()
    local y, d = parseCalendarString(raw)
    if y and d and d >= 1 and d <= DAYS_IN_YEAR then
      return { year = y, dayOfYear = d, source = "calendar", raw = raw }
     end
  end
  local fb = getCurrentDateInfo_fallback()
  return { year = fb.psscYear, dayOfYear = fb.dayOfYear, source = "fallback" }
end
 
---------------------------------------------------------
-- 1) Season helper
---------------------------------------------------------
local function getSeasonName(dayOfYear)
  if dayOfYear <= 61 then return "Atosiel"
  elseif dayOfYear <= 122 then return "Thalassiel"
  else return "Opsitheiel" end
end
 
---------------------------------------------------------
-- 2) Deterministic RNG
---------------------------------------------------------
local function hash31(str)
  local h = 0
  str = tostring(str or "")
  for i = 1, #str do
    h = (h * 131 + str:byte(i)) % 2147483647
  end
  return h
end
 
local function lcg(seed)
  local s = seed % 2147483647
  if s <= 0 then s = s + 2147483646 end
  return function()
    s = (1103515245 * s + 12345) % 2147483647
    return s
  end
end


    local currentTime = os.time()
local function rand01(nextInt) return (nextInt() % 1000000) / 1000000 end
    local totalDaysElapsed = math.floor((currentTime - startDate) / secondsInDay)
local function randInt(nextInt, a, b)
  a, b = tonumber(a) or 0, tonumber(b) or 0
  if b < a then a, b = b, a end
  return a + (nextInt() % (b - a + 1))
end


    local yearFraction = totalDaysElapsed / daysPerYear
local function pickFromList(nextInt, list)
    local psscYear = math.floor(yearFraction)
  if not list or #list == 0 then return nil end
    local dayOfYear = math.floor((yearFraction - psscYear) * daysPerYear) + 1
  return list[(nextInt() % #list) + 1]
end


     return {
---------------------------------------------------------
        psscYear = psscYear,
-- 3) City flag logic (fallback to ITU flag if missing)
        dayOfYear = dayOfYear
---------------------------------------------------------
     }
local ITU_FLAG = "Imperial Trade Union flag.png"
 
local fileExistsCache = {}
local function fileExists(fileName)
  fileName = tostring(fileName or "")
  if fileName == "" then return false end
  if fileExistsCache[fileName] ~= nil then return fileExistsCache[fileName] end
  local t = mw.title.new("File:" .. fileName)
  local ok = (t and t.exists) or false
  fileExistsCache[fileName] = ok
  return ok
end
 
local function normalizeForFlag(name)
  -- Mirrors the lightweight normalization used in your other modules:
  -- remove spaces/hyphens/’ and normalize common accented Latin chars.
  name = tostring(name or "")
  name = name:gsub("[%s%-’]", "")
  name = name:gsub("[ÁáÀàÂâÄäÃãÅåĀā]", "A")
  name = name:gsub("[ÉéÈèÊêËëĒē]", "E")
  name = name:gsub("[ÍíÌìÎîÏïĪī]", "I")
  name = name:gsub("[ÓóÒòÔôÖöÕõØøŌō]", "O")
  name = name:gsub("[ÚúÙùÛûÜüŪū]", "U")
  name = name:gsub("[ÝýŸÿ]", "Y")
  name = name:gsub("[Ææ]", "AE")
  return name
end
 
local cityFlagOverrides = {
  ["Skýrophos"] = "SkyrophosFlag.png",
  ["Slevik"]    = "SlevikFlag.png.png",
  ["Sårensby"]  = "SårensbyFlag.png",
  ["Odiferia"]  = "OdiferiaFlag.png",
}
 
local cityFlagCache = {}
local function cityFlagMarkup(cityName, px)
  px = tonumber(px) or 20
  local key = tostring(cityName or "") .. "|" .. tostring(px)
  if cityFlagCache[key] then return cityFlagCache[key] end
 
  local candidate = cityFlagOverrides[cityName] or (normalizeForFlag(cityName) .. "Flag.png")
  local file = fileExists(candidate) and candidate or ITU_FLAG
  local mk = string.format("[[File:%s|%dpx]]", file, px)
  cityFlagCache[key] = mk
  return mk
end
 
local function cityLinkMarkup(cityName)
  -- For canonical cities: link to the list-of-cities anchors.
  -- For the two extra climate-table-only cities, link to their own page (if it exists) without anchor assumptions.
  if cityName == "Skøda" or cityName == "Krlsgorod" then
     return string.format("[[%s]]", cityName)
  end
  return string.format("[[List_of_cities_in_Bassaridia_Vaeringheim#%s|%s]]", cityName, cityName)
end
 
---------------------------------------------------------
-- 4) Weather helpers
---------------------------------------------------------
local function cToF(c)
  if type(c) == "number" then return math.floor(c * 9/5 + 32 + 0.5) end
  return c
end
 
local function conditionFromText(t)
  t = (t or ""):lower()
  if t:find("blizzard") then return "Blizzard" end
  if t:find("snow") or t:find("sleet") or t:find("flurr") or t:find("ice") then return "Snow" end
  if t:find("thunder") or t:find("severe") or t:find("downpour") or t:find("storm") then return "Storm" end
  if t:find("rain") or t:find("drizzle") or t:find("shower") then return "Rain" end
  if t:find("fog") or t:find("mist") or t:find("low visibility") then return "Fog" end
  if t:find("dust") or t:find("sandstorm") or t:find("blowing sand") then return "Dust" end
  if t:find("clear") or t:find("spotless") or t:find("sunny") then return "Clear" end
  if t:find("overcast") or t:find("cloud") or t:find("gloom") or t:find("haze") then return "Cloudy" end
  return "Fair"
end
 
local function heatIndexF(T, RH)
  return -42.379 + 2.04901523*T + 10.14333127*RH - 0.22475541*T*RH
        - 0.00683783*T*T - 0.05481717*RH*RH + 0.00122874*T*T*RH
        + 0.00085282*T*RH*RH - 0.00000199*T*T*RH*RH
end
 
local function windChillF(T, windMph)
  return 35.74 + 0.6215*T - 35.75*(windMph^0.16) + 0.4275*T*(windMph^0.16)
end
 
local function computeFeelsLikeF(hiF, hum, windKmh)
  local windMph = (tonumber(windKmh) or 0) * 0.621371
  hiF = tonumber(hiF) or 0
  hum = tonumber(hum) or 0
  if hiF >= 80 and hum >= 40 then
    return math.floor(heatIndexF(hiF, hum) + 0.5)
  end
  if hiF <= 50 and windMph >= 3 then
    return math.floor(windChillF(hiF, windMph) + 0.5)
  end
  return hiF
end
 
local function computeCloudAndVis(nextInt, cond)
  if cond == "Clear" then
    return randInt(nextInt, 0, 20), randInt(nextInt, 15, 30)
  elseif cond == "Cloudy" then
    return randInt(nextInt, 35, 95), randInt(nextInt, 10, 25)
  elseif cond == "Fog" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 6)
  elseif cond == "Rain" then
    return randInt(nextInt, 70, 100), randInt(nextInt, 4, 15)
  elseif cond == "Storm" then
    return randInt(nextInt, 85, 100), randInt(nextInt, 2, 10)
  elseif cond == "Snow" then
     return randInt(nextInt, 80, 100), randInt(nextInt, 2, 10)
  elseif cond == "Blizzard" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 5)
  elseif cond == "Dust" then
    return randInt(nextInt, 20, 80), randInt(nextInt, 2, 10)
  end
  return randInt(nextInt, 10, 60), randInt(nextInt, 10, 25)
end
 
local function computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local impacts = { ground="OK", sea="OK", air="OK" }
  precipProb = tonumber(precipProb) or 0
  windKmh = tonumber(windKmh) or 0
  visKm = tonumber(visKm) or 99
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0
 
  if visKm <= 1 or windKmh >= 55 or cond == "Blizzard" then
    impacts.air = "No-Go"
  elseif visKm <= 5 or windKmh >= 40 or cond == "Storm" then
    impacts.air = "Limited"
  end
 
  if windKmh >= 50 or (cond == "Storm" and precipProb >= 85) or cond == "Blizzard" then
    impacts.sea = "Closed"
  elseif windKmh >= 28 or cond == "Storm" or (cond == "Rain" and precipProb >= 80) then
    impacts.sea = "Restricted"
  end
 
  if (cond == "Snow" or cond == "Blizzard") and (loF <= 32 or visKm <= 3) then
    impacts.ground = "Hazard"
  elseif cond == "Storm" or (cond == "Rain" and precipProb >= 80) or visKm <= 5 then
    impacts.ground = "Slow"
  end
 
  if hiF >= 100 and impacts.ground == "OK" then impacts.ground = "Slow" end
  if loF <= 10 and impacts.ground == "OK" then impacts.ground = "Slow" end
 
  return impacts
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 2. Determine the Season
-- 5) Ops Alert badge helpers
--    1..61      => Atosiel
--    62..122    => Thalassiel
--    123..183    => Opsitheiel
---------------------------------------------------------
---------------------------------------------------------
local function alertLabel(level)
  if level == 3 then return "Red" end
  if level == 2 then return "Orange" end
  if level == 1 then return "Yellow" end
  return "Green"
end
local function alertColor(level)
  if level == 3 then return "#f8d7da" end
  if level == 2 then return "#ffe5cc" end
  if level == 1 then return "#fff3cd" end
  return "#d4edda"
end
local function spanBadge(level, text)
  return string.format(
    "<span style='background:%s;padding:2px 6px;border:1px solid #ccc;border-radius:10px;white-space:nowrap;'>%s</span>",
    alertColor(level), text
  )
end


local function getSeasonName(dayOfYear)
local function formatImpact(imp)
    if dayOfYear <= 61 then
  imp = imp or {}
        return "Atosiel"
  return string.format("Gnd: %s; Sea: %s; Air: %s", imp.ground or "OK", imp.sea or "OK", imp.air or "OK")
    elseif dayOfYear <= 122 then
        return "Thalassiel"
    else
        return "Opsitheiel"
    end
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 3. Weather Events (10 per climate-season)
-- 6) Climate ranges (°C base; shown °F)
---------------------------------------------------------
---------------------------------------------------------
local climateTemperature = {
  ["Humid Subtropical"] = {
    Atosiel    = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80 },
    Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90 },
    Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75 }
  },
  ["Oceanic"] = {
    Atosiel    = { hiMin=10, hiMax=17, loMin=5,  loMax=12, humMin=70, humMax=90 },
    Thalassiel  = { hiMin=14, hiMax=21, loMin=9,  loMax=14, humMin=65, humMax=85 },
    Opsitheiel  = { hiMin=5,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 }
  },
  ["Subpolar Oceanic"] = {
    Atosiel    = { hiMin=3,  hiMax=9,  loMin=-2, loMax=3,  humMin=70, humMax=95 },
    Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 },
    Opsitheiel  = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95 }
  },
  ["Mediterranean (Hot Summer)"] = {
    Atosiel    = { hiMin=15, hiMax=21, loMin=7,  loMax=12, humMin=40, humMax=60 },
    Thalassiel  = { hiMin=25, hiMax=35, loMin=15, loMax=20, humMin=30, humMax=50 },
    Opsitheiel  = { hiMin=12, hiMax=18, loMin=5,  loMax=10, humMin=45, humMax=65 }
  },
  ["Hot Desert"] = {
    Atosiel    = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30 },
    Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20 },
    Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25 }
  },
  ["Cold Steppe"] = {
    Atosiel    = { hiMin=10, hiMax=18, loMin=2,  loMax=8,  humMin=30, humMax=50 },
    Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45 },
    Opsitheiel  = { hiMin=0,  hiMax=7,  loMin=-6, loMax=0,  humMin=35, humMax=55 }
  },
  ["Hot Steppe"] = {
    Atosiel    = { hiMin=23, hiMax=30, loMin=12, loMax=18, humMin=20, humMax=40 },
    Thalassiel  = { hiMin=30, hiMax=40, loMin=20, loMax=25, humMin=15, humMax=35 },
    Opsitheiel  = { hiMin=25, hiMax=33, loMin=15, loMax=20, humMin=15, humMax=40 }
  },
  ["Subarctic"] = {
    Atosiel    = { hiMin=0,  hiMax=8,  loMin=-10,loMax=-1, humMin=50, humMax=80 },
    Thalassiel  = { hiMin=5,  hiMax=15, loMin=-1, loMax=5,  humMin=50, humMax=85 },
    Opsitheiel  = { hiMin=-5, hiMax=0,  loMin=-15,loMax=-5, humMin=60, humMax=90 }
  }
}


---------------------------------------------------------
-- 7) Flavor strings (still keyed by climate/season)
---------------------------------------------------------
local climateEvents = {
local climateEvents = {
    ["Humid Subtropical"] = {
  ["Humid Subtropical"] = {
        Atosiel = {
    Atosiel = {
            "Morning drizzle and warm afternoon sunshine",
      "Morning drizzle and warm afternoon sunshine",
            "Mild thunderstorm building by midday",
      "Mild thunderstorm building by midday",
            "Patchy fog at dawn, clearing toward lunch",
      "Patchy fog at dawn, clearing toward lunch",
            "Light rain with sunny breaks after noon",
      "Light rain with sunny breaks after noon",
            "Gentle breezes, blossoming warmth, low humidity",
      "Overcast for part of the day, mild temperatures",
            "Scattered clouds with a brief shower by dusk",
      "Scattered clouds with a brief shower by dusk",
            "Humid sunrise, comfortable high around midday",
      "Warm breezes carrying faint floral scents",
            "Overcast for part of the day, mild temperatures",
      "Early morning seasonal storms and lake-driven floods may develop"
            "Warm breezes carrying faint floral scents",
    },
            "Partial sun, warm with a slight chance of rain"
    Thalassiel = {
        },
      "Hot, steamy day with intense midday heat",
        Thalassiel = {
      "Tropical-like humidity, afternoon thunder possible",
            "Hot, steamy day with intense midday heat",
      "Intermittent heavy downpours, muggy evenings",
            "Tropical-like humidity, afternoon thunder possible",
      "High humidity and patchy thunderstorms late",
            "Intermittent heavy downpours, muggy evenings",
      "Heat advisory with only brief cooling at night",
            "High humidity and patchy thunderstorms late",
      "Nighttime storms lingering into early morning",
            "Sun-scorched morning, scattered storms by dusk",
      "Afternoon heavy rains could trigger river floods"
            "Hazy sunshine, extremely warm all day",
            "Thick humidity, chance of lightning late evening",
            "Sticky air, short downpours in isolated spots",
            "Heat advisory with only brief cooling at night",
            "Nighttime storms lingering into early morning"
        },
        Opsitheiel = {
            "Warm daytime, gentle evening breezes",
            "Occasional rain, otherwise mild temperatures",
            "Late-season warmth, scattered rain after sunset",
            "Cooler mornings, returning to warmth by midday",
            "Sparse cloud cover, tranquil weather overall",
            "Fog at dawn, warm midday, pleasant night",
            "Partial sun, comfortable humidity levels",
            "Evening drizzle with mild breezes",
            "Patchy haze, moderate warmth throughout the day",
            "Short-lived shower, then clearing skies"
        }
     },
     },
    Opsitheiel = {
      "Warm daytime, gentle evening breezes",
      "Occasional rain, otherwise mild temperatures",
      "Fog at dawn, warm midday, pleasant night",
      "Evening drizzle with mild breezes",
      "Short-lived shower, then clearing skies",
      "Evening cooling may lead to localized mudslides on steep slopes"
    }
  },


    ["Oceanic"] = {
  ["Oceanic"] = {
        Atosiel = {
    Atosiel = {
            "Frequent light rain, cool breezes all day",
      "Frequent light rain, cool breezes all day",
            "Foggy dawn, mild midmorning sunshine",
      "Foggy dawn, mild midmorning sunshine",
            "Short sunbreaks among passing showers",
      "Short sunbreaks among passing showers",
            "Consistent drizzle, fresh winds from the sea",
      "Consistent drizzle, fresh winds from the sea",
            "Cloudy intervals with a few bright spells",
      "Morning fog with coastal storms and marsh flooding possible"
            "Late-afternoon clearing, crisp evening air",
            "Slow-moving clouds, mild temperatures",
            "Gentle but persistent rain, green foliage thriving",
            "Off-and-on showers, brief sunny interludes",
            "Steady breeze, moderate humidity levels"
        },
        Thalassiel = {
            "Light rain off and on, mild temperatures",
            "Overcast with comfortable breezes throughout",
            "Drizzly morning, partly cloudy afternoon",
            "Soft rains, lush vegetation, stable temps",
            "Cool, damp start, drying toward evening",
            "Frequent cloud cover, gentle summer breeze",
            "Moist air with lingering fog near streams",
            "Rain-laden air, occasional breaks of sun",
            "Maritime winds bringing light spray inland",
            "Cloudy midday, mild sunshine before dusk"
        },
        Opsitheiel = {
            "Overcast skies, cool drizzle through the day",
            "Sporadic showers, chillier in the evening",
            "Low visibility in morning fog, slow clearing",
            "Gray skies, frequent rain squalls, cool wind",
            "Chilly nightfall, damp ground all day",
            "Heavier showers arriving late afternoon",
            "Periodic drizzle, temperatures on the cool side",
            "Morning gloom, occasional pockets of clearing",
            "Evening rainfall pushing in from the coast",
            "Dense cloud cover, breezy and damp"
        }
     },
     },
    Thalassiel = {
      "Light rain off and on, mild temperatures",
      "Overcast with comfortable breezes throughout",
      "Moist air with lingering fog near streams",
      "Intermittent showers may escalate into flash floods and coastal storms"
    },
    Opsitheiel = {
      "Overcast skies, cool drizzle through the day",
      "Low visibility in morning fog, slow clearing",
      "Heavier showers arriving late afternoon",
      "Evening drizzle may develop into coastal gales and shoreline flooding"
    }
  },


    ["Subpolar Oceanic"] = {
  ["Subpolar Oceanic"] = {
        Atosiel = {
    Atosiel = {
            "Near-freezing dawn, cold drizzle by midday",
      "Near-freezing dawn, cold drizzle by midday",
            "Mist over melting snow patches, breezy afternoon",
      "Overcast skies, mix of rain and sleet",
            "Overcast skies, mix of rain and sleet",
      "Snowmelt water raising local streams",
            "Cool, damp air, slight warmup after noon",
      "Cold drizzle combined with snowstorms and blizzards in higher areas"
            "Late-day sleet turning to light rain",
            "Mountaintop flurries, valley drizzle",
            "Cloudy morning, partial clearing in late afternoon",
            "Chilly breeze, scattered showers off and on",
            "Gray skies, some slushy buildup on paths",
            "Snowmelt water raising local streams"
        },
        Thalassiel = {
            "Cool, short days with periodic drizzle",
            "Heavy low clouds, pockets of bright breaks",
            "Gentle rain, evening chill deeper than normal",
            "Intermittent showers, damp wind from hills",
            "Cloud deck lingering, occasional lighter drizzle",
            "Faint sunlight overshadowed by thick cloud",
            "Subdued warmth, sporadic mist across ridges",
            "Surprisingly bright morning, clouding by dusk",
            "Short bursts of rain followed by fog",
            "Late-night chill with drizzle or brief sleet"
        },
        Opsitheiel = {
            "Steady cold rain mixing with wet snow",
            "Freezing drizzle at dawn, raw gusty winds",
            "Snowfall likely at higher elevations, rain below",
            "Cloudy, damp day with potential sleet storms",
            "Early morning frost turning to slush midday",
            "Occasional heavy showers with flurry bursts",
            "Gray gloom, subfreezing nights across valleys",
            "Pelting sleet for an hour, then calm, cold air",
            "Gusty evening, possible snow accumulations",
            "Sun rarely visible, persistent winter gloom"
        }
     },
     },
    Thalassiel = {
      "Cool, short days with periodic drizzle",
      "Short bursts of rain followed by fog",
      "Misty conditions could prompt snowstorms and river floods"
    },
    Opsitheiel = {
      "Steady cold rain mixing with wet snow",
      "Freezing drizzle at dawn, raw gusty winds",
      "Cloudy, damp day with potential sleet storms",
      "Freezing drizzle may trigger avalanches in higher passes"
    }
  },


    ["Mediterranean (Hot Summer)"] = {
  ["Mediterranean (Hot Summer)"] = {
        Atosiel = {
    Atosiel = {
            "Mild temperatures, bright sunshine, dry air",
      "Mild temperatures, bright sunshine, dry air",
            "Morning dew, warming fast under clear skies",
      "Sunny midday with a crisp, light wind",
            "Cool breezes off the sea, comfortable afternoon",
      "Short drizzly spell, then mostly clear",
            "Sunny midday with a crisp, light wind",
      "Bright skies overshadowed by potential heat advisory and droughts"
            "Short drizzly spell, then mostly clear",
            "Early bloom across hills, moderate warmth",
            "Mostly sunny, a few scattered clouds late",
            "Light shower possible at sunrise, drying by noon",
            "Warming trend, low humidity, pleasant day",
            "Gradual warming under spotless sky"
        },
        Thalassiel = {
            "Very hot midday sun, minimal clouds",
            "Scorching days, slight breeze at dusk",
            "Parched hillsides, no sign of rainfall",
            "High UV index, bright, glaring sunlight",
            "Coastal dryness, strong midday glare",
            "Overnight relief, but intense heat returns early",
            "Dust in the wind from inland hills",
            "Cloudless sky, near-record high temps",
            "Salt-laden breezes if near the coast, scorching inland",
            "Heatwaves persisting, zero rain expected"
        },
        Opsitheiel = {
            "Cooler spells, brief showery intervals",
            "Cloudy periods bringing mild relief from heat",
            "Pleasant afternoons, occasional dusk rains",
            "Moderate days, crisp mornings, calm nights",
            "Sporadic light rainfall, refreshing breezes",
            "Cloudbanks drifting inland, mild drizzle at times",
            "Patchy overcast, comfortable midday temps",
            "Clear dawn, mild sunshine, gentle wind late",
            "Intervals of cloud cover, potential short rain",
            "Moist sea air, moderate daytime warmth"
        }
     },
     },
    Thalassiel = {
      "Very hot midday sun, minimal clouds",
      "Heatwaves persisting, zero rain expected",
      "Intense midday sun might lead to severe heat waves and dust storms"
    },
    Opsitheiel = {
      "Cooler spells, brief showery intervals",
      "Sporadic light rainfall, refreshing breezes",
      "Cooling breezes could reduce temperatures, but localized brushfires and summer wildfires remain possible"
    }
  },


    ["Hot Desert"] = {
  ["Hot Desert"] = {
        Atosiel = {
    Atosiel = {
            "Hot afternoons, cooler late nights, wide temp swing",
      "Dry air, bright sunshine, no clouds in sight",
            "Dry air, bright sunshine, no clouds in sight",
      "Gentle breezes raising light sand by midday",
            "Gentle breezes raising light sand by midday",
      "Minor dust devil scouring the dunes",
            "Evening chill setting in after hot daytime",
      "Scorching afternoons may be accompanied by dust storms and occasional mirage-like shimmers"
            "Minor dust devil scouring the dunes",
    },
            "Visibility excellent, stable high pressure",
    Thalassiel = {
            "Patchy desert haze, strong sun overhead",
      "Blistering midday sun, risk of heat stroke",
            "No hint of precipitation, cloudless horizon",
      "Occasional dust storm cutting visibility briefly",
            "Daytime warmth, refreshing if slight breeze arrives",
      "Extreme heat could combine with blowing sand to trigger potential sandstorms"
            "Sun-warmed rock surfaces, mild nights"
        },
        Thalassiel = {
            "Extreme heat from mid-morning onward",
            "Blistering midday sun, risk of heat stroke",
            "Near-record highs, swirling dust at times",
            "Virtually no clouds, scorching open sands",
            "Occasional dust storm cutting visibility briefly",
            "Mirage-like shimmer across flat terrain",
            "Sunset brings partial relief, still quite warm",
            "Dry conditions, subpar humidity all day",
            "Baking desert floor, potential for blowing sand",
            "Intense heat building day after day"
        },
        Opsitheiel = {
            "Warm, mostly sunny with cooler nights",
            "Sparse winds carrying fine sand sporadically",
            "Large day-night temperature gap, no precipitation",
            "Stable conditions, sunlit afternoons, brisk evenings",
            "Mildly hot days, star-filled clear skies at night",
            "Shallow dust clouds after brief winds",
            "Occasional gust picking up desert grit",
            "Gentle warmth, dryness prevalent, no clouds",
            "Cool breezes shortly after sunset, mild day overall",
            "Hazy horizon, soft morning glow across dunes"
        }
     },
     },
    Opsitheiel = {
      "Warm, mostly sunny with cooler nights",
      "Occasional gust picking up desert grit",
      "Evening cooling might still see intense heat with possible dust devils on dry slopes"
    }
  },


    ["Cold Steppe"] = {
  ["Cold Steppe"] = {
        Atosiel = {
    Atosiel = {
            "Cool mornings, moderate midday warmth, breezy",
      "Cool mornings, moderate midday warmth, breezy",
            "Light rain passing through grasslands midday",
      "Light rain passing through grasslands midday",
            "Variable cloud cover, mild temperature swings",
      "Variable cloud cover, mild temperature swings",
            "Patchy frost at dawn, warming soon after",
      "Mild conditions could turn volatile with sudden flash floods and minor landslides"
            "Crisp air, possible small hail if storms appear",
            "Sunny afternoon, cooler late evening",
            "Gentle breeze, fleeting sun amidst scattered clouds",
            "Slow temperature rise, slight dryness in mid-afternoon",
            "Brief drizzle, otherwise bright and breezy",
            "Day-night difference noticeable, mild but windy"
        },
        Thalassiel = {
            "Warm day, potential for gusty thunderstorms",
            "Hot midday sun, dryness intensifying by late day",
            "Cloud buildup, short but intense showers possible",
            "Clear morning, chance of late afternoon storms",
            "Long daylight hours, rolling thunder near dusk",
            "Slight dryness, patchy heat with limited shade",
            "Periodic storms bringing relief from heat",
            "Warm evening, slight nighttime cooldown",
            "Sky remains mostly clear, grassland shimmering",
            "Thunderheads visible in distance, might pass by"
        },
        Opsitheiel = {
            "Cool to cold days, frosty nights on open steppe",
            "Possible light snowfall, especially overnight",
            "Wind-driven chill under gray skies",
            "Sparse precipitation, dryness persists, cold air",
            "Subfreezing after sunset, sometimes sunny midday",
            "Short bursts of sunshine, otherwise chilly conditions",
            "Occasional dusting of snow, quickly melting by noon",
            "Biting wind, few clouds, stark horizon",
            "Clearing late morning, crisp, cold nightfall",
            "Daytime near freezing, nights well below"
        }
     },
     },
    Thalassiel = {
      "Warm day, potential for gusty thunderstorms",
      "Cloud buildup, short but intense showers possible",
      "Rolling thunder near dusk, rain squalls possible",
      "Afternoon heat may intensify, leading to thunderstorms, river floods, and gusty rain squalls"
    },
    Opsitheiel = {
      "Wind-driven chill under gray skies",
      "Possible light snowfall, especially overnight",
      "Sparse precipitation, dryness persists, cold air",
      "Chilly nights might bring frost and the risk of localized landslides on steep slopes"
    }
  },


    ["Hot Steppe"] = {
  ["Hot Steppe"] = {
        Atosiel = {
    Atosiel = {
            "Hot daytime sun, slight breeze, no rain",
      "Hot daytime sun, slight breeze, no rain",
            "Gusty wind with dust swirling near midday",
      "Gusty wind with dust swirling near midday",
            "Warm mornings, scorching afternoon, very low humidity",
      "Scorching sun may lead to persistent dryness with a risk of brushfires and dust devils on dry slopes"
            "Light breeze after sundown, starry overhead",
    },
            "Some haze from heated ground, minimal cloud cover",
    Thalassiel = {
            "Sun intensity rising toward midday peak",
      "Brutally hot midday sun, patchy dust storms",
            "Dry soil conditions, no sign of moisture",
      "Occasional swirling wind gusts, drifting dust",
            "Sparse vegetation, daily heat climbing gradually",
      "Intense heat may trigger severe dust storms and a potential heat wave"
            "Clear horizon, wind picking up in late evening",
            "Sun exposure high, consistent dryness"
        },
        Thalassiel = {
            "Brutally hot midday sun, patchy dust storms",
            "Scorching conditions, faint breeze offers minimal relief",
            "Occasional swirling wind gusts, drifting dust",
            "Heated ground emits rippling mirages at distance",
            "Persistent dryness, minimal nighttime cooldown",
            "Skies remain cloudless, high evaporation rate",
            "Dust-laden air in late afternoon swirl",
            "Rare cloud can appear but quickly vanishes",
            "Daily highs remain extreme, nights only mild",
            "Lingering warmth through sunset, dryness unwavering"
        },
        Opsitheiel = {
            "Hot days, moderate evenings, stable dryness",
            "Rare short rains if storm fronts approach, mostly none",
            "Daytime heat lingering, nights slightly cooler",
            "Periodically hazy morning from airborne dust",
            "Mild breezes, daytime remains intensely hot",
            "Cloudless skies, gentle drop in temps overnight",
            "Dust devils possible over parched plains",
            "Occasional relief from mild wind, no precipitation",
            "Hot, tranquil afternoon, moderate wind at dusk",
            "Minimal cloud presence, dryness dominating environment"
        }
     },
     },
    Opsitheiel = {
      "Hot days, moderate evenings, stable dryness",
      "Dust devils possible over parched plains",
      "Evening cooling might reduce temperatures, yet localized sandstorms and minor landslides remain a concern"
    }
  },


    ["Subarctic"] = {
  ["Subarctic"] = {
        Atosiel = {
    Atosiel = {
            "Snow melting, slushy paths, daytime sunshine",
      "Snow melting, slushy paths, daytime sunshine",
            "Chilly breezes, frequent flurry patches possible",
      "Mixed precipitation, cold rain at lower altitudes",
            "Long nights gradually shortening, crisp mornings",
      "Rapid thawing could spark river floods and snowstorms with heavy snowfall"
            "Mixed precipitation, cold rain at lower altitudes",
    },
            "Slush build-up midmorning, partial sun midday",
    Thalassiel = {
            "Sunny breaks around noon, subzero by late night",
      "Short, cool days, moderate sunshine at times",
            "Sporadic storm with sleet or melting snow",
      "Drizzle commonly, heavier rainfall occasionally",
            "Icy patches linger, overall chilly but bright",
      "Cool days may be interrupted by sudden blizzard-like conditions and heavy rain squalls"
            "Early thaw, mild midday, returning chill after dusk",
    },
            "Reluctant spring with freezing nights, slow warm days"
    Opsitheiel = {
        },
      "Early snowfall returning, freezing ground rapidly",
        Thalassiel = {
      "Blizzard-like conditions with large snow accumulations",
            "Short, cool days, moderate sunshine at times",
      "Deep winter chill may result in persistent blizzards and avalanches in higher passes"
            "Rainy intervals, potential sleet in highest peaks",
            "Snow line retreating, green valleys emerging slowly",
            "Extended twilight, crisp air after sundown",
            "Sudden midafternoon chill despite mid-summer period",
            "Drizzle commonly, heavier rainfall occasionally",
            "Mountain passes remain cool, breezy conditions",
            "Mostly cloudy, occasional glimpses of sun",
            "Brisk wind, earlier nightfall than lower latitudes",
            "Daytime highs remain mild, nights quite cold"
        },
        Opsitheiel = {
            "Early snowfall returning, freezing ground rapidly",
            "Prolonged darkness, bitterness sets in quickly",
            "Gusty winds carrying ice crystals through valleys",
            "Heavy drifts forming, multiple snowfall events",
            "Frequent subzero lows, no hint of thaw",
            "Blizzard-like conditions with large snow accumulations",
            "Icy rivers, widespread frost in daylight",
            "Mountains see near-constant snow, valleys freeze nightly",
            "Rare clear spells, intense wind chill outside",
            "Deep winter mode, swirling snow flurries all day"
        }
     }
     }
  }
}
}


---------------------------------------------------------
---------------------------------------------------------
-- 4. Temperature, Humidity, Rain Ranges (hiMin, hiMax, etc.)
-- 8) Triggered advisory keywords (optional extra advisory layer)
---------------------------------------------------------
---------------------------------------------------------
 
local cityForecastOverrides = {
local climateTemperature = {
  ["Hamarrfell"] = {
    ["Humid Subtropical"] = {
    Atosiel = {
        Atosiel     = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80, crMin=20, crMax=50, rfMin=1, rfMax=10 },
      "Late snowpack lingering on ridge roads, cold meltwater in the lower streets",
        Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90, crMin=30, crMax=70, rfMin=2, rfMax=15 },
      "Fog in the ropeway approaches, sleet over the upper passes",
        Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75, crMin=15, crMax=40, rfMin=1, rfMax=8  }
      "Sharp mountain wind with thawing snow on the lower approaches",
      "Cold drizzle below, fresh snowfall returning across the higher road cuttings"
     },
     },
     ["Oceanic"] = {
     Thalassiel = {
        Atosiel    = { hiMin=10, hiMax=17, loMin=5,  loMax=12, humMin=70, humMax=90, crMin=30, crMax=60, rfMin=2, rfMax=10 },
      "Short cool day with clear intervals, but fog holding in the upland cuts",
        Thalassiel  = { hiMin=14, hiMax=21, loMin=9,  loMax=14, humMin=65, humMax=85, crMin=35, crMax=70, rfMin=2, rfMax=12 },
      "Mountain rain and low cloud drifting across the high road",
        Opsitheiel  = { hiMin=5, hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90, crMin=40, crMax=80, rfMin=3, rfMax=15 }
      "Cool highland air with sudden mist over the ropeway line",
      "Breezy conditions in the staging yards, cloud gathering over the passes"
     },
     },
     ["Subpolar Oceanic"] = {
     Opsitheiel = {
        Atosiel    = { hiMin=3,  hiMax=9,  loMin=-2, loMax=3,  humMin=70, humMax=95, crMin=30, crMax=70, rfMin=2, rfMax=10 },
      "Early snow returning to the ridgelines, freezing ground after dusk",
        Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90, crMin=35, crMax=75, rfMin=2, rfMax=12 },
      "Blizzard conditions in the higher passes, severe wind over exposed roads",
        Opsitheiel = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95, crMin=40, crMax=80, rfMin=3, rfMax=15 }
      "Hard frost in the city with drifting snow on the upland approaches",
    },
      "Snow squalls and low visibility across the ropeway and pass network"
    ["Mediterranean (Hot Summer)"] = {
        Atosiel    = { hiMin=15, hiMax=21, loMin=7,  loMax=12, humMin=40, humMax=60, crMin=10, crMax=40, rfMin=1, rfMax=6  },
        Thalassiel  = { hiMin=25, hiMax=35, loMin=15, loMax=20, humMin=30, humMax=50, crMin=0,  crMax=10, rfMin=0, rfMax=3  },
        Opsitheiel  = { hiMin=12, hiMax=18, loMin=5,  loMax=10, humMin=45, humMax=65, crMin=10, crMax=40, rfMin=1, rfMax=6  }
    },
    ["Hot Desert"] = {
        Atosiel    = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30, crMin=0, crMax=5,  rfMin=0, rfMax=1  },
        Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20, crMin=0, crMax=2,  rfMin=0, rfMax=1  },
        Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25, crMin=0, crMax=10, rfMin=0, rfMax=2  }
    },
    ["Cold Steppe"] = {
        Atosiel    = { hiMin=10, hiMax=18, loMin=2,  loMax=8,  humMin=30, humMax=50, crMin=10, crMax=40, rfMin=1, rfMax=5  },
        Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45, crMin=10, crMax=40, rfMin=1, rfMax=5  },
        Opsitheiel  = { hiMin=0,  hiMax=7,  loMin=-6, loMax=0,  humMin=35, humMax=55, crMin=10, crMax=30, rfMin=1, rfMax=5  }
    },
    ["Hot Steppe"] = {
        Atosiel    = { hiMin=23, hiMax=30, loMin=12, loMax=18, humMin=20, humMax=40, crMin=0,  crMax=20, rfMin=0, rfMax=3  },
        Thalassiel  = { hiMin=30, hiMax=40, loMin=20, loMax=25, humMin=15, humMax=35, crMin=0,  crMax=10, rfMin=0, rfMax=2  },
        Opsitheiel  = { hiMin=25, hiMax=33, loMin=15, loMax=20, humMin=15, humMax=40, crMin=0,  crMax=20, rfMin=0, rfMax=3  }
    },
    ["Subarctic"] = {
        Atosiel    = { hiMin=0,  hiMax=8,  loMin=-10,loMax=-1, humMin=50, humMax=80, crMin=20, crMax=50, rfMin=1, rfMax=5  },
        Thalassiel  = { hiMin=5,  hiMax=15, loMin=-1, loMax=5,  humMin=50, humMax=85, crMin=20, crMax=60, rfMin=1, rfMax=6  },
        Opsitheiel  = { hiMin=-5, hiMax=0,  loMin=-15,loMax=-5, humMin=60, humMax=90, crMin=30, crMax=70, rfMin=1, rfMax=8  }
     }
     }
  }
}
local triggeredDisasters = {
  { keywords={"seasonal storms","lake%-driven floods"}, hazard="Seasonal storms and lake-driven floods" },
  { keywords={"river floods","heavy rains"}, hazard="River floods during heavy rains" },
  { keywords={"snowstorms","heavy snowfall"}, hazard="Snowstorms / heavy snowfall" },
  { keywords={"avalanch"}, hazard="Avalanche risk" },
  { keywords={"landslides","mudslides"}, hazard="Landslides / mudslides" },
  { keywords={"drought","persistent dryness","dry conditions"}, hazard="Drought / elevated fire risk" },
  { keywords={"brushfires","wildfires"}, hazard="Brushfire / wildfire risk" },
  { keywords={"dust storm","sandstorm","blowing sand"}, hazard="Dust / sandstorm risk" },
  { keywords={"fog","low visibility"}, hazard="Fog-related hazards" },
  { keywords={"severe storm","thunder"}, hazard="Severe storm / lightning risk" },
  { keywords={"storm surges","shoreline flooding","coastal gales"}, hazard="Coastal flooding / surge risk" }
}
}
local windDirections = { "N","NE","E","SE","S","SW","W","NW" }


---------------------------------------------------------
---------------------------------------------------------
-- 5. Color Coding for Weather Description
-- 9) City registry (ALL cities) + climate assignment
-- NOTE: Notranskja and Tonar are not explicitly in the nation Climate table;
--      they are assigned plausible climates consistent with their dependency regions.
---------------------------------------------------------
---------------------------------------------------------
local cityData = {
  -- Core major
  { name="Vaeringheim",      climate="Humid Subtropical" },
  { name="Luminaria",        climate="Oceanic" },
  { name="Serena",          climate="Subpolar Oceanic" },
  { name="Pyralis",          climate="Oceanic" },
  { name="Symphonara",      climate="Oceanic" },
  { name="Aurelia",          climate="Mediterranean (Hot Summer)" },
  { name="Somniumpolis",    climate="Humid Subtropical" },
  { name="Nexa",            climate="Oceanic" },
  { name="Lunalis Sancta",  climate="Oceanic" },
  { name="Sylvapolis",      climate="Humid Subtropical" },
  -- Core minor
  { name="Saluria",          climate="Oceanic" },
  { name="Aetherium",        climate="Subarctic" },
  { name="Ferrum Citadel",  climate="Hot Desert" },
  { name="Acheron",          climate="Cold Steppe" },
  { name="Erythros",        climate="Oceanic" },
  { name="Catonis Atrium",  climate="Oceanic" },
  { name="Delphica",        climate="Oceanic" },
  { name="Koinonía",        climate="Oceanic" },
  { name="Aureum",          climate="Mediterranean (Hot Summer)" },
  -- Alperkin
  { name="The Alpazkigz",    climate="Cold Steppe" }, -- plausible high-steppe/plateau mix; adjust if you later codify it
  { name="Odiferia",        climate="Humid Subtropical" }, -- wetland belt; adjust if you later codify it
  -- New South Jangsong (major)
  { name="Skýrophos",        climate="Oceanic" },
  { name="Bjornopolis",      climate="Oceanic" },
  { name="Aegirheim",        climate="Subarctic" },
  { name="Norsolyra",        climate="Oceanic" },
  { name="Thorsalon",        climate="Oceanic" },
  -- New South Jangsong (minor)
  { name="Pelagia",          climate="Hot Steppe" },
  { name="Myrene",          climate="Oceanic" },
  { name="Thyrea",          climate="Humid Subtropical" },
  { name="Ephyra",          climate="Subpolar Oceanic" },
  { name="Halicarn",        climate="Mediterranean (Hot Summer)" },
  -- Haifan Bassaridia (major)
  { name="Keybir-Aviv",      climate="Humid Subtropical" },
  { name="Tel-Amin",        climate="Mediterranean (Hot Summer)" },
  { name="Diamandis",        climate="Mediterranean (Hot Summer)" },
  { name="Jogi",            climate="Oceanic" },
  { name="Lewisburg",        climate="Humid Subtropical" },
  -- Haifan Bassaridia (minor)
  { name="Thermosalem",      climate="Oceanic" },
  { name="Akróstadium",      climate="Cold Steppe" },
  { name="Sufriya",          climate="Humid Subtropical" },
  { name="Lykopolis",        climate="Oceanic" },


local function getEventColor(eventText)
  -- Normark
    local textLower = eventText:lower()
  { name="Ardclach",         climate="Subarctic" },
    if textLower:find("thunder") or textLower:find("storm") then
  { name="Riddersborg",      climate="Subpolar Oceanic" },
        return "#FFD2D2"  -- light red for storms
    elseif textLower:find("snow") or textLower:find("sleet") or textLower:find("flurries") then
         return "#D2ECFF" -- light blue for snow
    elseif textLower:find("rain") or textLower:find("drizzle") or textLower:find("downpour") then
        return "#D2DFFF"  -- light purple/blue for rain events
    elseif textLower:find("dust") or textLower:find("desert") then
        return "#FFFACD"  -- lemon chiffon for dust
    elseif textLower:find("hail") then
        return "#E5F6FF"  -- pale for hail
    else
        return "#F8F8F8"  -- default light gray
    end
end


---------------------------------------------------------
  -- Ouriana (province)
-- 6. City-Specific Natural Disaster Risks
  { name="Bashkim",          climate="Cold Steppe" },
---------------------------------------------------------
  { name="Ourid",            climate="Cold Steppe" },
  -- Ourianian dependency
  { name="Tonar",            climate="Cold Steppe" },


local cityDisasterProfiles = {
  -- Caledonian dependencies (canonical list)
    ["Vaeringheim"]      = {"flood", "heatwave", "thunderstorm"},
  { name="Fanghorn",        climate="Subarctic" },
    ["Luminaria"]         = {"flood", "landslide"},
  { name="Eikbu",           climate="Subarctic" },
    ["Serena"]            = {"snowstorm", "landslide"},
  { name="Galvø",           climate="Subarctic" },
    ["Pyralis"]          = {"forest fire", "heatwave"},
  { name="Sårensby",        climate="Subpolar Oceanic" },
    ["Symphonara"]        = {"landslide", "flood"},
  { name="Slevik",          climate="Subpolar Oceanic" },
    ["Aurelia"]          = {"drought", "heatwave"},
  { name="Sjøsborg",         climate="Subpolar Oceanic" },
    ["Somniumpolis"]      = {"flood"},
  { name="Storesund",       climate="Subpolar Oceanic" },
    ["Nexa"]              = {"landslide"},
  { name="Notranskja",      climate="Subarctic" }, -- not listed in nation Climate table; set consistent with nearby Caledonian Dfc cities
    ["Lunalis Sancta"]    = {"flood"},
  { name="Kaledonija",       climate="Subarctic" },
    ["Sylvapolis"]        = {"flood"},
  { name="Skøda",            climate="Subarctic" },
    ["Saluria"]          = {"flood"},
  { name="Krlsgorod",       climate="Subarctic" },
    ["Aetherium"]         = {"snowstorm", "blizzard"},
  { name="Hamarrfell",      climate="Subarctic" },
    ["Ferrum Citadel"]    = {"dust storm", "heatwave"},
    ["Acheron"]           = {"flood", "landslide"},
    ["Erythros"]          = {"flood"},
    ["Catonis Atrium"]    = {"landslide"},
    ["Delphica"]          = {"flood"},
    ["Koinonía"]          = {"landslide"},
    ["Aureum"]            = {"drought", "heatwave"},
    ["Skýrophos"]        = {"landslide", "coastal storm"},
    ["Bjornopolis"]       = {"flood"},
    ["Aegirheim"]        = {"snowstorm", "blizzard"},
    ["Norsolyra"]        = {"flood", "dust storm"},
    ["Thorsalon"]        = {"coastal storm", "flood"},
    ["Pelagia"]          = {"dust storm", "drought"},
    ["Myrene"]            = {"flood"},
    ["Thyrea"]            = {"flood", "thunderstorm"},
    ["Ephyra"]           = {"snowstorm"},
    ["Halicarn"]          = {"drought", "landslide"},
    ["Keybir-Aviv"]      = {"flood", "heatwave"},
    ["Tel-Amin"]          = {"drought", "heatwave"},
    ["Diamandis"]        = {"drought", "heatwave"},
    ["Jogi"]              = {"flood"},
    ["Lewisburg"]        = {"flood", "landslide"},
    ["Thermosalem"]      = {"flood"},
    ["Akróstadium"]       = {"dust storm", "landslide"},
    ["Sufriya"]          = {"flood", "dust storm"},
    ["Lykopolis"]        = {"flood"}
}
}


---------------------------------------------------------
---------------------------------------------------------
-- 7. Disaster Advisory Logic
-- 10) Ops alert computation
---------------------------------------------------------
---------------------------------------------------------
local function computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastText)
  local level = 0
  local hazard = "No reports"
  local function bump() level = math.min(3, level + 1) end


local function getDisasterAdvisory(cityName, eventText, chanceOfRain, predictedRain, highC)
  precipProb = tonumber(precipProb) or 0
    local disasterList = cityDisasterProfiles[cityName] or {}
  windKmh = tonumber(windKmh) or 0
    local textLower = eventText:lower()
  visKm = tonumber(visKm) or 99
    local advisories = {}
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0


    for _, disasterType in ipairs(disasterList) do
  if impacts and (impacts.air ~= "OK" or impacts.sea ~= "OK" or impacts.ground ~= "OK") then bump() end
        if disasterType == "flood" then
  if impacts and (impacts.air == "No-Go" or impacts.sea == "Closed" or impacts.ground == "Hazard") then bump(); bump() end
            if chanceOfRain > 60 or textLower:find("heavy downpours") then
  if windKmh >= 30 or visKm <= 5 or precipProb >= 85 then bump() end
                table.insert(advisories, "Flood Advisory")
  if hiF >= 105 or loF <= 5 then bump() end
            end
 
        elseif disasterType == "heatwave" then
  if cond == "Storm" then hazard = "Severe storm / lightning risk"
            if highC >= 32 then
  elseif cond == "Rain" then hazard = "Heavy rain / localized flooding"
                table.insert(advisories, "Heatwave Warning")
  elseif cond == "Fog" then hazard = "Low visibility / navigation hazard"
            end
  elseif cond == "Blizzard" then hazard = "Blizzard / whiteout conditions"
        elseif disasterType == "thunderstorm" then
  elseif cond == "Snow" then hazard = "Snow/ice / hazardous travel"
            if textLower:find("thunder") or textLower:find("storm") then
  elseif cond == "Dust" then hazard = "Dust/sand reduction in visibility"
                table.insert(advisories, "Thunderstorm Alert")
  else
            end
    if hiF >= 100 then hazard = "Heat stress conditions"
        elseif disasterType == "snowstorm" then
    elseif loF <= 10 then hazard = "Extreme cold exposure risk"
            if textLower:find("snow") or textLower:find("sleet") then
    else hazard = "No reports" end
                table.insert(advisories, "Snowstorm Warning")
  end
            end
 
        elseif disasterType == "landslide" then
  local extras = {}
            if chanceOfRain > 50 or textLower:find("heavy rain") or textLower:find("thunderstorm") then
  local t = (forecastText or ""):lower()
                table.insert(advisories, "Landslide Risk")
  for _, item in ipairs(triggeredDisasters) do
            end
    for _, kw in ipairs(item.keywords or {}) do
        elseif disasterType == "forest fire" then
      if t:find(kw) then
            if textLower:find("hot") or chanceOfRain < 10 then
        local pHit = (level >= 2) and 0.35 or 0.10
                table.insert(advisories, "Forest Fire Risk")
         if rand01(nextInt) <= pHit then extras[#extras+1] = item.hazard end
            end
         break
        elseif disasterType == "drought" then
      end
            if chanceOfRain < 5 then
                table.insert(advisories, "Drought Alert")
            end
        elseif disasterType == "blizzard" then
            if textLower:find("snow") or textLower:find("flurries") then
                table.insert(advisories, "Blizzard Warning")
            end
        elseif disasterType == "dust storm" then
            if textLower:find("dust") then
                table.insert(advisories, "Dust Storm Advisory")
            end
         elseif disasterType == "coastal storm" then
            if textLower:find("storm") then
                table.insert(advisories, "Coastal Storm Alert")
            end
         end
     end
     end
  end


    if #advisories == 0 then
  local recommend = { hatch=false, missionary=false }
        return "No Advisory"
  if impacts and (impacts.sea ~= "OK" or impacts.air ~= "OK") then recommend.hatch = true end
     else
  if impacts and impacts.ground ~= "OK" then recommend.missionary = true end
        return table.concat(advisories, "; ")
  if hiF >= 100 or loF <= 10 then recommend.missionary = true end
    end
 
  return {
    level = level,
    label = alertLabel(level),
    hazard = hazard,
    impacts = impacts or {ground="OK", sea="OK", air="OK"},
     recommend = recommend,
    advisory = (#extras > 0) and table.concat(extras, "; ") or "No reports"
  }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 8. Random Weather Stats (wind direction + wind speed)
-- 11) Core forecast generator (deterministic)
---------------------------------------------------------
---------------------------------------------------------
local function makeForecastForCity(cityName, climateName, year, dayOfYear)
  local season = getSeasonName(dayOfYear)
  local nextInt = lcg(hash31(string.format("wx:%d:%03d:%s", year, dayOfYear, cityName)))


local windDirections = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
local seasonTbl =
  (cityForecastOverrides[cityName] and cityForecastOverrides[cityName][season])
  or (climateEvents[climateName] and climateEvents[climateName][season])


local function getRandomWeatherStats(climate, season)
local forecastStr = (seasonTbl and #seasonTbl > 0) and pickFromList(nextInt, seasonTbl) or "No data"
    local data = climateTemperature[climate] and climateTemperature[climate][season]
  local cond = conditionFromText(forecastStr)
    if not data then
 
        return {
  local tdata = climateTemperature[climateName] and climateTemperature[climateName][season]
            highC="N/A", lowC="N/A", humidity="N/A",
  if not tdata then
            chanceOfRain="N/A", predictedRain="N/A", windDir="N/A", windSpeed="N/A"
    -- safety fallback if a climate key is missing
        }
    tdata = climateTemperature["Oceanic"][season]
    end
  end
 
  local hiC = randInt(nextInt, tdata.hiMin, tdata.hiMax)
  local loC = randInt(nextInt, tdata.loMin, tdata.loMax)
  local hum = randInt(nextInt, tdata.humMin, tdata.humMax)
 
  local precipProb = randInt(nextInt, 0, 100)
  local fLower = (forecastStr or ""):lower()
  if fLower:find("rain") or fLower:find("drizzle") or fLower:find("downpour") or fLower:find("shower")
    or fLower:find("snow") or fLower:find("storm") or fLower:find("thunder") or fLower:find("sleet")
  then
    precipProb = randInt(nextInt, 70, 99)
  end
 
  local precipType = "None"
  if cond == "Rain" or cond == "Storm" then precipType = "Rain"
  elseif cond == "Snow" or cond == "Blizzard" then precipType = "Snow" end
 
  local windDir = windDirections[(nextInt() % #windDirections) + 1]
  local windKmh
  if cond == "Storm" then windKmh = randInt(nextInt, 22, 55)
  elseif cond == "Blizzard" then windKmh = randInt(nextInt, 20, 50)
  elseif cond == "Snow" then windKmh = randInt(nextInt, 10, 40)
  elseif cond == "Dust" then windKmh = randInt(nextInt, 15, 45)
  else windKmh = randInt(nextInt, 0, 28) end
  local gustKmh = math.min(90, windKmh + randInt(nextInt, 5, 30))


    local hiC = math.random(data.hiMin, data.hiMax)
  local cloud, visKm = computeCloudAndVis(nextInt, cond)
    local loC = math.random(data.loMin, data.loMax)
    local hum = math.random(data.humMin, data.humMax)


    -- chance of rain
  local hiF, loF = cToF(hiC), cToF(loC)
    local cRain = math.random(data.crMin, data.crMax)
  local feelsF = computeFeelsLikeF(hiF, hum, windKmh)
    local pRain = 0
    if cRain > 0 then
        pRain = math.random(data.rfMin, data.rfMax)
    end


    local wDir  = windDirections[math.random(#windDirections)]
  local impacts = computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
    -- e.g., wind speeds from 0–50 km/h. Adjust as you see fit:
  local alert = computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastStr)
    local wSpd  = math.random(0, 50)


    return {
  return {
        highC = hiC,
    name = cityName,
        lowC = loC,
    climate = climateName,
        humidity = hum,
    season = season,
        chanceOfRain = cRain,
    condition = cond,
        predictedRain = pRain,
    hiF = hiF, loF = loF, feelsF = feelsF,
        windDir = wDir,
    humidity = hum,
        windSpeed = wSpd
    precipType = precipType, precipProb = precipProb,
     }
    windDir = windDir, windKmh = windKmh, gustKmh = gustKmh,
    cloud = cloud, visKm = visKm,
    forecast = forecastStr,
     ops = alert
  }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 9. Celsius to Fahrenheit
-- 12) Exported Ops API
---------------------------------------------------------
---------------------------------------------------------
function p.getOpsData(cityName, year, dayOfYear)
  cityName = tostring(cityName or "")
  local y = tonumber(year)
  local d = tonumber(dayOfYear)
  if not y or not d then
    local di = getDateInfo(nil)
    y, d = di.year, di.dayOfYear
  end
  local climate = nil
  for _, e in ipairs(cityData) do
    if e.name == cityName then climate = e.climate break end
  end
  if not climate then return nil end
  local fc = makeForecastForCity(cityName, climate, y, d)
  if not fc then return nil end


local function cToF(c)
  return {
     return math.floor(c * 9/5 + 32 + 0.5)
    city = fc.name,
    climate = fc.climate,
    season = fc.season,
    condition = fc.condition,
    hiF = fc.hiF, loF = fc.loF, feelsF = fc.feelsF,
    humidity = fc.humidity,
    precipType = fc.precipType,
    precipProb = fc.precipProb,
    windDir = fc.windDir,
    windKmh = fc.windKmh,
    gustKmh = fc.gustKmh,
    cloud = fc.cloud,
    visKm = fc.visKm,
    impacts = fc.ops.impacts,
    alert = {
      level = fc.ops.level,
      label = fc.ops.label,
      hazard = fc.ops.hazard,
      recommend = fc.ops.recommend,
      advisory = fc.ops.advisory
    }
  }
end
 
function p.opsAlert(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local city = tostring(args.city or "")
  if city == "" then
     return "Error: city parameter required. Example: {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}"
  end
  local op = p.getOpsData(city, di.year, di.dayOfYear)
  if not op then return "Error: unknown city '" .. city .. "'." end
  local badge = spanBadge(op.alert.level, op.alert.label .. " – " .. (op.condition or ""))
  return string.format("%s<br/>%s<br/>%s", badge, formatImpact(op.impacts), op.alert.hazard or "No reports")
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 10. Full City List (No abbreviations)
-- 13) Forecast tables
---------------------------------------------------------
---------------------------------------------------------
function p.weatherForecast(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local dayOfYear, yearNumber = di.dayOfYear, di.year
  local seasonName = getSeasonName(dayOfYear)
  local out = {}
  table.insert(out, "== Daily Weather Forecast ==")
  table.insert(out, string.format("''(Day %d of Year %d PSSC, %s)''", dayOfYear, yearNumber, seasonName))
  table.insert(out, "")
  table.insert(out, '<div style="overflow-x:auto;">')
  table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
  table.insert(out, table.concat({
    "! City",
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind Dir",
    "! Wind (km/h)",
    "! Gust (km/h)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
    "! Natural Disaster Advisory"
  }, "\n"))


local cityData = {
  for _, entry in ipairs(cityData) do
     -- Major Cities in Bassaridia Vaeringheim
     local fc = makeForecastForCity(entry.name, entry.climate, yearNumber, dayOfYear)
    {city = "Vaeringheim",       climate = "Humid Subtropical"},
     if fc then
    {city = "Luminaria",        climate = "Humid Subtropical"},
      local cityCell = cityFlagMarkup(fc.name, 20) .. " " .. cityLinkMarkup(fc.name)
     {city = "Serena",            climate = "Subpolar Oceanic"},
      local opsBadge = spanBadge(fc.ops.level, fc.ops.label)
    {city = "Pyralis",           climate = "Humid Subtropical"},
      local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)
    {city = "Symphonara",        climate = "Oceanic"},
    {city = "Aurelia",          climate = "Humid Subtropical"},
    {city = "Somniumpolis",     climate = "Humid Subtropical"},
    {city = "Nexa",              climate = "Humid Subtropical"},
    {city = "Lunalis Sancta",    climate = "Oceanic"},
    {city = "Sylvapolis",        climate = "Humid Subtropical"},


    -- Minor Cities in Bassaridia Vaeringheim
      table.insert(out, "|-")
    {city = "Saluria",           climate = "Oceanic"},
      table.insert(out, string.format(
    {city = "Aetherium",        climate = "Subarctic"},
        "| %s\n| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
    {city = "Ferrum Citadel",   climate = "Hot Desert"},
         cityCell, fc.climate, fc.season, fc.condition,
    {city = "Acheron",           climate = "Cold Steppe"},
        fc.hiF, fc.loF, fc.feelsF, fc.humidity,
    {city = "Erythros",         climate = "Oceanic"},
        precipCell, fc.windDir, fc.windKmh, fc.gustKmh,
    {city = "Catonis Atrium",   climate = "Oceanic"},
        fc.cloud, fc.visKm,
    {city = "Delphica",          climate = "Oceanic"},
        formatImpact(fc.ops.impacts),
    {city = "Koinonía",         climate = "Oceanic"},
        opsBadge, fc.ops.hazard,
    {city = "Aureum",           climate = "Mediterranean (Hot Summer)"},
        fc.forecast,
        fc.ops.advisory
      ))
    end
  end


    -- Major Cities in New South Jangsong
  table.insert(out, "|}")
    {city = "Skýrophos",         climate = "Oceanic"},
  table.insert(out, "</div>")
    {city = "Bjornopolis",      climate = "Oceanic"},
  return table.concat(out, "\n")
    {city = "Aegirheim",         climate = "Subarctic"},
end
    {city = "Norsolyra",         climate = "Oceanic"},
    {city = "Thorsalon",        climate = "Oceanic"},


    -- Minor Cities in New South Jangsong
function p.weatherForCity(frame)
    {city = "Pelagia",          climate = "Hot Steppe"},
  local args = (frame and frame.args) or {}
    {city = "Myrene",            climate = "Oceanic"},
  local cityRequested = tostring(args.city or "")
    {city = "Thyrea",            climate = "Humid Subtropical"},
  if cityRequested == "" then
     {city = "Ephyra",            climate = "Subpolar Oceanic"},
     return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
    {city = "Halicarn",          climate = "Mediterranean (Hot Summer)"},
  end


    -- Major Cities in Haifan Bassaridia
  local di = getDateInfo(args)
    {city = "Keybir-Aviv",      climate = "Humid Subtropical"},
    {city = "Tel-Amin",          climate = "Mediterranean (Hot Summer)"},
    {city = "Diamandis",        climate = "Mediterranean (Hot Summer)"},
    {city = "Jogi",              climate = "Oceanic"},
    {city = "Lewisburg",        climate = "Humid Subtropical"},


    -- Minor Cities in Haifan Bassaridia
  local climate = nil
    {city = "Thermosalem",      climate = "Oceanic"},
  for _, e in ipairs(cityData) do
     {city = "Akróstadium",      climate = "Cold Steppe"},
     if e.name == cityRequested then climate = e.climate break end
    {city = "Sufriya",          climate = "Humid Subtropical"},
  end
     {city = "Lykopolis",        climate = "Oceanic"}
  if not climate then
}
     return "Error: City '" .. cityRequested .. "' not found in city registry."
  end


---------------------------------------------------------
  local fc = makeForecastForCity(cityRequested, climate, di.year, di.dayOfYear)
-- 11. Main Weather Forecast Table Function
  if not fc then
---------------------------------------------------------
    return "Error: Forecast unavailable for city '" .. cityRequested .. "'."
  end


function p.weatherForecast(frame)
  local opsBadge = spanBadge(fc.ops.level, fc.ops.label .. " – " .. fc.condition)
    local dateInfo  = getCurrentDateInfo()
  local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)
    local dayOfYear = dateInfo.dayOfYear
  local cityCell = cityFlagMarkup(fc.name, 20) .. " " .. cityLinkMarkup(fc.name)
    local yearNumber= dateInfo.psscYear


    local seasonName= getSeasonName(dayOfYear)
  local out = {}
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
  table.insert(out, table.concat({
    "! City",
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind (dir / km/h / gust)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
    "! Natural Disaster Advisory"
  }, "\n"))


    local output = {}
  table.insert(out, "|-")
    table.insert(output, "== Daily Weather Forecast ==\n")
  table.insert(out, string.format(
    table.insert(output, string.format(
    "| %s\n| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s / %d / %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
        "''(Day %d of Year %d PSSC, %s)''\n\n",
    cityCell, fc.climate, fc.season, fc.condition,
        dayOfYear, yearNumber, seasonName
    fc.hiF, fc.loF, fc.feelsF, fc.humidity,
     ))
    precipCell,
    fc.windDir, fc.windKmh, fc.gustKmh,
    fc.cloud, fc.visKm,
    formatImpact(fc.ops.impacts),
    opsBadge, fc.ops.hazard,
     fc.forecast,
    fc.ops.advisory
  ))


    -- Start table
  table.insert(out, "|}")
    table.insert(output, '{| class="wikitable sortable" style="width:100%; text-align:left;"\n')
  return table.concat(out, "\n")
    table.insert(output,
end
        "! City !! Climate !! Season !! High °C (°F) !! Low °C (°F) !! Humidity (%) !! Chance of Rain (%) !! Rainfall (mm) !! Wind Dir !! Wind Speed (km/h) !! Today's Weather !! Advisory\n"
    )


    for _, cityEntry in ipairs(cityData) do
---------------------------------------------------------
        local cityName    = cityEntry.city
-- 14) Stars for tonight (deterministic)
        local climateName = cityEntry.climate
---------------------------------------------------------
local observerLat = 38.0


        -- (A) Pick random event
local starChartData = {
        local climateTable = climateEvents[climateName]
  { name="Azos",      starClass="Host Star", lat=62.0 },
        local seasonTable  = climateTable and climateTable[seasonName]
  { name="Danaß",    starClass="Host Star", lat=54.0 },
        local forecastStr = "No data"
  { name="Aprobelle", starClass="Host Star", lat=46.0 },
        if seasonTable and #seasonTable > 0 then
  { name="Eos",      starClass="Host Star", lat=40.0 },
            local rndIndex = math.random(#seasonTable)
  { name="Thalassa", starClass="Host Star", lat=34.0 },
            forecastStr = seasonTable[rndIndex]
  { name="Cato",      starClass="Host Star", lat=28.0 },
        end
  { name="Noctis",    starClass="Host Star", lat=22.0 }
}


        -- (B) Color coding for event cell
function p.starsForTonight(frame)
        local rowColor = getEventColor(forecastStr)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local nextInt = lcg(hash31(string.format("stars:%d:%03d", di.year, di.dayOfYear)))


        -- (C) Random stats
  local function isVisibleAt630(lat)
        local stats = getRandomWeatherStats(climateName, seasonName)
    local offset = randInt(nextInt, 5, 55)
        local highC  = stats.highC
    return (lat < (observerLat + offset))
        local lowC  = stats.lowC
   end
        local hum    = stats.humidity
        local cRain  = stats.chanceOfRain
        local pRain  = stats.predictedRain
        local wDir  = stats.windDir
        local wSpd  = stats.windSpeed
        local highF  = (type(highC)=="number") and cToF(highC) or "N/A"
        local lowF   = (type(lowC)=="number")  and cToF(lowC)  or "N/A"


        -- (D) Natural Disaster Advisory
  local out = {}
        local advisory = getDisasterAdvisory(
  table.insert(out, "=== Northern Host Stars Visible at ~6:30 PM ===")
            cityName, forecastStr, cRain, pRain,
  table.insert(out, "")
            (type(highC)=="number" and highC or 0)
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
        )
  table.insert(out, "! Star !! Class !! Star-Lat !! Visible? !! Approx. Altitude")


        table.insert(output, "|-\n")
  for _, star in ipairs(starChartData) do
        table.insert(output, string.format(
    local yesNo, alt = "No", 0
            '| %s || %s || %s || %s (%s) || %s (%s) || %s || %s || %s || %s || %s || style="background-color:%s" | %s || %s\n',
    if isVisibleAt630(star.lat) then
            cityName, climateName, seasonName,
      yesNo = "Yes"
            tostring(highC), tostring(highF),
      alt = randInt(nextInt, 20, 80)
            tostring(lowC),  tostring(lowF),
            tostring(hum),
            tostring(cRain),
            tostring(pRain),
            wDir,
            tostring(wSpd),
            rowColor, forecastStr,
            advisory
        ))
     end
     end
    table.insert(out, "|-")
    table.insert(out, string.format("| %s || %s || %.1f°N || %s || %d°", star.name, star.starClass, star.lat, yesNo, alt))
  end


    table.insert(output, "|}\n")
  table.insert(out, "|}")
    return table.concat(output)
  return table.concat(out, "\n")
end
end


return p
return p

Latest revision as of 18:58, 3 April 2026

Documentation for this module may be created at Module:BassaridiaForecast/doc

---------------------------------------------------------
-- Module:BassaridiaForecast  (FULL CITY COVERAGE + CITY FLAGS)
--
-- Updates embedded:
--  • Includes EVERY city on the canonical list (54) from List_of_cities_in_Bassaridia_Vaeringheim,
--    plus two climate-table-only Caledonian entries (Skøda, Krlsgorod) for completeness.
--  • Adds a City Flag icon in the City column.
--  • Flag standard: if the city flag file can't be found, show the Imperial Trade Union flag.
--  • Deterministic forecast per (city, year, day) so other modules can read the same signal.
--  • Exposes getOpsData(city, year, dayOfYear) for War League / Hatch / Missionary modules.
--
-- Usage:
--   {{#invoke:BassaridiaForecast|weatherForecast}}
--   {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}
--   {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}
--   {{#invoke:BassaridiaForecast|starsForTonight}}
--
---------------------------------------------------------

local p = {}

---------------------------------------------------------
-- 0) Calendar integration (Module:BassaridianCalendar)
---------------------------------------------------------
local calOk, cal = pcall(require, "Module:BassaridianCalendar")
local DAYS_IN_YEAR = 183

local function getCurrentDateInfo_fallback()
  local startDate    = os.time({year=1999, month=8, day=6})
  local secondsInDay = 86400
  local currentTime  = os.time()
  local totalDays    = math.floor((currentTime - startDate) / secondsInDay)
  local psscYear  = math.floor(totalDays / DAYS_IN_YEAR)
  local dayOfYear = (totalDays % DAYS_IN_YEAR) + 1
  return { psscYear = psscYear, dayOfYear = dayOfYear }
end

local function parseCalendarString(s)
  s = tostring(s or "")
  local day, year = s:match("^(%d+)%s*,.-,%s*(%d+)%s*PSSC")
  if day and year then return tonumber(year), tonumber(day) end
  year = s:match("(%d+)%s*PSSC")
  day  = s:match("^(%d+)")
  if year and day then return tonumber(year), tonumber(day) end
  return nil, nil
end

local function getDateInfo(args)
  args = args or {}
  local yy = tonumber(args.year)
  local dd = tonumber(args.day)
  if yy and dd and dd >= 1 and dd <= DAYS_IN_YEAR then
    return { year = yy, dayOfYear = dd, source = "args" }
  end
  if calOk and cal and type(cal.getCurrentDate) == "function" then
    local raw = cal.getCurrentDate()
    local y, d = parseCalendarString(raw)
    if y and d and d >= 1 and d <= DAYS_IN_YEAR then
      return { year = y, dayOfYear = d, source = "calendar", raw = raw }
    end
  end
  local fb = getCurrentDateInfo_fallback()
  return { year = fb.psscYear, dayOfYear = fb.dayOfYear, source = "fallback" }
end

---------------------------------------------------------
-- 1) Season helper
---------------------------------------------------------
local function getSeasonName(dayOfYear)
  if dayOfYear <= 61 then return "Atosiel"
  elseif dayOfYear <= 122 then return "Thalassiel"
  else return "Opsitheiel" end
end

---------------------------------------------------------
-- 2) Deterministic RNG
---------------------------------------------------------
local function hash31(str)
  local h = 0
  str = tostring(str or "")
  for i = 1, #str do
    h = (h * 131 + str:byte(i)) % 2147483647
  end
  return h
end

local function lcg(seed)
  local s = seed % 2147483647
  if s <= 0 then s = s + 2147483646 end
  return function()
    s = (1103515245 * s + 12345) % 2147483647
    return s
  end
end

local function rand01(nextInt) return (nextInt() % 1000000) / 1000000 end
local function randInt(nextInt, a, b)
  a, b = tonumber(a) or 0, tonumber(b) or 0
  if b < a then a, b = b, a end
  return a + (nextInt() % (b - a + 1))
end

local function pickFromList(nextInt, list)
  if not list or #list == 0 then return nil end
  return list[(nextInt() % #list) + 1]
end

---------------------------------------------------------
-- 3) City flag logic (fallback to ITU flag if missing)
---------------------------------------------------------
local ITU_FLAG = "Imperial Trade Union flag.png"

local fileExistsCache = {}
local function fileExists(fileName)
  fileName = tostring(fileName or "")
  if fileName == "" then return false end
  if fileExistsCache[fileName] ~= nil then return fileExistsCache[fileName] end
  local t = mw.title.new("File:" .. fileName)
  local ok = (t and t.exists) or false
  fileExistsCache[fileName] = ok
  return ok
end

local function normalizeForFlag(name)
  -- Mirrors the lightweight normalization used in your other modules:
  -- remove spaces/hyphens/’ and normalize common accented Latin chars.
  name = tostring(name or "")
  name = name:gsub("[%s%-’]", "")
  name = name:gsub("[ÁáÀàÂâÄäÃãÅåĀā]", "A")
  name = name:gsub("[ÉéÈèÊêËëĒē]", "E")
  name = name:gsub("[ÍíÌìÎîÏïĪī]", "I")
  name = name:gsub("[ÓóÒòÔôÖöÕõØøŌō]", "O")
  name = name:gsub("[ÚúÙùÛûÜüŪū]", "U")
  name = name:gsub("[ÝýŸÿ]", "Y")
  name = name:gsub("[Ææ]", "AE")
  return name
end

local cityFlagOverrides = {
  ["Skýrophos"] = "SkyrophosFlag.png",
  ["Slevik"]    = "SlevikFlag.png.png",
  ["Sårensby"]  = "SårensbyFlag.png",
  ["Odiferia"]  = "OdiferiaFlag.png",
}

local cityFlagCache = {}
local function cityFlagMarkup(cityName, px)
  px = tonumber(px) or 20
  local key = tostring(cityName or "") .. "|" .. tostring(px)
  if cityFlagCache[key] then return cityFlagCache[key] end

  local candidate = cityFlagOverrides[cityName] or (normalizeForFlag(cityName) .. "Flag.png")
  local file = fileExists(candidate) and candidate or ITU_FLAG
  local mk = string.format("[[File:%s|%dpx]]", file, px)
  cityFlagCache[key] = mk
  return mk
end

local function cityLinkMarkup(cityName)
  -- For canonical cities: link to the list-of-cities anchors.
  -- For the two extra climate-table-only cities, link to their own page (if it exists) without anchor assumptions.
  if cityName == "Skøda" or cityName == "Krlsgorod" then
    return string.format("[[%s]]", cityName)
  end
  return string.format("[[List_of_cities_in_Bassaridia_Vaeringheim#%s|%s]]", cityName, cityName)
end

---------------------------------------------------------
-- 4) Weather helpers
---------------------------------------------------------
local function cToF(c)
  if type(c) == "number" then return math.floor(c * 9/5 + 32 + 0.5) end
  return c
end

local function conditionFromText(t)
  t = (t or ""):lower()
  if t:find("blizzard") then return "Blizzard" end
  if t:find("snow") or t:find("sleet") or t:find("flurr") or t:find("ice") then return "Snow" end
  if t:find("thunder") or t:find("severe") or t:find("downpour") or t:find("storm") then return "Storm" end
  if t:find("rain") or t:find("drizzle") or t:find("shower") then return "Rain" end
  if t:find("fog") or t:find("mist") or t:find("low visibility") then return "Fog" end
  if t:find("dust") or t:find("sandstorm") or t:find("blowing sand") then return "Dust" end
  if t:find("clear") or t:find("spotless") or t:find("sunny") then return "Clear" end
  if t:find("overcast") or t:find("cloud") or t:find("gloom") or t:find("haze") then return "Cloudy" end
  return "Fair"
end

local function heatIndexF(T, RH)
  return -42.379 + 2.04901523*T + 10.14333127*RH - 0.22475541*T*RH
         - 0.00683783*T*T - 0.05481717*RH*RH + 0.00122874*T*T*RH
         + 0.00085282*T*RH*RH - 0.00000199*T*T*RH*RH
end

local function windChillF(T, windMph)
  return 35.74 + 0.6215*T - 35.75*(windMph^0.16) + 0.4275*T*(windMph^0.16)
end

local function computeFeelsLikeF(hiF, hum, windKmh)
  local windMph = (tonumber(windKmh) or 0) * 0.621371
  hiF = tonumber(hiF) or 0
  hum = tonumber(hum) or 0
  if hiF >= 80 and hum >= 40 then
    return math.floor(heatIndexF(hiF, hum) + 0.5)
  end
  if hiF <= 50 and windMph >= 3 then
    return math.floor(windChillF(hiF, windMph) + 0.5)
  end
  return hiF
end

local function computeCloudAndVis(nextInt, cond)
  if cond == "Clear" then
    return randInt(nextInt, 0, 20), randInt(nextInt, 15, 30)
  elseif cond == "Cloudy" then
    return randInt(nextInt, 35, 95), randInt(nextInt, 10, 25)
  elseif cond == "Fog" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 6)
  elseif cond == "Rain" then
    return randInt(nextInt, 70, 100), randInt(nextInt, 4, 15)
  elseif cond == "Storm" then
    return randInt(nextInt, 85, 100), randInt(nextInt, 2, 10)
  elseif cond == "Snow" then
    return randInt(nextInt, 80, 100), randInt(nextInt, 2, 10)
  elseif cond == "Blizzard" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 5)
  elseif cond == "Dust" then
    return randInt(nextInt, 20, 80), randInt(nextInt, 2, 10)
  end
  return randInt(nextInt, 10, 60), randInt(nextInt, 10, 25)
end

local function computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local impacts = { ground="OK", sea="OK", air="OK" }
  precipProb = tonumber(precipProb) or 0
  windKmh = tonumber(windKmh) or 0
  visKm = tonumber(visKm) or 99
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0

  if visKm <= 1 or windKmh >= 55 or cond == "Blizzard" then
    impacts.air = "No-Go"
  elseif visKm <= 5 or windKmh >= 40 or cond == "Storm" then
    impacts.air = "Limited"
  end

  if windKmh >= 50 or (cond == "Storm" and precipProb >= 85) or cond == "Blizzard" then
    impacts.sea = "Closed"
  elseif windKmh >= 28 or cond == "Storm" or (cond == "Rain" and precipProb >= 80) then
    impacts.sea = "Restricted"
  end

  if (cond == "Snow" or cond == "Blizzard") and (loF <= 32 or visKm <= 3) then
    impacts.ground = "Hazard"
  elseif cond == "Storm" or (cond == "Rain" and precipProb >= 80) or visKm <= 5 then
    impacts.ground = "Slow"
  end

  if hiF >= 100 and impacts.ground == "OK" then impacts.ground = "Slow" end
  if loF <= 10 and impacts.ground == "OK" then impacts.ground = "Slow" end

  return impacts
end

---------------------------------------------------------
-- 5) Ops Alert badge helpers
---------------------------------------------------------
local function alertLabel(level)
  if level == 3 then return "Red" end
  if level == 2 then return "Orange" end
  if level == 1 then return "Yellow" end
  return "Green"
end

local function alertColor(level)
  if level == 3 then return "#f8d7da" end
  if level == 2 then return "#ffe5cc" end
  if level == 1 then return "#fff3cd" end
  return "#d4edda"
end

local function spanBadge(level, text)
  return string.format(
    "<span style='background:%s;padding:2px 6px;border:1px solid #ccc;border-radius:10px;white-space:nowrap;'>%s</span>",
    alertColor(level), text
  )
end

local function formatImpact(imp)
  imp = imp or {}
  return string.format("Gnd: %s; Sea: %s; Air: %s", imp.ground or "OK", imp.sea or "OK", imp.air or "OK")
end

---------------------------------------------------------
-- 6) Climate ranges (°C base; shown °F)
---------------------------------------------------------
local climateTemperature = {
  ["Humid Subtropical"] = {
    Atosiel     = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80 },
    Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90 },
    Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75 }
  },
  ["Oceanic"] = {
    Atosiel     = { hiMin=10, hiMax=17, loMin=5,  loMax=12, humMin=70, humMax=90 },
    Thalassiel  = { hiMin=14, hiMax=21, loMin=9,  loMax=14, humMin=65, humMax=85 },
    Opsitheiel  = { hiMin=5,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 }
  },
  ["Subpolar Oceanic"] = {
    Atosiel     = { hiMin=3,  hiMax=9,  loMin=-2, loMax=3,  humMin=70, humMax=95 },
    Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 },
    Opsitheiel  = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95 }
  },
  ["Mediterranean (Hot Summer)"] = {
    Atosiel     = { hiMin=15, hiMax=21, loMin=7,  loMax=12, humMin=40, humMax=60 },
    Thalassiel  = { hiMin=25, hiMax=35, loMin=15, loMax=20, humMin=30, humMax=50 },
    Opsitheiel  = { hiMin=12, hiMax=18, loMin=5,  loMax=10, humMin=45, humMax=65 }
  },
  ["Hot Desert"] = {
    Atosiel     = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30 },
    Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20 },
    Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25 }
  },
  ["Cold Steppe"] = {
    Atosiel     = { hiMin=10, hiMax=18, loMin=2,  loMax=8,  humMin=30, humMax=50 },
    Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45 },
    Opsitheiel  = { hiMin=0,  hiMax=7,  loMin=-6, loMax=0,  humMin=35, humMax=55 }
  },
  ["Hot Steppe"] = {
    Atosiel     = { hiMin=23, hiMax=30, loMin=12, loMax=18, humMin=20, humMax=40 },
    Thalassiel  = { hiMin=30, hiMax=40, loMin=20, loMax=25, humMin=15, humMax=35 },
    Opsitheiel  = { hiMin=25, hiMax=33, loMin=15, loMax=20, humMin=15, humMax=40 }
  },
  ["Subarctic"] = {
    Atosiel     = { hiMin=0,  hiMax=8,  loMin=-10,loMax=-1, humMin=50, humMax=80 },
    Thalassiel  = { hiMin=5,  hiMax=15, loMin=-1, loMax=5,  humMin=50, humMax=85 },
    Opsitheiel  = { hiMin=-5, hiMax=0,  loMin=-15,loMax=-5, humMin=60, humMax=90 }
  }
}

---------------------------------------------------------
-- 7) Flavor strings (still keyed by climate/season)
---------------------------------------------------------
local climateEvents = {
  ["Humid Subtropical"] = {
    Atosiel = {
      "Morning drizzle and warm afternoon sunshine",
      "Mild thunderstorm building by midday",
      "Patchy fog at dawn, clearing toward lunch",
      "Light rain with sunny breaks after noon",
      "Overcast for part of the day, mild temperatures",
      "Scattered clouds with a brief shower by dusk",
      "Warm breezes carrying faint floral scents",
      "Early morning seasonal storms and lake-driven floods may develop"
    },
    Thalassiel = {
      "Hot, steamy day with intense midday heat",
      "Tropical-like humidity, afternoon thunder possible",
      "Intermittent heavy downpours, muggy evenings",
      "High humidity and patchy thunderstorms late",
      "Heat advisory with only brief cooling at night",
      "Nighttime storms lingering into early morning",
      "Afternoon heavy rains could trigger river floods"
    },
    Opsitheiel = {
      "Warm daytime, gentle evening breezes",
      "Occasional rain, otherwise mild temperatures",
      "Fog at dawn, warm midday, pleasant night",
      "Evening drizzle with mild breezes",
      "Short-lived shower, then clearing skies",
      "Evening cooling may lead to localized mudslides on steep slopes"
    }
  },

  ["Oceanic"] = {
    Atosiel = {
      "Frequent light rain, cool breezes all day",
      "Foggy dawn, mild midmorning sunshine",
      "Short sunbreaks among passing showers",
      "Consistent drizzle, fresh winds from the sea",
      "Morning fog with coastal storms and marsh flooding possible"
    },
    Thalassiel = {
      "Light rain off and on, mild temperatures",
      "Overcast with comfortable breezes throughout",
      "Moist air with lingering fog near streams",
      "Intermittent showers may escalate into flash floods and coastal storms"
    },
    Opsitheiel = {
      "Overcast skies, cool drizzle through the day",
      "Low visibility in morning fog, slow clearing",
      "Heavier showers arriving late afternoon",
      "Evening drizzle may develop into coastal gales and shoreline flooding"
    }
  },

  ["Subpolar Oceanic"] = {
    Atosiel = {
      "Near-freezing dawn, cold drizzle by midday",
      "Overcast skies, mix of rain and sleet",
      "Snowmelt water raising local streams",
      "Cold drizzle combined with snowstorms and blizzards in higher areas"
    },
    Thalassiel = {
      "Cool, short days with periodic drizzle",
      "Short bursts of rain followed by fog",
      "Misty conditions could prompt snowstorms and river floods"
    },
    Opsitheiel = {
      "Steady cold rain mixing with wet snow",
      "Freezing drizzle at dawn, raw gusty winds",
      "Cloudy, damp day with potential sleet storms",
      "Freezing drizzle may trigger avalanches in higher passes"
    }
  },

  ["Mediterranean (Hot Summer)"] = {
    Atosiel = {
      "Mild temperatures, bright sunshine, dry air",
      "Sunny midday with a crisp, light wind",
      "Short drizzly spell, then mostly clear",
      "Bright skies overshadowed by potential heat advisory and droughts"
    },
    Thalassiel = {
      "Very hot midday sun, minimal clouds",
      "Heatwaves persisting, zero rain expected",
      "Intense midday sun might lead to severe heat waves and dust storms"
    },
    Opsitheiel = {
      "Cooler spells, brief showery intervals",
      "Sporadic light rainfall, refreshing breezes",
      "Cooling breezes could reduce temperatures, but localized brushfires and summer wildfires remain possible"
    }
  },

  ["Hot Desert"] = {
    Atosiel = {
      "Dry air, bright sunshine, no clouds in sight",
      "Gentle breezes raising light sand by midday",
      "Minor dust devil scouring the dunes",
      "Scorching afternoons may be accompanied by dust storms and occasional mirage-like shimmers"
    },
    Thalassiel = {
      "Blistering midday sun, risk of heat stroke",
      "Occasional dust storm cutting visibility briefly",
      "Extreme heat could combine with blowing sand to trigger potential sandstorms"
    },
    Opsitheiel = {
      "Warm, mostly sunny with cooler nights",
      "Occasional gust picking up desert grit",
      "Evening cooling might still see intense heat with possible dust devils on dry slopes"
    }
  },

  ["Cold Steppe"] = {
    Atosiel = {
      "Cool mornings, moderate midday warmth, breezy",
      "Light rain passing through grasslands midday",
      "Variable cloud cover, mild temperature swings",
      "Mild conditions could turn volatile with sudden flash floods and minor landslides"
    },
    Thalassiel = {
      "Warm day, potential for gusty thunderstorms",
      "Cloud buildup, short but intense showers possible",
      "Rolling thunder near dusk, rain squalls possible",
      "Afternoon heat may intensify, leading to thunderstorms, river floods, and gusty rain squalls"
    },
    Opsitheiel = {
      "Wind-driven chill under gray skies",
      "Possible light snowfall, especially overnight",
      "Sparse precipitation, dryness persists, cold air",
      "Chilly nights might bring frost and the risk of localized landslides on steep slopes"
    }
  },

  ["Hot Steppe"] = {
    Atosiel = {
      "Hot daytime sun, slight breeze, no rain",
      "Gusty wind with dust swirling near midday",
      "Scorching sun may lead to persistent dryness with a risk of brushfires and dust devils on dry slopes"
    },
    Thalassiel = {
      "Brutally hot midday sun, patchy dust storms",
      "Occasional swirling wind gusts, drifting dust",
      "Intense heat may trigger severe dust storms and a potential heat wave"
    },
    Opsitheiel = {
      "Hot days, moderate evenings, stable dryness",
      "Dust devils possible over parched plains",
      "Evening cooling might reduce temperatures, yet localized sandstorms and minor landslides remain a concern"
    }
  },

  ["Subarctic"] = {
    Atosiel = {
      "Snow melting, slushy paths, daytime sunshine",
      "Mixed precipitation, cold rain at lower altitudes",
      "Rapid thawing could spark river floods and snowstorms with heavy snowfall"
    },
    Thalassiel = {
      "Short, cool days, moderate sunshine at times",
      "Drizzle commonly, heavier rainfall occasionally",
      "Cool days may be interrupted by sudden blizzard-like conditions and heavy rain squalls"
    },
    Opsitheiel = {
      "Early snowfall returning, freezing ground rapidly",
      "Blizzard-like conditions with large snow accumulations",
      "Deep winter chill may result in persistent blizzards and avalanches in higher passes"
    }
  }
}

---------------------------------------------------------
-- 8) Triggered advisory keywords (optional extra advisory layer)
---------------------------------------------------------
local cityForecastOverrides = {
  ["Hamarrfell"] = {
    Atosiel = {
      "Late snowpack lingering on ridge roads, cold meltwater in the lower streets",
      "Fog in the ropeway approaches, sleet over the upper passes",
      "Sharp mountain wind with thawing snow on the lower approaches",
      "Cold drizzle below, fresh snowfall returning across the higher road cuttings"
    },
    Thalassiel = {
      "Short cool day with clear intervals, but fog holding in the upland cuts",
      "Mountain rain and low cloud drifting across the high road",
      "Cool highland air with sudden mist over the ropeway line",
      "Breezy conditions in the staging yards, cloud gathering over the passes"
    },
    Opsitheiel = {
      "Early snow returning to the ridgelines, freezing ground after dusk",
      "Blizzard conditions in the higher passes, severe wind over exposed roads",
      "Hard frost in the city with drifting snow on the upland approaches",
      "Snow squalls and low visibility across the ropeway and pass network"
    }
  }
}
local triggeredDisasters = {
  { keywords={"seasonal storms","lake%-driven floods"}, hazard="Seasonal storms and lake-driven floods" },
  { keywords={"river floods","heavy rains"}, hazard="River floods during heavy rains" },
  { keywords={"snowstorms","heavy snowfall"}, hazard="Snowstorms / heavy snowfall" },
  { keywords={"avalanch"}, hazard="Avalanche risk" },
  { keywords={"landslides","mudslides"}, hazard="Landslides / mudslides" },
  { keywords={"drought","persistent dryness","dry conditions"}, hazard="Drought / elevated fire risk" },
  { keywords={"brushfires","wildfires"}, hazard="Brushfire / wildfire risk" },
  { keywords={"dust storm","sandstorm","blowing sand"}, hazard="Dust / sandstorm risk" },
  { keywords={"fog","low visibility"}, hazard="Fog-related hazards" },
  { keywords={"severe storm","thunder"}, hazard="Severe storm / lightning risk" },
  { keywords={"storm surges","shoreline flooding","coastal gales"}, hazard="Coastal flooding / surge risk" }
}

local windDirections = { "N","NE","E","SE","S","SW","W","NW" }

---------------------------------------------------------
-- 9) City registry (ALL cities) + climate assignment
-- NOTE: Notranskja and Tonar are not explicitly in the nation Climate table;
--       they are assigned plausible climates consistent with their dependency regions.
---------------------------------------------------------
local cityData = {
  -- Core major
  { name="Vaeringheim",      climate="Humid Subtropical" },
  { name="Luminaria",        climate="Oceanic" },
  { name="Serena",           climate="Subpolar Oceanic" },
  { name="Pyralis",          climate="Oceanic" },
  { name="Symphonara",       climate="Oceanic" },
  { name="Aurelia",          climate="Mediterranean (Hot Summer)" },
  { name="Somniumpolis",     climate="Humid Subtropical" },
  { name="Nexa",             climate="Oceanic" },
  { name="Lunalis Sancta",   climate="Oceanic" },
  { name="Sylvapolis",       climate="Humid Subtropical" },

  -- Core minor
  { name="Saluria",          climate="Oceanic" },
  { name="Aetherium",        climate="Subarctic" },
  { name="Ferrum Citadel",   climate="Hot Desert" },
  { name="Acheron",          climate="Cold Steppe" },
  { name="Erythros",         climate="Oceanic" },
  { name="Catonis Atrium",   climate="Oceanic" },
  { name="Delphica",         climate="Oceanic" },
  { name="Koinonía",         climate="Oceanic" },
  { name="Aureum",           climate="Mediterranean (Hot Summer)" },

  -- Alperkin
  { name="The Alpazkigz",    climate="Cold Steppe" }, -- plausible high-steppe/plateau mix; adjust if you later codify it
  { name="Odiferia",         climate="Humid Subtropical" }, -- wetland belt; adjust if you later codify it

  -- New South Jangsong (major)
  { name="Skýrophos",        climate="Oceanic" },
  { name="Bjornopolis",      climate="Oceanic" },
  { name="Aegirheim",        climate="Subarctic" },
  { name="Norsolyra",        climate="Oceanic" },
  { name="Thorsalon",        climate="Oceanic" },

  -- New South Jangsong (minor)
  { name="Pelagia",          climate="Hot Steppe" },
  { name="Myrene",           climate="Oceanic" },
  { name="Thyrea",           climate="Humid Subtropical" },
  { name="Ephyra",           climate="Subpolar Oceanic" },
  { name="Halicarn",         climate="Mediterranean (Hot Summer)" },

  -- Haifan Bassaridia (major)
  { name="Keybir-Aviv",      climate="Humid Subtropical" },
  { name="Tel-Amin",         climate="Mediterranean (Hot Summer)" },
  { name="Diamandis",        climate="Mediterranean (Hot Summer)" },
  { name="Jogi",             climate="Oceanic" },
  { name="Lewisburg",        climate="Humid Subtropical" },

  -- Haifan Bassaridia (minor)
  { name="Thermosalem",      climate="Oceanic" },
  { name="Akróstadium",      climate="Cold Steppe" },
  { name="Sufriya",          climate="Humid Subtropical" },
  { name="Lykopolis",        climate="Oceanic" },

  -- Normark
  { name="Ardclach",         climate="Subarctic" },
  { name="Riddersborg",      climate="Subpolar Oceanic" },

  -- Ouriana (province)
  { name="Bashkim",          climate="Cold Steppe" },
  { name="Ourid",            climate="Cold Steppe" },
  -- Ourianian dependency
  { name="Tonar",            climate="Cold Steppe" },

  -- Caledonian dependencies (canonical list)
  { name="Fanghorn",         climate="Subarctic" },
  { name="Eikbu",            climate="Subarctic" },
  { name="Galvø",            climate="Subarctic" },
  { name="Sårensby",         climate="Subpolar Oceanic" },
  { name="Slevik",           climate="Subpolar Oceanic" },
  { name="Sjøsborg",         climate="Subpolar Oceanic" },
  { name="Storesund",        climate="Subpolar Oceanic" },
  { name="Notranskja",       climate="Subarctic" }, -- not listed in nation Climate table; set consistent with nearby Caledonian Dfc cities
  { name="Kaledonija",       climate="Subarctic" },
  { name="Skøda",            climate="Subarctic" },
  { name="Krlsgorod",        climate="Subarctic" },
  { name="Hamarrfell",       climate="Subarctic" },
}

---------------------------------------------------------
-- 10) Ops alert computation
---------------------------------------------------------
local function computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastText)
  local level = 0
  local hazard = "No reports"
  local function bump() level = math.min(3, level + 1) end

  precipProb = tonumber(precipProb) or 0
  windKmh = tonumber(windKmh) or 0
  visKm = tonumber(visKm) or 99
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0

  if impacts and (impacts.air ~= "OK" or impacts.sea ~= "OK" or impacts.ground ~= "OK") then bump() end
  if impacts and (impacts.air == "No-Go" or impacts.sea == "Closed" or impacts.ground == "Hazard") then bump(); bump() end
  if windKmh >= 30 or visKm <= 5 or precipProb >= 85 then bump() end
  if hiF >= 105 or loF <= 5 then bump() end

  if cond == "Storm" then hazard = "Severe storm / lightning risk"
  elseif cond == "Rain" then hazard = "Heavy rain / localized flooding"
  elseif cond == "Fog" then hazard = "Low visibility / navigation hazard"
  elseif cond == "Blizzard" then hazard = "Blizzard / whiteout conditions"
  elseif cond == "Snow" then hazard = "Snow/ice / hazardous travel"
  elseif cond == "Dust" then hazard = "Dust/sand reduction in visibility"
  else
    if hiF >= 100 then hazard = "Heat stress conditions"
    elseif loF <= 10 then hazard = "Extreme cold exposure risk"
    else hazard = "No reports" end
  end

  local extras = {}
  local t = (forecastText or ""):lower()
  for _, item in ipairs(triggeredDisasters) do
    for _, kw in ipairs(item.keywords or {}) do
      if t:find(kw) then
        local pHit = (level >= 2) and 0.35 or 0.10
        if rand01(nextInt) <= pHit then extras[#extras+1] = item.hazard end
        break
      end
    end
  end

  local recommend = { hatch=false, missionary=false }
  if impacts and (impacts.sea ~= "OK" or impacts.air ~= "OK") then recommend.hatch = true end
  if impacts and impacts.ground ~= "OK" then recommend.missionary = true end
  if hiF >= 100 or loF <= 10 then recommend.missionary = true end

  return {
    level = level,
    label = alertLabel(level),
    hazard = hazard,
    impacts = impacts or {ground="OK", sea="OK", air="OK"},
    recommend = recommend,
    advisory = (#extras > 0) and table.concat(extras, "; ") or "No reports"
  }
end

---------------------------------------------------------
-- 11) Core forecast generator (deterministic)
---------------------------------------------------------
local function makeForecastForCity(cityName, climateName, year, dayOfYear)
  local season = getSeasonName(dayOfYear)
  local nextInt = lcg(hash31(string.format("wx:%d:%03d:%s", year, dayOfYear, cityName)))

local seasonTbl =
  (cityForecastOverrides[cityName] and cityForecastOverrides[cityName][season])
  or (climateEvents[climateName] and climateEvents[climateName][season])

local forecastStr = (seasonTbl and #seasonTbl > 0) and pickFromList(nextInt, seasonTbl) or "No data"
  local cond = conditionFromText(forecastStr)

  local tdata = climateTemperature[climateName] and climateTemperature[climateName][season]
  if not tdata then
    -- safety fallback if a climate key is missing
    tdata = climateTemperature["Oceanic"][season]
  end

  local hiC = randInt(nextInt, tdata.hiMin, tdata.hiMax)
  local loC = randInt(nextInt, tdata.loMin, tdata.loMax)
  local hum = randInt(nextInt, tdata.humMin, tdata.humMax)

  local precipProb = randInt(nextInt, 0, 100)
  local fLower = (forecastStr or ""):lower()
  if fLower:find("rain") or fLower:find("drizzle") or fLower:find("downpour") or fLower:find("shower")
     or fLower:find("snow") or fLower:find("storm") or fLower:find("thunder") or fLower:find("sleet")
  then
    precipProb = randInt(nextInt, 70, 99)
  end

  local precipType = "None"
  if cond == "Rain" or cond == "Storm" then precipType = "Rain"
  elseif cond == "Snow" or cond == "Blizzard" then precipType = "Snow" end

  local windDir = windDirections[(nextInt() % #windDirections) + 1]
  local windKmh
  if cond == "Storm" then windKmh = randInt(nextInt, 22, 55)
  elseif cond == "Blizzard" then windKmh = randInt(nextInt, 20, 50)
  elseif cond == "Snow" then windKmh = randInt(nextInt, 10, 40)
  elseif cond == "Dust" then windKmh = randInt(nextInt, 15, 45)
  else windKmh = randInt(nextInt, 0, 28) end
  local gustKmh = math.min(90, windKmh + randInt(nextInt, 5, 30))

  local cloud, visKm = computeCloudAndVis(nextInt, cond)

  local hiF, loF = cToF(hiC), cToF(loC)
  local feelsF = computeFeelsLikeF(hiF, hum, windKmh)

  local impacts = computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local alert = computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastStr)

  return {
    name = cityName,
    climate = climateName,
    season = season,
    condition = cond,
    hiF = hiF, loF = loF, feelsF = feelsF,
    humidity = hum,
    precipType = precipType, precipProb = precipProb,
    windDir = windDir, windKmh = windKmh, gustKmh = gustKmh,
    cloud = cloud, visKm = visKm,
    forecast = forecastStr,
    ops = alert
  }
end

---------------------------------------------------------
-- 12) Exported Ops API
---------------------------------------------------------
function p.getOpsData(cityName, year, dayOfYear)
  cityName = tostring(cityName or "")
  local y = tonumber(year)
  local d = tonumber(dayOfYear)
  if not y or not d then
    local di = getDateInfo(nil)
    y, d = di.year, di.dayOfYear
  end

  local climate = nil
  for _, e in ipairs(cityData) do
    if e.name == cityName then climate = e.climate break end
  end
  if not climate then return nil end

  local fc = makeForecastForCity(cityName, climate, y, d)
  if not fc then return nil end

  return {
    city = fc.name,
    climate = fc.climate,
    season = fc.season,
    condition = fc.condition,
    hiF = fc.hiF, loF = fc.loF, feelsF = fc.feelsF,
    humidity = fc.humidity,
    precipType = fc.precipType,
    precipProb = fc.precipProb,
    windDir = fc.windDir,
    windKmh = fc.windKmh,
    gustKmh = fc.gustKmh,
    cloud = fc.cloud,
    visKm = fc.visKm,
    impacts = fc.ops.impacts,
    alert = {
      level = fc.ops.level,
      label = fc.ops.label,
      hazard = fc.ops.hazard,
      recommend = fc.ops.recommend,
      advisory = fc.ops.advisory
    }
  }
end

function p.opsAlert(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local city = tostring(args.city or "")
  if city == "" then
    return "Error: city parameter required. Example: {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}"
  end
  local op = p.getOpsData(city, di.year, di.dayOfYear)
  if not op then return "Error: unknown city '" .. city .. "'." end
  local badge = spanBadge(op.alert.level, op.alert.label .. " – " .. (op.condition or ""))
  return string.format("%s<br/>%s<br/>%s", badge, formatImpact(op.impacts), op.alert.hazard or "No reports")
end

---------------------------------------------------------
-- 13) Forecast tables
---------------------------------------------------------
function p.weatherForecast(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local dayOfYear, yearNumber = di.dayOfYear, di.year
  local seasonName = getSeasonName(dayOfYear)

  local out = {}
  table.insert(out, "== Daily Weather Forecast ==")
  table.insert(out, string.format("''(Day %d of Year %d PSSC, %s)''", dayOfYear, yearNumber, seasonName))
  table.insert(out, "")

  table.insert(out, '<div style="overflow-x:auto;">')
  table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
  table.insert(out, table.concat({
    "! City",
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind Dir",
    "! Wind (km/h)",
    "! Gust (km/h)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
    "! Natural Disaster Advisory"
  }, "\n"))

  for _, entry in ipairs(cityData) do
    local fc = makeForecastForCity(entry.name, entry.climate, yearNumber, dayOfYear)
    if fc then
      local cityCell = cityFlagMarkup(fc.name, 20) .. " " .. cityLinkMarkup(fc.name)
      local opsBadge = spanBadge(fc.ops.level, fc.ops.label)
      local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)

      table.insert(out, "|-")
      table.insert(out, string.format(
        "| %s\n| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
        cityCell, fc.climate, fc.season, fc.condition,
        fc.hiF, fc.loF, fc.feelsF, fc.humidity,
        precipCell, fc.windDir, fc.windKmh, fc.gustKmh,
        fc.cloud, fc.visKm,
        formatImpact(fc.ops.impacts),
        opsBadge, fc.ops.hazard,
        fc.forecast,
        fc.ops.advisory
      ))
    end
  end

  table.insert(out, "|}")
  table.insert(out, "</div>")
  return table.concat(out, "\n")
end

function p.weatherForCity(frame)
  local args = (frame and frame.args) or {}
  local cityRequested = tostring(args.city or "")
  if cityRequested == "" then
    return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
  end

  local di = getDateInfo(args)

  local climate = nil
  for _, e in ipairs(cityData) do
    if e.name == cityRequested then climate = e.climate break end
  end
  if not climate then
    return "Error: City '" .. cityRequested .. "' not found in city registry."
  end

  local fc = makeForecastForCity(cityRequested, climate, di.year, di.dayOfYear)
  if not fc then
    return "Error: Forecast unavailable for city '" .. cityRequested .. "'."
  end

  local opsBadge = spanBadge(fc.ops.level, fc.ops.label .. " – " .. fc.condition)
  local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)
  local cityCell = cityFlagMarkup(fc.name, 20) .. " " .. cityLinkMarkup(fc.name)

  local out = {}
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
  table.insert(out, table.concat({
    "! City",
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind (dir / km/h / gust)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
    "! Natural Disaster Advisory"
  }, "\n"))

  table.insert(out, "|-")
  table.insert(out, string.format(
    "| %s\n| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s / %d / %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
    cityCell, fc.climate, fc.season, fc.condition,
    fc.hiF, fc.loF, fc.feelsF, fc.humidity,
    precipCell,
    fc.windDir, fc.windKmh, fc.gustKmh,
    fc.cloud, fc.visKm,
    formatImpact(fc.ops.impacts),
    opsBadge, fc.ops.hazard,
    fc.forecast,
    fc.ops.advisory
  ))

  table.insert(out, "|}")
  return table.concat(out, "\n")
end

---------------------------------------------------------
-- 14) Stars for tonight (deterministic)
---------------------------------------------------------
local observerLat = 38.0

local starChartData = {
  { name="Azos",      starClass="Host Star", lat=62.0 },
  { name="Danaß",     starClass="Host Star", lat=54.0 },
  { name="Aprobelle", starClass="Host Star", lat=46.0 },
  { name="Eos",       starClass="Host Star", lat=40.0 },
  { name="Thalassa",  starClass="Host Star", lat=34.0 },
  { name="Cato",      starClass="Host Star", lat=28.0 },
  { name="Noctis",    starClass="Host Star", lat=22.0 }
}

function p.starsForTonight(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local nextInt = lcg(hash31(string.format("stars:%d:%03d", di.year, di.dayOfYear)))

  local function isVisibleAt630(lat)
    local offset = randInt(nextInt, 5, 55)
    return (lat < (observerLat + offset))
  end

  local out = {}
  table.insert(out, "=== Northern Host Stars Visible at ~6:30 PM ===")
  table.insert(out, "")
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
  table.insert(out, "! Star !! Class !! Star-Lat !! Visible? !! Approx. Altitude")

  for _, star in ipairs(starChartData) do
    local yesNo, alt = "No", 0
    if isVisibleAt630(star.lat) then
      yesNo = "Yes"
      alt = randInt(nextInt, 20, 80)
    end
    table.insert(out, "|-")
    table.insert(out, string.format("| %s || %s || %.1f°N || %s || %d°", star.name, star.starClass, star.lat, yesNo, alt))
  end

  table.insert(out, "|}")
  return table.concat(out, "\n")
end

return p