Module:CultRelationsDailyBVR: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
Line 615: Line 615:
city = trim(city)
city = trim(city)
return string.format("[[List of cities in Bassaridia Vaeringheim#%s|%s]]", city, city)
return string.format("[[List of cities in Bassaridia Vaeringheim#%s|%s]]", city, city)
end
local function eventCityLink(event)
if not event then
return "—"
end
local cultName = normalizeCultName(event.a)
local cult = cultByName[cultName]
if not cult or not cult.city or cult.city == "" then
return "—"
end
return cityLink(cult.city)
end
end


Line 1,411: Line 1,426:
table.insert(out, string.format("'''Daily cult-relations event log — %s'''", data.date.longText))
table.insert(out, string.format("'''Daily cult-relations event log — %s'''", data.date.longText))
table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
table.insert(out, '! Event !! Severity !! Cults !! Effect !! Notes')
table.insert(out, '! Event !! Severity !! Location !! Cults !! Effect !! Notes')


for _, e in ipairs(data.events or {}) do
for _, e in ipairs(data.events or {}) do
table.insert(out, "|-")
table.insert(out, string.format(
table.insert(out, string.format(
"%s at %s: %s ↔ %s (%s)",
"| %s || %s || %s ↔ %s || style='text-align:center;' | %s || %s",
e.title,
e.title,
eventCityLink(e),
severityBadge(e.severity),
e.a,
cultMark(e.a),
e.b,
cultMark(e.b),
signed(e.delta)
signed(e.delta),
))
e.text
))
end
end



Revision as of 21:05, 1 June 2026

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


