Module:BassaridiaForecast: Difference between revisions
From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs) No edit summary |
NewZimiaGov (talk | contribs) No edit summary |
||
| (16 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- Module: BassaridiaForecast | -- 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}} | |||
-- | |||
--------------------------------------------------------- | --------------------------------------------------------- | ||
| Line 12: | Line 21: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 0) Calendar integration (Module:BassaridianCalendar) | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local | 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 | |||
return { | 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 | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 1) Season helper | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local function getSeasonName(dayOfYear) | local function getSeasonName(dayOfYear) | ||
if dayOfYear <= 61 then return "Atosiel" | |||
elseif dayOfYear <= 122 then return "Thalassiel" | |||
else return "Opsitheiel" end | |||
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 | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 6) Climate ranges (°C base; shown °F) | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local climateTemperature = { | 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" | |||
}, | }, | ||
["Hot | 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 triggeredDisasters = { | 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 | local cityData = { | ||
if | -- 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" }, | |||
-- EXTRA (present in nation Climate table but not on canonical city list) | |||
{ name="Skøda", climate="Subarctic" }, | |||
{ name="Krlsgorod", climate="Subarctic" }, | |||
} | |||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 10) Ops alert computation | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local function | 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 | |||
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 | 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 | ||
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 | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 11) Core forecast generator (deterministic) | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local | local function makeForecastForCity(cityName, climateName, year, dayOfYear) | ||
local | local season = getSeasonName(dayOfYear) | ||
local nextInt = lcg(hash31(string.format("wx:%d:%03d:%s", year, dayOfYear, cityName))) | |||
[" | |||
local seasonTbl = 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. | 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", | |||
| %s | cityCell, fc.climate, fc.season, fc.condition, | ||
| %s | fc.hiF, fc.loF, fc.feelsF, fc.humidity, | ||
| %d | precipCell, fc.windDir, fc.windKmh, fc.gustKmh, | ||
| %d | fc.cloud, fc.visKm, | ||
| %d | formatImpact(fc.ops.impacts), | ||
| %d | opsBadge, fc.ops.hazard, | ||
| %s | fc.forecast, | ||
| %d | fc.ops.advisory | ||
| | )) | ||
| %s | |||
end | end | ||
end | |||
table.insert(out, "|}") | |||
table.insert(out, "</div>") | |||
return table.concat(out, "\n") | |||
end | end | ||
function p.weatherForCity(frame) | 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") | |||
| style=" | |||
table.insert(out, "| | 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 | end | ||
return p | return p | ||
Latest revision as of 03:21, 2 January 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 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" },
-- EXTRA (present in nation Climate table but not on canonical city list)
{ name="Skøda", climate="Subarctic" },
{ name="Krlsgorod", 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 = 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