Module:BassaridiaForecast: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(13 intermediate revisions by the same user not shown)
Line 1: Line 1:
---------------------------------------------------------
---------------------------------------------------------
-- Module: BassaridiaForecast
-- Module:BassaridiaForecast (FULL CITY COVERAGE + CITY FLAGS)
-- Provides three functions:
--
-- 1) weatherForecast(frame) -> returns a single big table
-- Updates embedded:
--    for ALL cities (with daily-random weather).
-- • Includes EVERY city on the canonical list (54) from List_of_cities_in_Bassaridia_Vaeringheim,
-- 2) weatherForCity(frame) -> returns a single small table
--    plus two climate-table-only Caledonian entries (Skøda, Krlsgorod) for completeness.
--   for a SPECIFIC city (param "city").
--  • Adds a City Flag icon in the City column.
-- 3) starsForTonight(frame) -> returns a table of star
-- • Flag standard: if the city flag file can't be found, show the Imperial Trade Union flag.
--   "rise" and "set" times, based on season-based
--  • Deterministic forecast per (city, year, day) so other modules can read the same signal.
--   night length (very simplified).
-- • 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}}
--
---------------------------------------------------------
---------------------------------------------------------


Line 15: Line 21:


---------------------------------------------------------
---------------------------------------------------------
-- HELPER #1: Current Date Info for PSSC
-- 0) Calendar integration (Module:BassaridianCalendar)
-- Also used to create a daily random seed.
---------------------------------------------------------
---------------------------------------------------------
local function getCurrentDateInfo()
local calOk, cal = pcall(require, "Module:BassaridianCalendar")
    local startDate = os.time({year = 1999, month = 8, day = 6})
local DAYS_IN_YEAR = 183
    local secondsInDay = 86400
    local daysPerYear = 183


    local currentTime = os.time()
local function getCurrentDateInfo_fallback()
    local totalDaysElapsed = math.floor((currentTime - startDate) / secondsInDay)
  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 yearFraction = totalDaysElapsed / daysPerYear
local function parseCalendarString(s)
    local psscYear = math.floor(yearFraction)
  s = tostring(s or "")
    local dayOfYear = math.floor((yearFraction - psscYear) * daysPerYear) + 1
  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


     return {
local function getDateInfo(args)
        psscYear = psscYear,
  args = args or {}
        dayOfYear = dayOfYear
  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
end


---------------------------------------------------------
---------------------------------------------------------
-- HELPER #2: Determine Season
-- 1) Season helper
-- (Atosiel=1..61, Thalassiel=62..122, Opsitheiel=123..183)
---------------------------------------------------------
---------------------------------------------------------
local function getSeasonName(dayOfYear)
local function getSeasonName(dayOfYear)
    if dayOfYear <= 61 then
  if dayOfYear <= 61 then return "Atosiel"
        return "Atosiel"
  elseif dayOfYear <= 122 then return "Thalassiel"
    elseif dayOfYear <= 122 then
  else return "Opsitheiel" end
        return "Thalassiel"
    else
        return "Opsitheiel"
    end
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 3. climateTemperature (°C behind scenes, displayed °F)
-- 2) Deterministic RNG
---------------------------------------------------------
---------------------------------------------------------
local climateTemperature = {
local function hash31(str)
    ["Humid Subtropical"] = {
  local h = 0
        Atosiel    = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80 },
  str = tostring(str or "")
        Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90 },
  for i = 1, #str do
        Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75 }
     h = (h * 131 + str:byte(i)) % 2147483647
    },
  end
    ["Oceanic"] = {
  return h
        Atosiel    = { hiMin=10, hiMax=17, loMin=5,  loMax=12, humMin=70, humMax=90 },
end
        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 }
local function lcg(seed)
     },
  local s = seed % 2147483647
    ["Subpolar Oceanic"] = {
  if s <= 0 then s = s + 2147483646 end
        Atosiel    = { hiMin=3,  hiMax=9,  loMin=-2, loMax=3,  humMin=70, humMax=95 },
  return function()
        Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 },
     s = (1103515245 * s + 12345) % 2147483647
        Opsitheiel  = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95 }
     return s
    },
  end
    ["Mediterranean (Hot Summer)"] = {
end
        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 },