-- Module:CultRelationsDailyBVR
-- Dynamic cult-relations index for the Reformed Stripping Path.
--
-- This module:
--   * Uses [[Module:BassaridianCalendar]] for the current PSSC date.
--   * Stores a fixed roster of cults/mysteries from Planetary Devotion.
--   * Starts from baseline alliances and rivalries.
--   * Applies deterministic daily events so relations change automatically by day.
--   * Provides table, pair, matrix, event-log, and raw-data functions.
--
-- Suggested invocations:
--   {{#invoke:CultRelationsDailyBVR|makeTable}}
--   {{#invoke:CultRelationsDailyBVR|makeTable|limit=30}}
--   {{#invoke:CultRelationsDailyBVR|makeTable|year=53|day=95}}
--   {{#invoke:CultRelationsDailyBVR|makePair|a=Harmony Sanctum|b=Ignis Aeternum}}
--   {{#invoke:CultRelationsDailyBVR|eventLog}}
--   {{#invoke:CultRelationsDailyBVR|makeMatrix}}
--   {{#invoke:CultRelationsDailyBVR|legend}}
--
-- Optional bridge from [[Module:MissionaryDailyOpsBVR]]:
--   local cultRel = require("Module:CultRelationsDailyBVR")
--   cultRel.makeTable(frame)

local p = {}

local calOk, cal = pcall(require, "Module:BassaridianCalendar")
if not calOk then
	cal = nil
end

local DAYS_IN_YEAR = 183

--------------------------------------------------------------------------------
-- 1. BASIC UTILITIES
--------------------------------------------------------------------------------

local function trim(s)
	s = tostring(s or "")
	return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function lower(s)
	return mw.ustring.lower(trim(s))
end

local function clamp(n, lo, hi)
	n = tonumber(n) or 0
	if n < lo then return lo end
	if n > hi then return hi end
	return n
end

local function sortedPair(a, b)
	a, b = trim(a), trim(b)
	if a < b then
		return a .. "||" .. b
	end
	return b .. "||" .. a
end

local function splitPairKey(k)
	local a, b = tostring(k or ""):match("^(.-)%|%|(.*)$")
	return a or "", b or ""
end

local function tableContains(t, value)
	for _, v in ipairs(t or {}) do
		if v == value then
			return true
		end
	end
	return false
end

local function shallowCopy(t)
	local out = {}
	for k, v in pairs(t or {}) do
		out[k] = v
	end
	return out
end

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

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

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

local function pickWeighted(nextInt, list)
	local total = 0
	for _, item in ipairs(list or {}) do
		total = total + (tonumber(item.w) or 0)
	end

	if total <= 0 then
		return list and list[1] or nil
	end

	local r = nextInt() % total
	local acc = 0
	for _, item in ipairs(list) do
		acc = acc + (tonumber(item.w) or 0)
		if r < acc then
			return item
		end
	end

	return list[#list]
end

--------------------------------------------------------------------------------
-- 2. DATE HANDLING
--------------------------------------------------------------------------------

local monthNames = {
	[1] = "Atosiel",
	[2] = "Thalassiel",
	[3] = "Opsitheiel"
}

local zodiacByDay = {
	[1] = {
		{12, "Atosien"},
		{24, "Eosena"},
		{36, "Micrasha"},
		{48, "Pyreska"},
		{61, "Indomin"}
	},
	[2] = {
		{12, "Chrysen"},
		{24, "Thalassian"},
		{36, "Nephelia"},
		{48, "Glinaeus"},
		{61, "Noctien"}
	},
	[3] = {
		{12, "Opsithia"},
		{24, "Stygian"},
		{36, "Faunian"},
		{48, "Silenian"},
		{61, "Catosien"}
	}
}

local function doyToMonthDom(doy)
	doy = clamp(doy, 1, DAYS_IN_YEAR)
	if doy <= 61 then
		return 1, doy
	elseif doy <= 122 then
		return 2, doy - 61
	else
		return 3, doy - 122
	end
end

local function monthDomToDoy(month, dom)
	month = tonumber(month) or 1
	dom = tonumber(dom) or 1
	if month == 1 then
		return clamp(dom, 1, 61)
	elseif month == 2 then
		return 61 + clamp(dom, 1, 61)
	else
		return 122 + clamp(dom, 1, 61)
	end
end

local function zodiacFor(monthNum, dom)
	local rows = zodiacByDay[monthNum] or zodiacByDay[1]
	for _, row in ipairs(rows) do
		if dom <= row[1] then
			return row[2]
		end
	end
	return rows[#rows][2]
end

local function parseCalendarString(raw)
	raw = tostring(raw or "")

	-- Expected from getCurrentDateNoProverb:
	-- "95, Thalassiel (Noctien), 53 PSSC"
	local doy, month, zodiac, year = raw:match("^(%d+),%s*([%a]+)%s*%(([^%)]+)%),%s*(%d+)%s*PSSC")
	if doy and month and year then
		local d = tonumber(doy)
		local y = tonumber(year)
		local m = 1
		if month == "Thalassiel" then
			m = 2
		elseif month == "Opsitheiel" then
			m = 3
		end
		local mn, dom = doyToMonthDom(d)
		return {
			year = y,
			dayOfYear = d,
			monthNum = mn,
			month = monthNames[mn],
			dom = dom,
			zodiac = zodiac or zodiacFor(mn, dom),
			raw = raw,
			text = string.format("%d/%d/%d PSSC", dom, mn, y),
			longText = string.format("%d, %s (%s), %d PSSC", d, monthNames[mn], zodiac or zodiacFor(mn, dom), y)
		}
	end

	-- Accept a simpler "day/month/year PSSC" pattern.
	local dom2, month2, year2 = raw:match("^(%d+)/(%d+)/(%d+)%s*PSSC")
	if dom2 and month2 and year2 then
		local mn = clamp(tonumber(month2), 1, 3)
		local dm = clamp(tonumber(dom2), 1, 61)
		local y = tonumber(year2)
		local d = monthDomToDoy(mn, dm)
		local z = zodiacFor(mn, dm)
		return {
			year = y,
			dayOfYear = d,
			monthNum = mn,
			month = monthNames[mn],
			dom = dm,
			zodiac = z,
			raw = raw,
			text = string.format("%d/%d/%d PSSC", dm, mn, y),
			longText = string.format("%d, %s (%s), %d PSSC", d, monthNames[mn], z, y)
		}
	end

	return nil
end

local function getDateInfo(args)
	args = args or {}

	local y = tonumber(args.year or args.psscyear)
	local doy = tonumber(args.day or args.dayofyear or args.doy)

	if y and doy then
		doy = clamp(doy, 1, DAYS_IN_YEAR)
		local mn, dom = doyToMonthDom(doy)
		local z = zodiacFor(mn, dom)
		return {
			year = y,
			dayOfYear = doy,
			monthNum = mn,
			month = monthNames[mn],
			dom = dom,
			zodiac = z,
			raw = string.format("%d, %s (%s), %d PSSC", doy, monthNames[mn], z, y),
			text = string.format("%d/%d/%d PSSC", dom, mn, y),
			longText = string.format("%d, %s (%s), %d PSSC", doy, monthNames[mn], z, y)
		}
	end

	local raw = ""
	if cal and type(cal.getCurrentDateNoProverb) == "function" then
		raw = cal.getCurrentDateNoProverb()
	elseif cal and type(cal.getCurrentDate) == "function" then
		raw = cal.getCurrentDate()
	end

	local parsed = parseCalendarString(raw)
	if parsed then
		return parsed
	end

	-- Stable fallback if the calendar module is unavailable.
	local fallbackYear = 53
	local fallbackDay = 1
	local mn, dom = doyToMonthDom(fallbackDay)
	local z = zodiacFor(mn, dom)

	return {
		year = fallbackYear,
		dayOfYear = fallbackDay,
		monthNum = mn,
		month = monthNames[mn],
		dom = dom,
		zodiac = z,
		raw = "Calendar unavailable",
		text = string.format("%d/%d/%d PSSC", dom, mn, fallbackYear),
		longText = string.format("%d, %s (%s), %d PSSC", fallbackDay, monthNames[mn], z, fallbackYear)
	}
end

--------------------------------------------------------------------------------
-- 3. CULT ROSTER
-- Based on Planetary Devotion / Mystery table.
--------------------------------------------------------------------------------

local cults = {
	{
		key = "OSI",
		name = "Ordo Solis Invicti",
		divine = "Atos",
		city = "Vaeringheim",
		page = "Atos (Reformed Stripping Path)",
		anchor = "Ordo Solis Invicti",
		logo = "AtosSygil.png",
		tendency = {"order", "measure", "light", "state"}
	},
	{
		key = "AUR",
		name = "Order of Aurora Mystica",
		divine = "Eos",
		city = "Luminaria",
		page = "Eos (Reformed Stripping Path)",
		anchor = "Order of Aurora Mystica",
		logo = "Auroramystica.png",
		tendency = {"dawn", "renewal", "illumination", "mediation"}
	},
	{
		key = "HAR",
		name = "Harmony Sanctum",
		divine = "Micras",
		city = "Serena",
		page = "Micras (Reformed Stripping Path)",
		anchor = "Harmony Sanctum",
		logo = "Harmonysanctum.png",
		tendency = {"balance", "wisdom", "mediation", "order"}
	},
	{
		key = "IGN",
		name = "Ignis Aeternum",
		divine = "Pyros",
		city = "Pyralis",
		page = "Pyros (Reformed Stripping Path)",
		anchor = "Ignis Aeternum",
		logo = "Ignis Aeternum.png",
		tendency = {"fire", "renewal", "passion", "destruction"}
	},
	{
		key = "CHS",
		name = "Celestial Harmony Sect",
		divine = "Indigo and Momiji",
		city = "Symphonara",
		page = "Indigo (Reformed Stripping Path)",
		anchor = "Celestial Harmony Sect",
		logo = "HarmonySect.png",
		tendency = {"duality", "change", "mediation", "ambiguity"}
	},
	{
		key = "GGS",
		name = "Guild of Golden Shadows",
		divine = "Chrysos",
		city = "Aurelia",
		page = "Chrysos (Reformed Stripping Path)",
		anchor = "Guild of Golden Shadows",
		logo = "GoldenShadows.png",
		tendency = {"wealth", "trade", "secrecy", "espionage"}
	},
	{
		key = "AZU",
		name = "Azure Sentinel Sect",
		divine = "Thalassa",
		city = "Vaeringheim",
		page = "Thalassa (Reformed Stripping Path)",
		anchor = "Azure Sentinel Sect",
		logo = "AzureSentinel.png",
		tendency = {"sea", "war", "depth", "mystery"}
	},
	{
		key = "RNB",
		name = "Reverie Nebulous",
		divine = "Nephele",
		city = "Somniumpolis",
		page = "Nephele (Reformed Stripping Path)",
		anchor = "Reverie Nebulous",
		logo = "RevNeb.png",
		tendency = {"dreams", "clouds", "imagination", "omens"}
	},
	{
		key = "EOF",
		name = "Eon Fellowship",
		divine = "Glinos",
		city = "Nexa",
		page = "Glinos (Reformed Stripping Path)",
		anchor = "Eon Fellowship",
		logo = "EonFel.png",
		tendency = {"winter", "death", "cycle", "endurance"}
	},
	{
		key = "UMB",
		name = "Order of the Umbral Oracle",
		divine = "Noctis",
		city = "Lunalis Sancta",
		page = "Noctis (Reformed Stripping Path)",
		anchor = "Order of the Umbral Oracle",
		logo = "Umbral.png",
		tendency = {"night", "secrecy", "truth", "introspection"}
	},
	{
		key = "VDE",
		name = "Mystery of the Verdant Embrace",
		divine = "Opsithe",
		city = "Sylvapolis",
		page = "Opsithe (Reformed Stripping Path)",
		anchor = "Mystery of the Verdant Embrace",
		logo = "Verdant.png",
		tendency = {"harvest", "fertility", "nature", "stability"}
	},
	{
		key = "CON",
		name = "Conclave Illuminara",
		divine = "Tarsica",
		city = "Luminaria",
		page = "Tarsica (Reformed Stripping Path)",
		anchor = "Conclave Illuminara",
		logo = "Conclave.png",
		tendency = {"illumination", "wisdom", "moon", "eclipse"}
	},
	{
		key = "SVI",
		name = "Sanctum Vitalis",
		divine = "Plateau",
		city = "Saluria",
		page = "Plateau (Reformed Stripping Path)",
		anchor = "Sanctum Vitalis",
		logo = "Vitalis.png",
		tendency = {"health", "medicine", "relief", "stability"}
	},
	{
		key = "ALB",
		name = "Temple Alabaster",
		divine = "Ivory",
		city = "Aetherium",
		page = "Ivory (Reformed Stripping Path)",
		anchor = "Temple Alabaster",
		logo = "Alabaster.png",
		tendency = {"purity", "serenity", "neutrality", "healing"}
	},
	{
		key = "CICL",
		name = "Court of the Ironclad",
		divine = "Fenrir",
		city = "Ferrum Citadel",
		page = "Fenrir (Reformed Stripping Path)",
		anchor = "Court of the Ironclad",
		logo = "Ironclad.png",
		tendency = {"strength", "tenacity", "security", "militancy"}
	},
	{
		key = "ACC",
		name = "Accord Concordia",
		divine = "Lukedu",
		city = "Symphonara",
		page = "Lukedu (Reformed Stripping Path)",
		anchor = "Accord Concordia",
		logo = "Concordia.png",
		tendency = {"unity", "peace", "mediation", "diplomacy"}
	},
	{
		key = "STY",
		name = "Mystery of the Stygian Veil",
		divine = "Styx",
		city = "Acheron",
		page = "Styx (Reformed Stripping Path)",
		anchor = "Mystery of the Stygian Veil",
		logo = "StygianVe.png",
		tendency = {"transformation", "rebirth", "death", "threshold"}
	},
	{
		key = "SYL",
		name = "Sylvan Fellowship",
		divine = "Faun",
		city = "Sylvapolis",
		page = "Faun (Reformed Stripping Path)",
		anchor = "Sylvan Fellowship",
		logo = "Sylvan.png",
		tendency = {"nature", "fertility", "wilds", "balance"}
	},
	{
		key = "MIR",
		name = "Mystery of Red Mirth",
		divine = "Silenus",
		city = "Erythros",
		page = "Silenus (Reformed Stripping Path)",
		anchor = "Mystery of Red Mirth",
		logo = "MirthFlag.png",
		tendency = {"merriment", "festival", "wine", "release"}
	},
	{
		key = "CAT",
		name = "Rex Catonis",
		divine = "Cato",
		city = "Catonis Atrium",
		page = "Cato (Reformed Stripping Path)",
		anchor = "Rex Catonis",
		logo = "Catonis.png",
		tendency = {"discipline", "order", "law", "judgment"}
	},
	{
		key = "SDE",
		name = "Sanctum Delphica",
		divine = "Sisera",
		city = "Delphica",
		page = "Sisera (Reformed Stripping Path)",
		anchor = "Sanctum Delphica",
		logo = "SanctumDel.png",
		tendency = {"reflection", "introspection", "records", "truth"}
	},
	{
		key = "AMI",
		name = "Ordo Amicitia",
		divine = "Erasmus",
		city = "Koinonía",
		page = "Erasmus (Reformed Stripping Path)",
		anchor = "Ordo Amicitia",
		logo = "OrdoFl.png",
		tendency = {"charity", "wisdom", "relief", "mercy"}
	},
	{
		key = "ILL",
		name = "Temple Illuminata",
		divine = "Nikolaj",
		city = "Aureum",
		page = "Nikolaj (Reformed Stripping Path)",
		anchor = "Temple Illuminata",
		logo = "AureumFlag.png",
		tendency = {"epiphany", "mystery", "cosmic", "insight"}
	}
}

local cultByName = {}
local cultByKey = {}
local cultAliases = {}

for _, c in ipairs(cults) do
	cultByName[c.name] = c
	cultByKey[c.key] = c
	cultAliases[lower(c.name)] = c.name
	cultAliases[lower(c.key)] = c.name
	cultAliases[lower(c.divine)] = c.name
end

-- Common shorthand aliases.
cultAliases["aurora"] = "Order of Aurora Mystica"
cultAliases["aurora mystica"] = "Order of Aurora Mystica"
cultAliases["harmony"] = "Harmony Sanctum"
cultAliases["ignis"] = "Ignis Aeternum"
cultAliases["golden shadows"] = "Guild of Golden Shadows"
cultAliases["azure"] = "Azure Sentinel Sect"
cultAliases["reverie"] = "Reverie Nebulous"
cultAliases["eon"] = "Eon Fellowship"
cultAliases["umbral"] = "Order of the Umbral Oracle"
cultAliases["verdant"] = "Mystery of the Verdant Embrace"
cultAliases["illuminara"] = "Conclave Illuminara"
cultAliases["vitalis"] = "Sanctum Vitalis"
cultAliases["alabaster"] = "Temple Alabaster"
cultAliases["ironclad"] = "Court of the Ironclad"
cultAliases["concordia"] = "Accord Concordia"
cultAliases["stygian veil"] = "Mystery of the Stygian Veil"
cultAliases["sylvan"] = "Sylvan Fellowship"
cultAliases["red mirth"] = "Mystery of Red Mirth"
cultAliases["catonis"] = "Rex Catonis"
cultAliases["delphica"] = "Sanctum Delphica"
cultAliases["amicitia"] = "Ordo Amicitia"
cultAliases["illuminata"] = "Temple Illuminata"

local function normalizeCultName(name)
	name = trim(name)
	if cultByName[name] then
		return name
	end
	return cultAliases[lower(name)] or name
end

local function cultLink(name)
	name = normalizeCultName(name)
	local c = cultByName[name]
	if not c then
		return "[[" .. name .. "]]"
	end
	return string.format("[[%s#%s|%s]]", c.page, c.anchor, c.name)
end

local function cultMark(name)
	name = normalizeCultName(name)
	local c = cultByName[name]
	if not c then
		return cultLink(name)
	end

	local logo = ""
	if c.logo and c.logo ~= "" then
		logo = string.format("[[File:%s|18px]] ", c.logo)
	end

	return logo .. cultLink(name)
end

local function cityLink(city)
	city = trim(city)
	return string.format("[[List of cities in Bassaridia Vaeringheim#%s|%s]]", city, city)
end

local function eventCityLink(event)
	if not event then
		return "—"
	end

	local cultName = normalizeCultName(event.a)
	local cult = cultByName[cultName]

	if not cult or not cult.city or cult.city == "" then
		return "—"
	end

	return cityLink(cult.city)
end

--------------------------------------------------------------------------------
-- 4. BASELINE RELATIONS
--
-- Scale:
--   +75 covenantal alliance
--   +55 strong alliance
--   +35 standing alliance
--   +15 mild affinity
--     0 neutral
--   -15 mild tension
--   -35 standing rivalry
--   -55 hostile rivalry
--   -75 sectarian enmity
--------------------------------------------------------------------------------

local baselineRows = {
	["Ordo Solis Invicti"] = {
		allies = {"Rex Catonis", "Harmony Sanctum", "Order of Aurora Mystica"},
		enemies = {},
		note = "solar order, measure, and public adjudication"
	},
	["Order of Aurora Mystica"] = {
		allies = {"Eon Fellowship", "Conclave Illuminara", "Harmony Sanctum", "Ordo Solis Invicti"},
		enemies = {"Celestial Harmony Sect", "Ignis Aeternum"},
		note = "dawn rites, renewal, and anti-destruction cooperation"
	},
	["Harmony Sanctum"] = {
		allies = {"Order of Aurora Mystica", "Mystery of the Verdant Embrace", "Guild of Golden Shadows", "Conclave Illuminara", "Accord Concordia", "Ordo Solis Invicti"},
		enemies = {"Ignis Aeternum", "Azure Sentinel Sect", "Court of the Ironclad", "Sylvan Fellowship"},
		note = "balance-oriented center of the faith"
	},
	["Ignis Aeternum"] = {
		allies = {"Azure Sentinel Sect", "Sylvan Fellowship"},
		enemies = {"Eon Fellowship", "Harmony Sanctum", "Reverie Nebulous", "Mystery of the Verdant Embrace", "Order of Aurora Mystica", "Court of the Ironclad", "Guild of Golden Shadows"},
		note = "purifying flame and destructive renewal"
	},
	["Celestial Harmony Sect"] = {
		allies = {"Order of the Umbral Oracle", "Accord Concordia", "Sanctum Delphica"},
		enemies = {"Order of Aurora Mystica", "Conclave Illuminara", "Rex Catonis"},
		note = "duality, change, and mediating ambiguity"
	},
	["Guild of Golden Shadows"] = {
		allies = {"Harmony Sanctum", "Reverie Nebulous", "Court of the Ironclad", "Sanctum Delphica"},
		enemies = {"Ignis Aeternum", "Azure Sentinel Sect"},
		note = "trade, intelligence, brokerage, and covert influence"
	},
	["Azure Sentinel Sect"] = {
		allies = {"Ignis Aeternum", "Reverie Nebulous"},
		enemies = {"Eon Fellowship", "Harmony Sanctum", "Guild of Golden Shadows"},
		note = "sea, war, depth, and temple defense"
	},
	["Reverie Nebulous"] = {
		allies = {"Guild of Golden Shadows", "Azure Sentinel Sect", "Sanctum Delphica"},
		enemies = {"Ignis Aeternum", "Eon Fellowship", "Court of the Ironclad"},
		note = "dreams, imagination, omen-craft, and social volatility"
	},
	["Eon Fellowship"] = {
		allies = {"Order of Aurora Mystica", "Court of the Ironclad", "Temple Illuminata"},
		enemies = {"Ignis Aeternum", "Azure Sentinel Sect", "Sylvan Fellowship", "Reverie Nebulous"},
		note = "winter, endurance, death, and cyclical renewal"
	},
	["Order of the Umbral Oracle"] = {
		allies = {"Harmony Sanctum", "Ordo Amicitia", "Celestial Harmony Sect", "Sanctum Delphica"},
		enemies = {},
		note = "night, hidden truth, introspection, and oracle-work"
	},
	["Mystery of the Verdant Embrace"] = {
		allies = {"Harmony Sanctum", "Ordo Amicitia", "Sanctum Vitalis", "Sylvan Fellowship"},
		enemies = {"Ignis Aeternum", "Court of the Ironclad"},
		note = "harvest, fertility, cultivated nature, and anti-destruction practice"
	},
	["Conclave Illuminara"] = {
		allies = {"Order of Aurora Mystica", "Harmony Sanctum", "Ordo Solis Invicti"},
		enemies = {"Celestial Harmony Sect"},
		note = "Tarsican illumination, eclipse interpretation, and wisdom rites"
	},
	["Sanctum Vitalis"] = {
		allies = {"Temple Alabaster", "Ordo Amicitia", "Mystery of the Verdant Embrace", "Harmony Sanctum"},
		enemies = {},
		note = "health, medicine, relief, and recovery"
	},
	["Temple Alabaster"] = {
		allies = {"Court of the Ironclad", "Ordo Amicitia", "Sanctum Vitalis", "Rex Catonis"},
		enemies = {},
		note = "purity, serenity, neutrality, and protected precincts"
	},
	["Court of the Ironclad"] = {
		allies = {"Eon Fellowship", "Guild of Golden Shadows", "Temple Alabaster", "Rex Catonis"},
		enemies = {"Ignis Aeternum", "Harmony Sanctum", "Reverie Nebulous", "Mystery of the Verdant Embrace"},
		note = "strength, tenacity, armed discipline, and crisis response"
	},
	["Accord Concordia"] = {
		allies = {"Sanctum Delphica", "Harmony Sanctum", "Celestial Harmony Sect", "Ordo Amicitia"},
		enemies = {},
		note = "unity, diplomacy, and inter-cult mediation"
	},
	["Mystery of the Stygian Veil"] = {
		allies = {"Temple Illuminata", "Order of the Umbral Oracle", "Sanctum Vitalis"},
		enemies = {"Ordo Amicitia"},
		note = "transformation, rebirth, death, and threshold rites"
	},
	["Sylvan Fellowship"] = {
		allies = {"Ignis Aeternum", "Mystery of the Verdant Embrace"},
		enemies = {"Eon Fellowship", "Harmony Sanctum"},
		note = "wild fertility, natural balance, and resistance to over-ordering"
	},
	["Mystery of Red Mirth"] = {
		allies = {"Rex Catonis", "Accord Concordia", "Ordo Amicitia"},
		enemies = {},
		note = "celebration, release, and ritual merriment"
	},
	["Rex Catonis"] = {
		allies = {"Mystery of Red Mirth", "Court of the Ironclad", "Temple Alabaster", "Ordo Solis Invicti"},
		enemies = {"Celestial Harmony Sect"},
		note = "discipline, order, law, and moral clarity"
	},
	["Sanctum Delphica"] = {
		allies = {"Accord Concordia", "Reverie Nebulous", "Order of the Umbral Oracle", "Guild of Golden Shadows", "Celestial Harmony Sect"},
		enemies = {},
		note = "reflection, records, introspection, and arbitration"
	},
	["Ordo Amicitia"] = {
		allies = {"Order of the Umbral Oracle", "Mystery of the Verdant Embrace", "Temple Alabaster", "Sanctum Vitalis", "Accord Concordia", "Mystery of Red Mirth"},
		enemies = {"Mystery of the Stygian Veil"},
		note = "charity, wisdom, aid distribution, and mercy"
	},
	["Temple Illuminata"] = {
		allies = {"Mystery of the Stygian Veil", "Eon Fellowship", "Conclave Illuminara"},
		enemies = {},
		note = "epiphanies, cosmic insight, and mystery"
	}
}

local mythicAffinities = {
	{"Order of Aurora Mystica", "Eon Fellowship", 15, "Eos–Glinos mythic affinity"},
	{"Order of Aurora Mystica", "Conclave Illuminara", 15, "Eos–Tarsica mythic affinity"},
	{"Harmony Sanctum", "Mystery of the Verdant Embrace", 15, "Micras–Opsithe mythic affinity"},
	{"Guild of Golden Shadows", "Reverie Nebulous", 15, "Chrysos–Nephele mythic affinity"},
	{"Guild of Golden Shadows", "Accord Concordia", 8, "Chrysos–Lukedu lineage affinity"},
	{"Azure Sentinel Sect", "Ignis Aeternum", 15, "Thalassa–Pyros mythic affinity"},
	{"Order of the Umbral Oracle", "Ordo Amicitia", 12, "Noctis–Erasmus affinity"},
	{"Accord Concordia", "Sanctum Delphica", 15, "Lukedu–Sisera mythic affinity"},
	{"Mystery of Red Mirth", "Rex Catonis", 15, "Silenus–Cato mythic affinity"},
	{"Mystery of the Stygian Veil", "Temple Illuminata", 15, "Styx–Nikolaj mythic affinity"},
	{"Court of the Ironclad", "Temple Alabaster", 12, "Fenrir–Ivory mythic affinity"},
	{"Sanctum Vitalis", "Mystery of the Verdant Embrace", 10, "Plateau–Opsithe lineage affinity"},
	{"Sylvan Fellowship", "Mystery of the Verdant Embrace", 10, "Faun–Opsithe natural affinity"},

	{"Ignis Aeternum", "Harmony Sanctum", -15, "Pyros–Micras mythic tension"},
	{"Ignis Aeternum", "Mystery of the Verdant Embrace", -15, "Pyros–Opsithe mythic tension"},
	{"Ignis Aeternum", "Reverie Nebulous", -12, "Pyros–Nephele mythic tension"},
	{"Azure Sentinel Sect", "Harmony Sanctum", -10, "Thalassa–Micras mythic tension"},
	{"Order of Aurora Mystica", "Celestial Harmony Sect", -10, "Eos–Indigo/Momiji mythic tension"},
	{"Conclave Illuminara", "Celestial Harmony Sect", -10, "Tarsica–Indigo/Momiji mythic tension"},
	{"Eon Fellowship", "Azure Sentinel Sect", -10, "Glinos–Thalassa mythic tension"},
	{"Eon Fellowship", "Sylvan Fellowship", -8, "winter–wild fertility tension"},
	{"Mystery of the Stygian Veil", "Ordo Amicitia", -8, "Styx–Nyssa/Erasmus secrecy tension"}
}

local function buildBaseScores()
	local scores = {}
	local notes = {}

	local function add(a, b, delta, note)
		a, b = normalizeCultName(a), normalizeCultName(b)
		if a == "" or b == "" or a == b then
			return
		end
		local k = sortedPair(a, b)
		scores[k] = clamp((scores[k] or 0) + delta, -95, 95)
		notes[k] = notes[k] or {}
		table.insert(notes[k], note)
	end

	for name, row in pairs(baselineRows) do
		for _, ally in ipairs(row.allies or {}) do
			add(name, ally, 35, "standing alliance")
		end
		for _, enemy in ipairs(row.enemies or {}) do
			add(name, enemy, -35, "standing rivalry")
		end
	end

	for _, rel in ipairs(mythicAffinities) do
		add(rel[1], rel[2], rel[3], rel[4])
	end

	return scores, notes
end

--------------------------------------------------------------------------------
-- 5. DAILY EVENT POOL
--------------------------------------------------------------------------------

local eventPool = {
	{
		w = 9,
		type = "relief",
		severity = "green",
		delta = 8,
		title = "Joint relief distribution",
		text = "{A} and {B} jointly distribute food, medicine, or shelter stipends through Temple Bank channels.",
		cults = {"Ordo Amicitia", "Sanctum Vitalis", "Temple Alabaster", "Harmony Sanctum", "Mystery of the Verdant Embrace"}
	},
	{
		w = 8,
		type = "mediation",
		severity = "green",
		delta = 7,
		title = "Mediation circuit",
		text = "{A} and {B} conduct a mediation circuit in a tense district, improving working confidence.",
		cults = {"Harmony Sanctum", "Accord Concordia", "Order of Aurora Mystica", "Celestial Harmony Sect", "Sanctum Delphica"}
	},
	{
		w = 7,
		type = "audit",
		severity = "green",
		delta = 6,
		title = "Temple Bank audit clears both sides",
		text = "A Temple Bank audit clears {A} and {B} of misconduct allegations, reducing public suspicion.",
		cults = {"Rex Catonis", "Sanctum Delphica", "Temple Alabaster", "Guild of Golden Shadows", "Ordo Solis Invicti"}
	},
	{
		w = 6,
		type = "festival",
		severity = "green",
		delta = 9,
		title = "Shared festival stewardship",
		text = "{A} and {B} jointly steward a festival route without disorder.",
		cults = {"Mystery of Red Mirth", "Rex Catonis", "Order of Aurora Mystica", "Conclave Illuminara", "Accord Concordia", "Ordo Amicitia"}
	},
	{
		w = 6,
		type = "calendar",
		severity = "green",
		delta = 6,
		title = "Calendar synchronization",
		text = "{A} and {B} agree on calendar timing for public rites and registry deadlines.",
		cults = {"Ordo Solis Invicti", "Conclave Illuminara", "Order of Aurora Mystica", "Rex Catonis", "Sanctum Delphica"}
	},
	{
		w = 6,
		type = "omen",
		severity = "yellow",
		delta = -7,
		title = "Disputed omen",
		text = "{A} and {B} issue conflicting interpretations of a public omen.",
		cults = {"Conclave Illuminara", "Order of the Umbral Oracle", "Temple Illuminata", "Reverie Nebulous", "Celestial Harmony Sect"}
	},
	{
		w = 7,
		type = "doctrine",
		severity = "yellow",
		delta = -8,
		title = "Doctrinal protest",
		text = "{A} denounces the ritual language used by {B}.",
		cults = {"Ignis Aeternum", "Harmony Sanctum", "Eon Fellowship", "Azure Sentinel Sect", "Mystery of the Verdant Embrace", "Order of Aurora Mystica"}
	},
	{
		w = 6,
		type = "trade",
		severity = "yellow",
		delta = -6,
		title = "Trade-route accusation",
		text = "{A} accuses {B} of shielding merchants, brokers, or shrine vendors from proper review.",
		cults = {"Guild of Golden Shadows", "Azure Sentinel Sect", "Rex Catonis", "Temple Alabaster", "Sanctum Delphica", "Ordo Solis Invicti"}
	},
	{
		w = 5,
		type = "security",
		severity = "yellow",
		delta = -7,
		title = "Shrine security dispute",
		text = "{A} objects to the security posture adopted by {B} near a shrine precinct.",
		cults = {"Court of the Ironclad", "Azure Sentinel Sect", "Rex Catonis", "Harmony Sanctum", "Order of Aurora Mystica", "Temple Alabaster"}
	},
	{
		w = 5,
		type = "dream",
		severity = "yellow",
		delta = -6,
		title = "Dream-prophecy disagreement",
		text = "{A} and {B} dispute whether a recurring dream report should be treated as prophecy, warning, or rumor.",
		cults = {"Reverie Nebulous", "Order of the Umbral Oracle", "Sanctum Delphica", "Conclave Illuminara", "Temple Illuminata"}
	},
	{
		w = 4,
		type = "panic",
		severity = "red",
		delta = -12,
		title = "Rumor panic",
		text = "Panic narratives spread between communities aligned with {A} and {B}.",
		cults = {"Reverie Nebulous", "Order of the Umbral Oracle", "Temple Illuminata", "Sanctum Delphica", "Eon Fellowship"}
	},
	{
		w = 4,
		type = "violence",
		severity = "red",
		delta = -16,
		title = "Sectarian violence",
		text = "Street violence attributed to extremists worsens relations between {A} and {B}.",
		cults = {"Ignis Aeternum", "Court of the Ironclad", "Azure Sentinel Sect", "Harmony Sanctum", "Reverie Nebulous", "Mystery of the Verdant Embrace"}
	},
	{
		w = 5,
		type = "mourning",
		severity = "green",
		delta = 7,
		title = "Shared mourning rites",
		text = "{A} and {B} cooperate during mourning rites after a local death, disaster, or battlefield recovery.",
		cults = {"Mystery of the Stygian Veil", "Ordo Amicitia", "Order of the Umbral Oracle", "Sanctum Vitalis", "Temple Alabaster"}
	},
	{
		w = 5,
		type = "ecology",
		severity = "green",
		delta = 6,
		title = "Ecological repair compact",
		text = "{A} and {B} sign a temporary compact for ecological repair and pilgrimage safety.",
		cults = {"Mystery of the Verdant Embrace", "Sylvan Fellowship", "Harmony Sanctum", "Sanctum Vitalis"}
	},
	{
		w = 4,
		type = "purification",
		severity = "red",
		delta = -14,
		title = "Purification controversy",
		text = "{A} is blamed for encouraging dangerous purification language against {B}.",
		cults = {"Ignis Aeternum", "Temple Alabaster", "Harmony Sanctum", "Mystery of the Verdant Embrace", "Order of Aurora Mystica"}
	},
	{
		w = 5,
		type = "registry",
		severity = "green",
		delta = 5,
		title = "Registry reconciliation",
		text = "{A} and {B} reconcile conflicting cult ledgers through formal registry review.",
		cults = {"Rex Catonis", "Sanctum Delphica", "Guild of Golden Shadows", "Ordo Amicitia", "Accord Concordia", "Ordo Solis Invicti"}
	},
	{
		w = 5,
		type = "port",
		severity = "green",
		delta = 6,
		title = "General Port membership drive",
		text = "{A} and {B} support a General Port membership drive tied to shrine investment and community stabilization.",
		cults = {"Guild of Golden Shadows", "Ordo Amicitia", "Harmony Sanctum", "Accord Concordia", "Sanctum Delphica"}
	},
	{
		w = 4,
		type = "privateer",
		severity = "yellow",
		delta = -7,
		title = "Privateer blessing dispute",
		text = "{A} and {B} disagree over whether a privateer commission should receive ritual blessing.",
		cults = {"Azure Sentinel Sect", "Guild of Golden Shadows", "Rex Catonis", "Court of the Ironclad", "Order of the Umbral Oracle"}
	},
	{
		w = 4,
		type = "missionary",
		severity = "green",
		delta = 6,
		title = "Missionary escort compact",
		text = "{A} and {B} agree to escort missionaries through a contested road or canal route.",
		cults = {"Ordo Amicitia", "Court of the Ironclad", "Temple Alabaster", "Harmony Sanctum", "Azure Sentinel Sect"}
	}
}

local festivalPressures = {
	Atosien = {
		positive = {"Ordo Solis Invicti", "Conclave Illuminara", "Rex Catonis"},
		negative = {}
	},
	Eosena = {
		positive = {"Order of Aurora Mystica", "Conclave Illuminara", "Accord Concordia"},
		negative = {"Celestial Harmony Sect"}
	},
	Micrasha = {
		positive = {"Harmony Sanctum", "Mystery of the Verdant Embrace", "Accord Concordia"},
		negative = {"Ignis Aeternum"}
	},
	Pyreska = {
		positive = {"Ignis Aeternum", "Azure Sentinel Sect", "Sylvan Fellowship"},
		negative = {"Harmony Sanctum", "Mystery of the Verdant Embrace"}
	},
	Indomin = {
		positive = {"Celestial Harmony Sect", "Sanctum Delphica", "Accord Concordia"},
		negative = {"Order of Aurora Mystica", "Conclave Illuminara", "Rex Catonis"}
	},
	Chrysen = {
		positive = {"Guild of Golden Shadows", "Reverie Nebulous", "Sanctum Delphica"},
		negative = {"Azure Sentinel Sect"}
	},
	Thalassian = {
		positive = {"Azure Sentinel Sect", "Ignis Aeternum", "Reverie Nebulous"},
		negative = {"Eon Fellowship", "Harmony Sanctum"}
	},
	Nephelia = {
		positive = {"Reverie Nebulous", "Guild of Golden Shadows", "Sanctum Delphica"},
		negative = {"Ignis Aeternum", "Court of the Ironclad"}
	},
	Glinaeus = {
		positive = {"Eon Fellowship", "Court of the Ironclad", "Order of Aurora Mystica"},
		negative = {"Ignis Aeternum", "Azure Sentinel Sect"}
	},
	Noctien = {
		positive = {"Order of the Umbral Oracle", "Ordo Amicitia", "Sanctum Delphica"},
		negative = {}
	},
	Opsithia = {
		positive = {"Mystery of the Verdant Embrace", "Sanctum Vitalis", "Sylvan Fellowship"},
		negative = {"Ignis Aeternum"}
	},
	Stygian = {
		positive = {"Mystery of the Stygian Veil", "Temple Illuminata", "Order of the Umbral Oracle"},
		negative = {"Ordo Amicitia"}
	},
	Faunian = {
		positive = {"Sylvan Fellowship", "Mystery of the Verdant Embrace", "Sanctum Vitalis"},
		negative = {"Eon Fellowship"}
	},
	Silenian = {
		positive = {"Mystery of Red Mirth", "Rex Catonis", "Accord Concordia"},
		negative = {}
	},
	Catosien = {
		positive = {"Rex Catonis", "Ordo Solis Invicti", "Temple Alabaster"},
		negative = {"Celestial Harmony Sect"}
	}
}

local function pickTwoDistinct(nextInt, list)
	if not list or #list < 2 then
		return nil, nil
	end

	local a = pick(nextInt, list)
	local b = pick(nextInt, list)
	local tries = 0

	while a == b and tries < 20 do
		b = pick(nextInt, list)
		tries = tries + 1
	end

	if a == b then
		return list[1], list[2]
	end

	return a, b
end

local function makeEventText(template, a, b)
	local s = tostring(template or "")
	s = s:gsub("{A}", cultLink(a))
	s = s:gsub("{B}", cultLink(b))
	return s
end

local function generateDailyEvents(dateInfo, args)
	args = args or {}
	local count = tonumber(args.events or args.eventcount or args.relationEvents) or 5
	count = clamp(count, 1, 12)

	local seed = hash31("CultRelationsDailyBVR:" .. tostring(dateInfo.year) .. ":" .. tostring(dateInfo.dayOfYear))
	local nextInt = lcg(seed)
	local events = {}
	local used = {}

	local tries = 0
	while #events < count and tries < 100 do
		tries = tries + 1

		local e = pickWeighted(nextInt, eventPool)
		local a, b = pickTwoDistinct(nextInt, e.cults)

		if a and b then
			a, b = normalizeCultName(a), normalizeCultName(b)
			local key = sortedPair(a, b) .. "::" .. tostring(e.title)

			if not used[key] then
				used[key] = true
				table.insert(events, {
					title = e.title,
					type = e.type,
					severity = e.severity,
					a = a,
					b = b,
					delta = tonumber(e.delta) or 0,
					text = makeEventText(e.text, a, b)
				})
			end
		end
	end

	-- Zodiac-season pressure. This makes the current zodiac period matter.
	local fp = festivalPressures[dateInfo.zodiac]
	if fp then
		local pa, pb = pickTwoDistinct(nextInt, fp.positive)
		if pa and pb then
			table.insert(events, {
				title = dateInfo.zodiac .. " ritual alignment",
				type = "zodiac",
				severity = "green",
				a = pa,
				b = pb,
				delta = 5,
				text = cultLink(pa) .. " and " .. cultLink(pb) .. " benefit from shared " .. dateInfo.zodiac .. " ritual emphasis."
			})
		end

		if fp.negative and #fp.negative > 0 and fp.positive and #fp.positive > 0 then
			local na = pick(nextInt, fp.negative)
			local nb = pick(nextInt, fp.positive)
			if na and nb and na ~= nb then
				table.insert(events, {
					title = dateInfo.zodiac .. " doctrinal strain",
					type = "zodiac",
					severity = "yellow",
					a = na,
					b = nb,
					delta = -4,
					text = cultLink(na) .. " experiences doctrinal strain with " .. cultLink(nb) .. " during the " .. dateInfo.zodiac .. " period."
				})
			end
		end
	end

	-- Recurring calendar-cycle pressure.
	if dateInfo.dayOfYear % 17 == 0 then
		table.insert(events, {
			title = "Bacchanalia review",
			type = "governance",
			severity = "green",
			a = "Harmony Sanctum",
			b = "Accord Concordia",
			delta = 8,
			text = cultLink("Harmony Sanctum") .. " and " .. cultLink("Accord Concordia") .. " improve relations during a formal review of mediation protocols."
		})
	end

	if dateInfo.dayOfYear % 29 == 0 then
		table.insert(events, {
			title = "Fire-renewal dispute",
			type = "doctrine",
			severity = "yellow",
			a = "Ignis Aeternum",
			b = "Mystery of the Verdant Embrace",
			delta = -10,
			text = cultLink("Ignis Aeternum") .. " and " .. cultLink("Mystery of the Verdant Embrace") .. " clash over fire-renewal language near cultivated shrine land."
		})
	end

	if dateInfo.dayOfYear % 41 == 0 then
		table.insert(events, {
			title = "Lunar interpretation compact",
			type = "omen",
			severity = "green",
			a = "Conclave Illuminara",
			b = "Order of Aurora Mystica",
			delta = 9,
			text = cultLink("Conclave Illuminara") .. " and " .. cultLink("Order of Aurora Mystica") .. " publish a shared lunar interpretation compact."
		})
	end

	if dateInfo.dayOfYear % 53 == 0 then
		table.insert(events, {
			title = "Order-and-mirth procession",
			type = "festival",
			severity = "green",
			a = "Rex Catonis",
			b = "Mystery of Red Mirth",
			delta = 8,
			text = cultLink("Rex Catonis") .. " and " .. cultLink("Mystery of Red Mirth") .. " coordinate disciplined festival processions without arrests."
		})
	end

	return events
end

--------------------------------------------------------------------------------
-- 6. DAILY RELATION CALCULATION
--------------------------------------------------------------------------------

local function addDelta(scores, notes, a, b, delta, note)
	a, b = normalizeCultName(a), normalizeCultName(b)
	if a == "" or b == "" or a == b then
		return
	end

	local k = sortedPair(a, b)
	scores[k] = clamp((scores[k] or 0) + (tonumber(delta) or 0), -100, 100)
	notes[k] = notes[k] or {}
	table.insert(notes[k], note)
end

local function applyEvent(scores, notes, e)
	addDelta(scores, notes, e.a, e.b, e.delta, e.title)

	-- Severe events harden each participant's pre-existing enemies.
	if e.severity == "red" then
		for _, actor in ipairs({e.a, e.b}) do
			local row = baselineRows[actor]
			for _, enemy in ipairs((row and row.enemies) or {}) do
				addDelta(scores, notes, actor, enemy, -3, "crisis hardening")
			end
		end
	end

	-- Cooperative events improve spillover relations with standing allies.
	if e.severity == "green" then
		for _, actor in ipairs({e.a, e.b}) do
			local row = baselineRows[actor]
			for _, ally in ipairs((row and row.allies) or {}) do
				addDelta(scores, notes, actor, ally, 2, "cooperation spillover")
			end
		end
	end

	-- Yellow events add mild distrust to factions already in rivalry.
	if e.severity == "yellow" then
		for _, actor in ipairs({e.a, e.b}) do
			local row = baselineRows[actor]
			for _, enemy in ipairs((row and row.enemies) or {}) do
				addDelta(scores, notes, actor, enemy, -1, "suspicion spillover")
			end
		end
	end
end

local function buildDailyScores(dateInfo, args)
	local scores, notes = buildBaseScores()
	local events = generateDailyEvents(dateInfo, args)

	for _, e in ipairs(events) do
		applyEvent(scores, notes, e)
	end

	return scores, notes, events
end

function p.getDailyRelations(args)
	args = args or {}
	local dateInfo = getDateInfo(args)
	local scores, notes, events = buildDailyScores(dateInfo, args)

	return {
		date = dateInfo,
		scores = scores,
		notes = notes,
		events = events,
		cults = cults,
		baseline = baselineRows
	}
end

--------------------------------------------------------------------------------
-- 7. DISPLAY HELPERS
--------------------------------------------------------------------------------

local function relationLabel(score)
	score = tonumber(score) or 0
	if score >= 75 then return "Covenantal alliance" end
	if score >= 55 then return "Strong alliance" end
	if score >= 35 then return "Standing alliance" end
	if score >= 15 then return "Working cooperation" end
	if score >= 5 then return "Mild cooperation" end
	if score > -5 then return "Neutral / watchful" end
	if score > -15 then return "Minor tension" end
	if score > -35 then return "Rivalry" end
	if score > -55 then return "Hostile rivalry" end
	if score > -75 then return "Sectarian hostility" end
	return "Sectarian enmity"
end

local function scoreStyle(score)
	score = tonumber(score) or 0
	if score >= 55 then
		return "background:#cfe8cf;"
	elseif score >= 15 then
		return "background:#e8f5e9;"
	elseif score > -15 then
		return "background:#f8f9fa;"
	elseif score > -55 then
		return "background:#fff3cd;"
	else
		return "background:#f8d7da;"
	end
end

local function severityBadge(severity)
	if severity == "red" then
		return '<span style="background:#f8d7da;border:1px solid #c99;padding:1px 5px;border-radius:8px;">Red</span>'
	elseif severity == "yellow" then
		return '<span style="background:#fff3cd;border:1px solid #cc9;padding:1px 5px;border-radius:8px;">Yellow</span>'
	end
	return '<span style="background:#d4edda;border:1px solid #9c9;padding:1px 5px;border-radius:8px;">Green</span>'
end

local function signed(n)
	n = tonumber(n) or 0
	if n > 0 then
		return "+" .. tostring(n)
	end
	return tostring(n)
end

local function getPairScore(data, a, b)
	a, b = normalizeCultName(a), normalizeCultName(b)
	return data.scores[sortedPair(a, b)] or 0
end

local function topPairs(data, limit, mode)
	limit = tonumber(limit) or 25
	mode = trim(mode or "important")

	local rows = {}

	for k, score in pairs(data.scores or {}) do
		local a, b = splitPairKey(k)
		if cultByName[a] and cultByName[b] then
			local item = {
				a = a,
				b = b,
				score = score,
				abs = math.abs(score)
			}

			if mode == "positive" then
				if score > 0 then
					table.insert(rows, item)
				end
			elseif mode == "negative" then
				if score < 0 then
					table.insert(rows, item)
				end
			else
				table.insert(rows, item)
			end
		end
	end

	table.sort(rows, function(x, y)
		if mode == "positive" then
			if x.score == y.score then return x.a .. x.b < y.a .. y.b end
			return x.score > y.score
		elseif mode == "negative" then
			if x.score == y.score then return x.a .. x.b < y.a .. y.b end
			return x.score < y.score
		else
			if x.abs == y.abs then return x.a .. x.b < y.a .. y.b end
			return x.abs > y.abs
		end
	end)

	local out = {}
	for i = 1, math.min(limit, #rows) do
		table.insert(out, rows[i])
	end

	return out
end

local function noteText(data, a, b)
	local k = sortedPair(normalizeCultName(a), normalizeCultName(b))
	local list = data.notes[k] or {}
	if #list == 0 then
		return "neutral baseline"
	end

	local seen = {}
	local out = {}
	for _, note in ipairs(list) do
		if not seen[note] then
			seen[note] = true
			table.insert(out, note)
		end
	end

	return table.concat(out, "; ")
end

--------------------------------------------------------------------------------
-- 8. PUBLIC DISPLAY FUNCTIONS
--------------------------------------------------------------------------------

function p.eventLog(frame)
	local args = frame and frame.args or {}
	local data = p.getDailyRelations(args)

	local out = {}

	table.insert(out, string.format("'''Daily cult-relations event log — %s'''", data.date.longText))
	table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
	table.insert(out, '! Event !! Severity !! Location !! Cults !! Effect !! Notes')

	for _, e in ipairs(data.events or {}) do
table.insert(out, string.format(
	"%s at %s: %s ↔ %s (%s)",
	e.title,
	eventCityLink(e),
	e.a,
	e.b,
	signed(e.delta)
))
	end

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

function p.makePair(frame)
	local args = frame and frame.args or {}
	local a = normalizeCultName(args.a or args[1])
	local b = normalizeCultName(args.b or args[2])

	if a == "" or b == "" then
		return "Specify two cults with <code>|a=</code> and <code>|b=</code>."
	end

	local data = p.getDailyRelations(args)
	local score = getPairScore(data, a, b)

	local out = {}

	table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
	table.insert(out, string.format("|+ Daily cult relation — %s", data.date.longText))
	table.insert(out, "! Cult A !! Cult B !! Score !! Status !! Basis")
	table.insert(out, "|-")
	table.insert(out, string.format(
		"| %s || %s || style='%s text-align:center; font-weight:bold;' | %d || %s || %s",
		cultMark(a),
		cultMark(b),
		scoreStyle(score),
		score,
		relationLabel(score),
		noteText(data, a, b)
	))
	table.insert(out, "|}")

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

function p.makeTable(frame)
	local args = frame and frame.args or {}
	local data = p.getDailyRelations(args)

	local limit = tonumber(args.limit or args.relationsLimit) or 25
	local mode = trim(args.mode or "important")
	local showEvents = lower(args.showEvents or "yes") ~= "no"
	local compact = lower(args.compact or "no") == "yes"

	local rows = topPairs(data, limit, mode)
	local out = {}

	table.insert(out, string.format(
		"<div style='border:1px solid #aaa;background:#f8f9fa;padding:8px;margin:6px 0;'>" ..
		"'''Reformed Stripping Path daily cult-relations index'''<br/>" ..
		"Date: '''%s'''<br/>" ..
		"Current zodiac period: '''%s'''<br/>" ..
		"Relations are recalculated from standing alliances, standing rivalries, mythic affinities, zodiac pressure, and embedded daily events. " ..
		"Positive scores indicate cooperation; negative scores indicate rivalry or hostility." ..
		"</div>",
		data.date.longText,
		data.date.zodiac
	))

	table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
	table.insert(out, "! Cult A !! Cult B !! Score !! Status")
	if not compact then
		table.insert(out, "!! Current basis")
	end

	for _, r in ipairs(rows) do
		table.insert(out, "|-")
		local row = string.format(
			"| %s || %s || style='%s text-align:center; font-weight:bold;' | %d || %s",
			cultMark(r.a),
			cultMark(r.b),
			scoreStyle(r.score),
			r.score,
			relationLabel(r.score)
		)
		if not compact then
			row = row .. " || " .. noteText(data, r.a, r.b)
		end
		table.insert(out, row)
	end

	table.insert(out, "|}")

	if showEvents then
		table.insert(out, "\n" .. p.eventLog({args = args}))
	end

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

function p.makeMatrix(frame)
	local args = frame and frame.args or {}
	local data = p.getDailyRelations(args)

	local out = {}

	table.insert(out, string.format("'''Cult-relations matrix — %s'''", data.date.longText))
	table.insert(out, '{| class="wikitable" style="font-size:85%; text-align:center;"')

	local header = {"! Cult"}
	for _, c in ipairs(cults) do
		table.insert(header, "!" .. c.key)
	end
	table.insert(out, table.concat(header, "\n"))

	for _, a in ipairs(cults) do
		local row = {"|-\n! " .. cultMark(a.name)}
		for _, b in ipairs(cults) do
			if a.name == b.name then
				table.insert(row, "| style='background:#eee;' | —")
			else
				local score = getPairScore(data, a.name, b.name)
				table.insert(row, string.format("| style='%s' | %d", scoreStyle(score), score))
			end
		end
		table.insert(out, table.concat(row, "\n"))
	end

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

function p.cultList(frame)
	local args = frame and frame.args or {}
	local out = {}

	table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
	table.insert(out, "! Key !! Cult / Mystery !! Divine !! Holy city !! General tendency")

	for _, c in ipairs(cults) do
		table.insert(out, "|-")
		table.insert(out, string.format(
			"| %s || %s || [[%s]] || %s || %s",
			c.key,
			cultMark(c.name),
			c.divine,
			cityLink(c.city),
			table.concat(c.tendency or {}, ", ")
		))
	end

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

function p.legend(frame)
	return table.concat({
		'{| class="wikitable" style="width:60%;"',
		'! Score range !! Meaning',
		'|-',
		'| 75 to 100 || Covenantal alliance',
		'|-',
		'| 55 to 74 || Strong alliance',
		'|-',
		'| 35 to 54 || Standing alliance',
		'|-',
		'| 15 to 34 || Working cooperation',
		'|-',
		'| 5 to 14 || Mild cooperation',
		'|-',
		'| -4 to 4 || Neutral / watchful',
		'|-',
		'| -14 to -5 || Minor tension',
		'|-',
		'| -34 to -15 || Rivalry',
		'|-',
		'| -54 to -35 || Hostile rivalry',
		'|-',
		'| -74 to -55 || Sectarian hostility',
		'|-',
		'| -100 to -75 || Sectarian enmity',
		'|}'
	}, "\n")
end

--------------------------------------------------------------------------------
-- 9. RAW / DEBUG FUNCTIONS
--------------------------------------------------------------------------------

function p.rawDate(frame)
	local args = frame and frame.args or {}
	local d = getDateInfo(args)
	return string.format(
		"dayOfYear=%s; month=%s; day=%s; zodiac=%s; year=%s; raw=%s",
		tostring(d.dayOfYear),
		tostring(d.month),
		tostring(d.dom),
		tostring(d.zodiac),
		tostring(d.year),
		tostring(d.raw)
	)
end

function p.rawScore(frame)
	local args = frame and frame.args or {}
	local a = normalizeCultName(args.a or args[1])
	local b = normalizeCultName(args.b or args[2])
	if a == "" or b == "" then
		return "0"
	end
	local data = p.getDailyRelations(args)
	return tostring(getPairScore(data, a, b))
end

function p.rawLabel(frame)
	local args = frame and frame.args or {}
	local a = normalizeCultName(args.a or args[1])
	local b = normalizeCultName(args.b or args[2])
	if a == "" or b == "" then
		return "Neutral / watchful"
	end
	local data = p.getDailyRelations(args)
	return relationLabel(getPairScore(data, a, b))
end

function p.rawEvents(frame)
	local args = frame and frame.args or {}
	local data = p.getDailyRelations(args)
	local out = {}
	for _, e in ipairs(data.events or {}) do
		table.insert(out, string.format(
			"%s: %s ↔ %s (%s)",
			e.title,
			e.a,
			e.b,
			signed(e.delta)
		))
	end
	return table.concat(out, "; ")
end

return p