local function rand01(nextInt) return (nextInt() % 1000000) / 1000000 end
        Opsitheiel  = { hiMin=12, hiMax=18, loMin=5,  loMax=10, humMin=45, humMax=65 }
local function randInt(nextInt, a, b)
     },
  a, b = tonumber(a) or 0, tonumber(b) or 0
    ["Hot Desert"] = {
  if b < a then a, b = b, a end
        Atosiel     = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30 },
  return a + (nextInt() % (b - a + 1))
        Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20 },
end
        Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25 }
 
    },
local function pickFromList(nextInt, list)
    ["Cold Steppe"] = {
  if not list or #list == 0 then return nil end
        Atosiel    = { hiMin=10, hiMax=18, loMin=2,  loMax=8,  humMin=30, humMax=50 },
  return list[(nextInt() % #list) + 1]
        Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45 },
end
        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 }
    }
}


---------------------------------------------------------
---------------------------------------------------------
-- 4. Weather Events by Climate & Season
-- 3) City flag logic (fallback to ITU flag if missing)
--    (Unchanged from your original code)
---------------------------------------------------------
---------------------------------------------------------
local climateEvents = {
local ITU_FLAG = "Imperial Trade Union flag.png"
    -- [Truncated for brevity—include your full event lists here!]
 
    -- ...
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


---------------------------------------------------------
---------------------------------------------------------
-- 5. Disasters: random chance if text has keywords
-- 4) Weather helpers
---------------------------------------------------------
---------------------------------------------------------
local triggeredDisasters = {
local function cToF(c)
     { keywords = {"seasonal storms","lake%-driven floods"}, hazard = "Seasonal storms and lake-driven floods" },
  if type(c) == "number" then return math.floor(c * 9/5 + 32 + 0.5) end
     { keywords = {"river floods","heavy rains"}, hazard = "River floods during heavy rains" },
  return c
     { keywords = {"snowstorms","higher elevations"}, hazard = "Snowstorms at higher elevations" },
end
     { keywords = {"steam vent eruptions"}, hazard = "Occasional steam vent eruptions" },
 
     -- ... (include ALL from your table)
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


local function getAdvisory(forecastStr)
  if (cond == "Snow" or cond == "Blizzard") and (loF <= 32 or visKm <= 3) then
     local textLower = forecastStr:lower()
     impacts.ground = "Hazard"
     local extras = {}
  elseif cond == "Storm" or (cond == "Rain" and precipProb >= 80) or visKm <= 5 then
     impacts.ground = "Slow"
  end


    local randomChance = 0.40  -- 40% chance
  if hiF >= 100 and impacts.ground == "OK" then impacts.ground = "Slow" end
    for _, item in ipairs(triggeredDisasters) do
  if loF <= 10 and impacts.ground == "OK" then impacts.ground = "Slow" end
        for _, kw in ipairs(item.keywords) do
            if textLower:find(kw) then
                local roll = math.random()
                if roll <= randomChance then
                    table.insert(extras, item.hazard)
                end
                break
            end
        end
    end


    if #extras > 0 then
  return impacts
        return table.concat(extras, "; ")
    else
        return "No reports"
    end
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 6. Conversions and Random Stats (unchanged)
-- 5) Ops Alert badge helpers
---------------------------------------------------------
---------------------------------------------------------
local function cToF(c)
local function alertLabel(level)
    if type(c) == "number" then
  if level == 3 then return "Red" end
        return math.floor(c * 9/5 + 32 + 0.5)
  if level == 2 then return "Orange" end
    end
  if level == 1 then return "Yellow" end
    return c
  return "Green"
end
end


local windDirections = {"N","NE","E","SE","S","SW","W","NW"}
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 getRandomWeatherStats(climate, season)
local function spanBadge(level, text)
    local data = climateTemperature[climate] and climateTemperature[climate][season]
  return string.format(
     if not data then
     "<span style='background:%s;padding:2px 6px;border:1px solid #ccc;border-radius:10px;white-space:nowrap;'>%s</span>",
        return {
    alertColor(level), text
            high = "N/A",
  )
            low = "N/A",
end
            humidity = "N/A",
            chanceOfRain = "N/A",
            windDir = "N/A",
            windSpeed = "N/A"
        }
    end


    local hi  = math.random(data.hiMin, data.hiMax)
local function formatImpact(imp)
    local lo   = math.random(data.loMin, data.loMax)
   imp = imp or {}
    local hum  = math.random(data.humMin, data.humMax)
  return string.format("Gnd: %s; Sea: %s; Air: %s", imp.ground or "OK", imp.sea or "OK", imp.air or "OK")
    local cRain= math.random(0,100)
    local wDir = windDirections[math.random(#windDirections)]
    local wSpd = math.random(0,50)
 
    return {
        high        = hi,
        low        = lo,
        humidity    = hum,
        chanceOfRain= cRain,
        windDir    = wDir,
        windSpeed  = wSpd
    }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 7. All City Data (Now Linked to Wiki)
-- 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 }
  }
}
 
---------------------------------------------------------
---------------------------------------------------------
-- We'll assume the user wants a link to micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#CityName
-- 7) Flavor strings (still keyed by climate/season)
-- so we do e.g.: "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Vaeringheim Vaeringheim]"
---------------------------------------------------------
---------------------------------------------------------
local function cityLink(cityName)
local climateEvents = {
     -- We'll remove spaces or handle them if needed.
  ["Humid Subtropical"] = {
     -- If cityName has a space, replace with underscore, or just keep #CityName?
     Atosiel = {
     -- If the anchor is "cityName", we might need an anchor that matches.
      "Morning drizzle and warm afternoon sunshine",
    -- We'll assume your wiki anchor is "#Vaeringheim" etc.
      "Mild thunderstorm building by midday",
     local anchor = cityName:gsub(" ", "_")
      "Patchy fog at dawn, clearing toward lunch",
     local baseUrl = "https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#"
      "Light rain with sunny breaks after noon",
     return string.format("[[%s%s|%s]]", baseUrl, anchor, cityName)
      "Overcast for part of the day, mild temperatures",
end
      "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"
    }
  },


local cityData = {
  ["Subpolar Oceanic"] = {
     {city = "Vaeringheim",      climate = "Humid Subtropical"},
     Atosiel = {
    {city = "Luminaria",         climate = "Oceanic"},
      "Near-freezing dawn, cold drizzle by midday",
     {city = "Serena",           climate = "Subpolar Oceanic"},
       "Overcast skies, mix of rain and sleet",
    {city = "Pyralis",           climate = "Oceanic"},
      "Snowmelt water raising local streams",
     {city = "Symphonara",        climate = "Oceanic"},
      "Cold drizzle combined with snowstorms and blizzards in higher areas"
    {city = "Aurelia",           climate = "Mediterranean (Hot Summer)"},
    },
    {city = "Somniumpolis",     climate = "Humid Subtropical"},
     Thalassiel = {
    {city = "Nexa",              climate = "Oceanic"},
      "Cool, short days with periodic drizzle",
     {city = "Lunalis Sancta",    climate = "Oceanic"},
      "Short bursts of rain followed by fog",
    {city = "Sylvapolis",        climate = "Humid Subtropical"},
      "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"
     }
  },


    {city = "Saluria",          climate = "Oceanic"},
  ["Mediterranean (Hot Summer)"] = {
     {city = "Aetherium",         climate = "Subarctic"},
     Atosiel = {
    {city = "Ferrum Citadel",   climate = "Hot Desert"},
      "Mild temperatures, bright sunshine, dry air",
    {city = "Acheron",           climate = "Cold Steppe"},
      "Sunny midday with a crisp, light wind",
     {city = "Erythros",         climate = "Oceanic"},
      "Short drizzly spell, then mostly clear",
    {city = "Catonis Atrium",   climate = "Oceanic"},
      "Bright skies overshadowed by potential heat advisory and droughts"
     {city = "Delphica",         climate = "Oceanic"},
    },
    {city = "Koinonía",         climate = "Oceanic"},
     Thalassiel = {
    {city = "Aureum",           climate = "Mediterranean (Hot Summer)"},
      "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"
    }
  },


     {city = "Skýrophos",         climate = "Oceanic"},
  ["Hot Desert"] = {
    {city = "Bjornopolis",      climate = "Oceanic"},
     Atosiel = {
     {city = "Aegirheim",         climate = "Subarctic"},
      "Dry air, bright sunshine, no clouds in sight",
     {city = "Norsolyra",         climate = "Oceanic"},
      "Gentle breezes raising light sand by midday",
    {city = "Thorsalon",         climate = "Oceanic"},
      "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"
    }
  },


     {city = "Pelagia",           climate = "Hot Steppe"},
  ["Cold Steppe"] = {
     {city = "Myrene",           climate = "Oceanic"},
     Atosiel = {
    {city = "Thyrea",           climate = "Humid Subtropical"},
      "Cool mornings, moderate midday warmth, breezy",
     {city = "Ephyra",           climate = "Subpolar Oceanic"},
      "Light rain passing through grasslands midday",
    {city = "Halicarn",         climate = "Mediterranean (Hot Summer)"},
      "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"
    }
  },


     {city = "Keybir-Aviv",      climate = "Humid Subtropical"},
  ["Hot Steppe"] = {
     {city = "Tel-Amin",         climate = "Mediterranean (Hot Summer)"},
     Atosiel = {
    {city = "Diamandis",         climate = "Mediterranean (Hot Summer)"},
      "Hot daytime sun, slight breeze, no rain",
     {city = "Jogi",             climate = "Oceanic"},
      "Gusty wind with dust swirling near midday",
    {city = "Lewisburg",         climate = "Humid Subtropical"},
       "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"
    }
  },


     {city = "Thermosalem",      climate = "Oceanic"},
  ["Subarctic"] = {
     {city = "Akróstadium",      climate = "Cold Steppe"},
     Atosiel = {
     {city = "Sufriya",           climate = "Humid Subtropical"},
      "Snow melting, slushy paths, daytime sunshine",
    {city = "Lykopolis",         climate = "Oceanic"}
      "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. Color-coded cell background
-- 8) Triggered advisory keywords (optional extra advisory layer)
---------------------------------------------------------
---------------------------------------------------------
local function getEventColor(eventText)
local cityForecastOverrides = {
     local textLower = eventText:lower()
  ["Hamarrfell"] = {
     if textLower:find("thunder") or textLower:find("storm") then
     Atosiel = {
        return "#FFD2D2"
      "Late snowpack lingering on ridge roads, cold meltwater in the lower streets",
     elseif textLower:find("snow") or textLower:find("sleet") or textLower:find("flurries") then
      "Fog in the ropeway approaches, sleet over the upper passes",
        return "#D2ECFF"
      "Sharp mountain wind with thawing snow on the lower approaches",
     elseif textLower:find("rain") or textLower:find("drizzle") or textLower:find("downpour") then
      "Cold drizzle below, fresh snowfall returning across the higher road cuttings"
        return "#D2DFFF"
     },
    elseif textLower:find("dust") or textLower:find("desert") then
    Thalassiel = {
        return "#FFFACD"
      "Short cool day with clear intervals, but fog holding in the upland cuts",
    else
      "Mountain rain and low cloud drifting across the high road",
        return "#F8F8F8"
      "Cool highland air with sudden mist over the ropeway line",
    end
      "Breezy conditions in the staging yards, cloud gathering over the passes"
end
     },
    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" }


---------------------------------------------------------
---------------------------------------------------------
-- 9A: ALL-CITIES FORECAST FUNCTION
-- 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.
---------------------------------------------------------
---------------------------------------------------------
function p.weatherForecast(frame)
local cityData = {
     local date = os.date("*t")
  -- Core major
    local dailySeed = (date.year * 1000) + date.yday
  { name="Vaeringheim",      climate="Humid Subtropical" },
    math.randomseed(dailySeed)
  { 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" },


    local dateInfo   = getCurrentDateInfo()
   -- Haifan Bassaridia (minor)
    local dayOfYear  = dateInfo.dayOfYear
  { name="Thermosalem",      climate="Oceanic" },
    local yearNumber = dateInfo.psscYear
  { name="Akróstadium",      climate="Cold Steppe" },
    local seasonName = getSeasonName(dayOfYear)
  { name="Sufriya",          climate="Humid Subtropical" },
  { name="Lykopolis",        climate="Oceanic" },


    local out = {}
  -- Normark
    table.insert(out, "== Daily Weather Forecast ==\n")
  { name="Ardclach",        climate="Subarctic" },
    table.insert(out,
  { name="Riddersborg",     climate="Subpolar Oceanic" },
         string.format("''(Day %d of Year %d PSSC, %s)''\n\n",
            dayOfYear, yearNumber, seasonName
        )
    )


    table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"\n')
  -- Ouriana (province)
    table.insert(out, [[
  { name="Bashkim",          climate="Cold Steppe" },
! City
  { name="Ourid",            climate="Cold Steppe" },
! Climate
  -- Ourianian dependency
! Season
  { name="Tonar",            climate="Cold Steppe" },
! High °F
! Low °F
! Humidity (%)
! Chance of Rain (%)
! Wind Direction
! Wind Speed (km/h)
! Today's Weather
! Natural Disaster Advisory
]])


    for _, entry in ipairs(cityData) do
  -- Caledonian dependencies (canonical list)
         local cityName    = entry.city
  { name="Fanghorn",        climate="Subarctic" },
         local climateName = entry.climate
  { 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" },
}


        local climateTbl = climateEvents[climateName]
---------------------------------------------------------
        local dateInfo  = getCurrentDateInfo()
-- 10) Ops alert computation
        local dayOfYear  = dateInfo.dayOfYear
---------------------------------------------------------
        local seasonName = getSeasonName(dayOfYear)
local function computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastText)
        local seasonTbl  = climateTbl and climateTbl[seasonName]
  local level = 0
  local hazard = "No reports"
  local function bump() level = math.min(3, level + 1) end


        local fStr      = "No data"
  precipProb = tonumber(precipProb) or 0
        if seasonTbl and #seasonTbl > 0 then
  windKmh = tonumber(windKmh) or 0
            local idx = math.random(#seasonTbl)
  visKm = tonumber(visKm) or 99
            fStr = seasonTbl[idx]
  hiF = tonumber(hiF) or 0
        end
  loF = tonumber(loF) or 0


        local rowColor = getEventColor(fStr)
  if impacts and (impacts.air ~= "OK" or impacts.sea ~= "OK" or impacts.ground ~= "OK") then bump() end
        local stats = getRandomWeatherStats(climateName, seasonName)
  if impacts and (impacts.air == "No-Go" or impacts.sea == "Closed" or impacts.ground == "Hazard") then bump(); bump() end
        local hiF = cToF(stats.high)
  if windKmh >= 30 or visKm <= 5 or precipProb >= 85 then bump() end
        local loF = cToF(stats.low)
  if hiF >= 105 or loF <= 5 then bump() end
        local hum = stats.humidity
        local cRain = stats.chanceOfRain
        local wDir = stats.windDir
        local wSpd = stats.windSpeed


        local advisory = getAdvisory(fStr)
  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


        table.insert(out, "|-\n")
  local extras = {}
        table.insert(out, string.format(
  local t = (forecastText or ""):lower()
[[
  for _, item in ipairs(triggeredDisasters) do
| %s
    for _, kw in ipairs(item.keywords or {}) do
| %s
      if t:find(kw) then
| %s
        local pHit = (level >= 2) and 0.35 or 0.10
| %d
        if rand01(nextInt) <= pHit then extras[#extras+1] = item.hazard end
| %d
        break
| %d
      end
| %d
| %s
| %d
| style="background-color:%s" | %s
| %s
]],
            cityLink(cityName),  -- linked version
            climateName,
            seasonName,
            hiF,
            loF,
            hum,
            cRain,
            wDir,
            wSpd,
            rowColor,
            fStr,
            advisory
        ))
     end
     end
  end


     table.insert(out, "|}\n")
  local recommend = { hatch=false, missionary=false }
     return table.concat(out)
  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
end


---------------------------------------------------------
---------------------------------------------------------
-- 9B: SINGLE-CITY FORECAST
-- 11) Core forecast generator (deterministic)
---------------------------------------------------------
---------------------------------------------------------
function p.weatherForCity(frame)
local function makeForecastForCity(cityName, climateName, year, dayOfYear)
    local cityRequested = frame.args.city
  local season = getSeasonName(dayOfYear)
    if not cityRequested or cityRequested == "" then
  local nextInt = lcg(hash31(string.format("wx:%d:%03d:%s", year, dayOfYear, cityName)))
        return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
    end


    local date = os.date("*t")
local seasonTbl =
    local dailySeed = (date.year * 1000) + date.yday
  (cityForecastOverrides[cityName] and cityForecastOverrides[cityName][season])
    math.randomseed(dailySeed)
  or (climateEvents[climateName] and climateEvents[climateName][season])


    local dateInfo  = getCurrentDateInfo()
local forecastStr = (seasonTbl and #seasonTbl > 0) and pickFromList(nextInt, seasonTbl) or "No data"
    local dayOfYear  = dateInfo.dayOfYear
  local cond = conditionFromText(forecastStr)
    local yearNumber = dateInfo.psscYear
    local seasonName = getSeasonName(dayOfYear)


    local foundEntry = nil
  local tdata = climateTemperature[climateName] and climateTemperature[climateName][season]
     for _, entry in ipairs(cityData) do
  if not tdata then
        if entry.city == cityRequested then
     -- safety fallback if a climate key is missing
            foundEntry = entry
    tdata = climateTemperature["Oceanic"][season]
            break
  end
        end
    end


    if not foundEntry then
  local hiC = randInt(nextInt, tdata.hiMin, tdata.hiMax)
        return "Error: City '" .. cityRequested .. "' not found in cityData."
  local loC = randInt(nextInt, tdata.loMin, tdata.loMax)
    end
  local hum = randInt(nextInt, tdata.humMin, tdata.humMax)


    local cityName    = foundEntry.city
  local precipProb = randInt(nextInt, 0, 100)
     local climateName = foundEntry.climate
  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 climateTbl = climateEvents[climateName]
  local precipType = "None"
    local seasonTbl  = climateTbl and climateTbl[seasonName]
  if cond == "Rain" or cond == "Storm" then precipType = "Rain"
    local fStr      = "No data"
  elseif cond == "Snow" or cond == "Blizzard" then precipType = "Snow" end
    if seasonTbl and #seasonTbl > 0 then
        local idx = math.random(#seasonTbl)
        fStr = seasonTbl[idx]
    end


    local rowColor = getEventColor(fStr)
  local windDir = windDirections[(nextInt() % #windDirections) + 1]
    local stats  = getRandomWeatherStats(climateName, seasonName)
  local windKmh
    local hiF    = cToF(stats.high)
  if cond == "Storm" then windKmh = randInt(nextInt, 22, 55)
    local loF    = cToF(stats.low)
  elseif cond == "Blizzard" then windKmh = randInt(nextInt, 20, 50)
    local hum    = stats.humidity
  elseif cond == "Snow" then windKmh = randInt(nextInt, 10, 40)
    local cRain  = stats.chanceOfRain
  elseif cond == "Dust" then windKmh = randInt(nextInt, 15, 45)
    local wDir   = stats.windDir
   else windKmh = randInt(nextInt, 0, 28) end
    local wSpd  = stats.windSpeed
  local gustKmh = math.min(90, windKmh + randInt(nextInt, 5, 30))
    local advisory = getAdvisory(fStr)


    local out = {}
  local cloud, visKm = computeCloudAndVis(nextInt, cond)
    table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n')
    table.insert(out, [[
! Climate
! Season
! High °F
! Low °F
! Humidity (%)
! Chance of Rain (%)
! Wind Dir
! Wind Speed (km/h)
! style="width:20em;" | Today's Weather
! Natural Disaster Advisory
]])
    table.insert(out, "|-\n")


    local row = string.format(
  local hiF, loF = cToF(hiC), cToF(loC)
[[
  local feelsF = computeFeelsLikeF(hiF, hum, windKmh)
| %s
| %s
| %d
| %d
| %d
| %d
| %s
| %d
| style="background-color:%s" | %s
| %s
]],
        climateName,
        seasonName,
        hiF,
        loF,
        hum,
        cRain,
        wDir,
        wSpd,
        rowColor,
        fStr,
        advisory
    )
    table.insert(out, row)


    table.insert(out, "|}\n\n")
  local impacts = computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local alert = computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastStr)


     return table.concat(out)
  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
end


---------------------------------------------------------
---------------------------------------------------------
-- 10. Host Stars Logic (Top Image) – "starsForTonight"
-- 12) Exported Ops API
---------------------------------------------------------
---------------------------------------------------------
-- Step A: star data (northern hemisphere from your "top image")
function p.getOpsData(cityName, year, dayOfYear)
local starDataNorth = {
  cityName = tostring(cityName or "")
    {name="Agave",   starClass="M", latitude=29.5},
   local y = tonumber(year)
    {name="Amaäz",    starClass="K", latitude=31},
  local d = tonumber(dayOfYear)
    {name="Amáenu",   starClass="G", latitude=45},
  if not y or not d then
    {name="Amap",    starClass="A", latitude=55},
     local di = getDateInfo(nil)
    {name="Amazä",    starClass="F", latitude=39},
     y, d = di.year, di.dayOfYear
     {name="Atämios",  starClass="F", latitude=56},
  end
     {name="Aprobelle",starClass="A", latitude=59},
 
    {name="Azos",    starClass="Galaxy", latitude=60},  -- approx
  local climate = nil
    {name="Bebeakaus",starClass="K", latitude=30.5},
  for _, e in ipairs(cityData) do
    {name="Bulhanu",  starClass="A", latitude=40},
     if e.name == cityName then climate = e.climate break end
    {name="Crösacío", starClass="K", latitude=17},
  end
    {name="Danaß",    starClass="A", latitude=67},
  if not climate then return nil end
    {name="Dilëtaz",  starClass="G", latitude=36},
 
     {name="Dranamos", starClass="A", latitude=58},
  local fc = makeForecastForCity(cityName, climate, y, d)
    {name="Gaht",    starClass="G", latitude=29},
   if not fc then return nil end
    {name="Häpi",    starClass="F", latitude=78},
    {name="Hazaméos", starClass="A", latitude=72},
    {name="Liléigos", starClass="F", latitude=18},
    {name="Nyama",    starClass="F", latitude=64},
    {name="Ocananus", starClass="F", latitude=60.5},
    {name="Orebele",  starClass="F", latitude=22},
    {name="Osiríos",  starClass="G", latitude=53},
    {name="Pythe",    starClass="A", latitude=45},
    {name="Sanashalo",starClass="G", latitude=27},
    {name="Tä",      starClass="F", latitude=39.5},
    {name="Vï",      starClass="A", latitude=46},
    {name="Wedíos",   starClass="A", latitude=54},
}


-- Step B: how many hours of darkness in each season (rough)
  return {
local seasonNightLength = {
    city = fc.name,
     Atosiel    = 10, -- 10 hours darkness
    climate = fc.climate,
     Thalassiel = 12, -- 12 hours darkness
    season = fc.season,
     Opsitheiel = 14, -- 14 hours darkness
    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


-- Helper: convert decimal hours to "HH:MM" string
function p.opsAlert(frame)
local function hourToHM(decimalHour)
  local args = (frame and frame.args) or {}
    local hh = math.floor(decimalHour)
  local di = getDateInfo(args)
     local mm = math.floor((decimalHour - hh)*60 + 0.5)
  local city = tostring(args.city or "")
    -- handle wrap-around if hh >= 24
  if city == "" then
    hh = hh % 24
     return "Error: city parameter required. Example: {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}"
    return string.format("%02d:%02d", hh, mm)
  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


function p.starsForTonight(frame)
---------------------------------------------------------
    local date = os.date("*t")
-- 13) Forecast tables
    local dailySeed = (date.year * 1000) + date.yday
---------------------------------------------------------
    math.randomseed(dailySeed)
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, "")


    local dateInfo   = getCurrentDateInfo()
   table.insert(out, '<div style="overflow-x:auto;">')
     local seasonName = getSeasonName(dateInfo.dayOfYear)
  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 nightLen = seasonNightLength[seasonName] or 12
  for _, entry in ipairs(cityData) do
     local nightStart = 19.0  -- 19:00 local
     local fc = makeForecastForCity(entry.name, entry.climate, yearNumber, dayOfYear)
    local queryTime = 18.5    -- 18:30 local time
     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)


    local out = {}
      table.insert(out, "|-")
    table.insert(out, "=== Host Stars Visible Tonight (Top Image) ===\n")
      table.insert(out, string.format(
    table.insert(out, string.format("''Time ~6:30 PM, Bassaridia Vaeringheim – Season: %s''\n\n", seasonName))
        "| %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, '{| class="wikitable" style="width:100%; text-align:left;"\n')
  table.insert(out, "|}")
    table.insert(out, "! Star Name || Star Class || Approx. Latitude || Rise Time || Set Time || Visible at 18:30?\n")
  table.insert(out, "</div>")
  return table.concat(out, "\n")
end


    for _, star in ipairs(starDataNorth) do
function p.weatherForCity(frame)
        -- pick a random time for starRise
  local args = (frame and frame.args) or {}
        local starRise = nightStart + math.random() * (nightLen * 0.3)
  local cityRequested = tostring(args.city or "")
        local starSet  = starRise + math.random() * (nightLen * 0.6 + 1.0)
  if cityRequested == "" then
    return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
  end


        -- clamp starSet so it doesn't exceed next morning
  local di = getDateInfo(args)
        local maxEnd = nightStart + nightLen + 2.0  -- e.g. by 9:00 next day
        if starSet > maxEnd then
            starSet = maxEnd
        end


        local riseStr = hourToHM(starRise)
  local climate = nil
        local setStr  = hourToHM(starSet)
  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 isVis
  local fc = makeForecastForCity(cityRequested, climate, di.year, di.dayOfYear)
        if (starRise <= queryTime) and (queryTime < starSet) then
  if not fc then
            isVis = "Yes"
    return "Error: Forecast unavailable for city '" .. cityRequested .. "'."
        else
  end
            isVis = "No"
        end


        table.insert(out, "|-\n")
  local opsBadge = spanBadge(fc.ops.level, fc.ops.label .. " – " .. fc.condition)
        table.insert(out, string.format(
  local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)
            "| %s || %s || %.1f°N || %s || %s || %s\n",
  local cityCell = cityFlagMarkup(fc.name, 20) .. " " .. cityLinkMarkup(fc.name)
            star.name, star.starClass, star.latitude, riseStr, setStr, isVis
 
        ))
  local out = {}
     end
  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, "|}\n\n")
  table.insert(out, "|}")
    return table.concat(out)
  return table.concat(out, "\n")
end
end


---------------------------------------------------------
---------------------------------------------------------
-- Return the module table
-- 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
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