Zum Inhalt springen
Das Halloween-Event (Süßes oder Saures) findet vom 24.10.2025 bis 07.11.2025 statt.

Modul:Firestone: Unterschied zwischen den Versionen

Aus Firestone Idle RPG Wiki
Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
 
(44 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt)
Zeile 2: Zeile 2:
local M = {}
local M = {}


-- ========= helpers =========
-- ========== kleine Helfer ==========
local COL_OFFER, COL_PRICE, COL_LIMIT = "50%", "25%", "25%"
local DAY = 86400
local function parse_iso_date(s)
  local y,m,d = tostring(s or ""):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
  if not y then return nil end
  return os.time{ year=tonumber(y), month=tonumber(m), day=tonumber(d), hour=12 }
end
local function ts_to_mw(ts) return os.date("!%Y%m%d%H%M%S", ts) end
local function format_date_de(v)
  local ts = (type(v)=="number") and v or parse_iso_date(v)
  if not ts then return "" end
  local lang = mw.getContentLanguage()
  return lang:formatDate("d.m.Y", ts_to_mw(ts))
end
 
local function isempty(v) return v == nil or v == "" end
local function isempty(v) return v == nil or v == "" end
local function norm(s)
local function norm(s)
Zeile 10: Zeile 25:
end
end


local function has_items(t)
-- bevorzugt Parent-Args (Vorlagen)
  if type(t) ~= "table" then return false end
  for _ in pairs(t) do return true end
  return false
end
 
-- bevorzugt Parent-Args (typisch bei Vorlagen)
local function getArgs(frame)
local function getArgs(frame)
   local p = frame:getParent()
   local p = frame:getParent()
   local src = (p and p.args) or frame.args or {}
   local src = (p and p.args) or frame.args or {}
   local a = {}
   local a = {}
   for k,v in pairs(src) do if v ~= nil and v ~= "" then a[k] = mw.text.trim(tostring(v)) end end
   for k,v in pairs(src) do
    if v ~= nil and v ~= "" then a[k] = mw.text.trim(tostring(v)) end
  end
   return a
   return a
end
local function has_items(t)
  if type(t) ~= "table" then return false end
  for _ in pairs(t) do return true end
  return false
end
end


Zeile 45: Zeile 62:
end
end


local function fileTag(file, opts) -- [[File:...]]
local function fileTag(file, opts) -- [[Datei:...]]
   if isempty(file) then return "" end
   if isempty(file) then return "" end
   local parts = { "Datei:"..file }
   local parts = { "Datei:"..file }
   if opts and opts.size   then table.insert(parts, opts.size) end
   if opts and opts.size then table.insert(parts, opts.size) end
   if opts and opts.param then table.insert(parts, opts.param) end -- z.B. "thumb|Text"
   if opts and opts.param then table.insert(parts, opts.param) end -- z.B. "thumb|Text"
   return string.format("[[%s]]", table.concat(parts, "|"))
   return string.format("[[%s]]", table.concat(parts, "|"))
end
end


-- ========= lazy loads =========
local function pretty_range(s)
  if not s or s=="" then return "" end
  s = tostring(s)
  if s:sub(-1) == "-" then return s:sub(1, -2) .. "+" end
  return s:gsub("%-","–")
end
 
-- ========== Lazy-Loads ==========
local I18N, EVENTS, HEROES
local I18N, EVENTS, HEROES
local function i18n()
local function i18n()
Zeile 61: Zeile 85:
     local ok2, mod = pcall(require, t)      ; if ok2 and type(mod) =="table" then I18N=mod ; return I18N end
     local ok2, mod = pcall(require, t)      ; if ok2 and type(mod) =="table" then I18N=mod ; return I18N end
   end
   end
  -- Minimal-Struktur, keine Text-Fallbacks
   I18N = { i18n={}, events={ names={}, sections={default={}}, phrases={}, links={}, unlocks={} } }
   I18N = { i18n={}, events={ names={}, sections={default={}}, infobox={}, links={}, units={}, unlocks={} } }
   return I18N
   return I18N
end
end
Zeile 82: Zeile 105:
end
end


-- ========= i18n helpers =========
-- ========== i18n / Labels ==========
local function tr_simple(kind, key)
  if isempty(key) then return "" end
  local map = (i18n().i18n or {})[kind] or {}
  return map[norm(key)] or key
end
 
local function tr_month(m)    return tr_simple("month", m) end
local function tr_duration(d) return tr_simple("duration", d) end
 
local function ev_label(evkey, which)
local function ev_label(evkey, which)
   local E = i18n().events or {}
   local E = i18n().events or {}
Zeile 98: Zeile 112:
   return (spec[which] or def[which] or "")
   return (spec[which] or def[which] or "")
end
end
local function ev_name(evkey)
local function ev_name(evkey)
   local names = (i18n().events or {}).names or {}
   local names = (i18n().events or {}).names or {}
   return names[norm(evkey)] or evkey
   return names[norm(evkey)] or evkey
end
end
local function header_labels(evkey)
  return ev_label(evkey, "offer"), ev_label(evkey, "price"), ev_label(evkey, "limit")
end
local function autotr(key_name, value)
  if type(value) ~= "string" then return value end
  local k = norm(key_name)


-- ========= event core =========
  -- 1) Standardwortschatz (falls vorhanden)
local function get_event(evkey)
  local m1 = (i18n().i18n or {})[k]
   local evs = events()
  local v  = m1 and m1[norm(value)]
   local real = pick_key(evs, evkey)
  if v then return v end
   return real and evs[real] or nil, real or evkey
 
  -- 2) Spezialfall Event-Typen: i18n().events.infobox.type
  if k == "type" then
    local m2 = (((i18n().events or {}).infobox) or {}).type or {}
    return m2[norm(value)] or value
  end
 
  return value
end
 
local function ev_notice_icon_wikitext(ev)
  local file = ev.currency and ev.currency.icon
  if not file or file == "" then return "" end
  local img = fileTag(file, { size = "24px", param = "frameless|link=" })
  return '<span class="fs-ic">' .. img .. '</span>'
end
 
local function ev_pagelink(ev, evkey, label)
   local page = ev.page
   if page and page ~= "" then
    return string.format("[[%s|%s]]", page, label)
  end
   return label
end
end


-- Plain fields exakt nach deinen Keys
 
local function event_field(ev, key, evkey)
-- ========== Nummern + <ref> (voll i18n-gesteuert) ==========
   if key == "banner" then return ev.banner or ""
local function parse_sci(v)
   elseif key == "type" then
   if type(v) == "number" then return v end
     local = ev.type or ""
   if type(v) == "string" then
     local tr = (((i18n().events or {}).infobox or {}).type or {})[norm(t)]
     local a,e = v:match("^%s*([%d%.]+)[eE]([%+%-]?%d+)%s*$")
    return tr or t
     if a and e then return (tonumber(a) or 0) * 10^(tonumber(e) or 0) end
   elseif key == "month" then
    local n = tonumber(v); if n then return n end
    return tr_month(ev.month or ev.date or "")
   end
   elseif key == "duration" then
  return nil
    return tr_duration(ev.duration or "")
end
   elseif key == "character_level_10" then
local function i18n_big_units()
     -- reine Übersetzung, kein Fallback-Text
   local N = (i18n().numbers or {})
     local f = (i18n().events or {}).unlocks or {}
   return {
     return f.character_level_10 or ""
     {1e12, N.trillion_sing,  N.trillion_plur},
   elseif key == "currency" or key == "currencies"
     {1e9,  N.billion_sing,  N.billion_plur},
      or key == "ex_shop" or key == "shop" or key == "shopname"
    {1e6,  N.million_sing,  N.million_plur},
      or key == "fullname" or key == "shortname"
     {1e3,  N.thousand_sing,  N.thousand_plur},
       or key == "currency_exchange" or key == "avatar_exchange"
  }
       or key == "name" or key == "eventname" then
end
    local k = (key == "eventname") and "name" or key
local function format_big(v)
    return ev_label(evkey, k)
   local n = parse_sci(v); if not n then return tostring(v or "") end
  else
  for _,u in ipairs(i18n_big_units()) do
     return tostring(ev[key] or "")
    local base, sing, plur = u[1], u[2], u[3]
    if base and sing and plur and n >= base and math.floor(n % base) == 0 then
       local q = math.floor(n / base)
       return string.format("%d %s", q, (q==1) and sing or plur)
     end
   end
   end
  return tostring(n)
end
local function ref_gear_power(v)
  if isempty(v) then return "" end
  local phr = (((i18n().events or {}).phrases) or {}).req_gear_power
  if isempty(phr) then return "" end
  local name = "gear-" .. tostring(v):gsub("%s","")
  return string.format('<ref name="%s">%s</ref>', name, (phr:gsub("$1", format_big(v))))
end
end


-- ==/=== Überschriften ==
-- ========== Event-Renderer ==========
local function htag(level, text)
local function htag(level, text)
   if isempty(text) then return "" end
   if isempty(text) then return "" end
Zeile 145: Zeile 198:
end
end


-- [[File:...|thumb|...]]
local function event_file(ev, evkey, which)
local function event_file(ev, evkey, which)
   if which=="deco" then
   if which=="deco" then
Zeile 165: Zeile 217:
end
end


-- History → mit Zeilenumbrüchen um Block-Markup sicher zu rendern
local function event_link(ev, evkey, which)
  if which=="type" then
    local E = i18n().events or {}
    local page  = (E.links and E.links.type and E.links.type[ev.type or ""])
                  or (E.links and E.links.type) or "Calendar Events"
    local map  = (E.infobox and E.infobox.type) or {}
    local label = map[norm(ev.type or "")] or (ev.type or "")
    return string.format("[[%s|%s]]", page, label)
  end
  return ""
end
 
local function event_history(ev)
local function event_history(ev)
   local H = ev.history
   local H = ev.history or {}
   if type(H) ~= "table" or H[1] == nil then return "" end
   if not has_items(H) then return "" end
   local lines = {}
 
  local alt_gp = ((i18n().events or {}).phrases or {}).alt_platform or "auf Google Play"
 
  local function fmt_range_de(s1, s2)
    return string.format("%s bis %s", format_date_de(s1), format_date_de(s2))
  end
 
  -- "2023-10-24 bis 2023-11-07" → "24.10.2023 bis 07.11.2023"
  local function fmt_alt_platform(val)
    local y1,m1,d1,y2,m2,d2 =
      tostring(val or ""):match("^%s*(%d%d%d%d)%-(%d%d)%-(%d%d)%s+bis%s+(%d%d%d%d)%-(%d%d)%-(%d%d)%s*$")
    if y1 then
      return fmt_range_de(y1.."-"..m1.."-"..d1, y2.."-"..m2.."-"..d2)
    end
    -- falls kein ISO-Bereich erkannt wurde: unverändert ausgeben
    return tostring(val or "")
  end
 
   local out = {}
   for _,h in ipairs(H) do
   for _,h in ipairs(H) do
     local base = string.format("* %s: %s bis %s",
     local year = tostring(h.year or "")
      tostring(h.year or ""), tostring(h.start or ""), tostring(h["end"] or ""))
    local s    = h.start or ""
    local e    = h["end"] or ""
 
    -- Start/Ende immer DE-formatiert
    local line = string.format("* %s: %s", year, fmt_range_de(s, e))
 
    -- optionaler Alternativ-Bereich (auch DE-formatiert)
     if h.alt_platform then
     if h.alt_platform then
       base = base .. " (" .. tostring(h.alt_platform) .. ")"
       line = line .. string.format(" (%s %s)", fmt_alt_platform(h.alt_platform), alt_gp)
     end
     end
     table.insert(lines, base)
 
     table.insert(out, line)
   end
   end
   return "\n" .. table.concat(lines, "\n") .. "\n"
   return table.concat(out, "\n")
end
end


-- Avatare (alle Jahre) – robust auch mit mw.loadData
local function item_label(key, amount)
  local I = (i18n().items or {})[key] or {}
  local n = tonumber(amount or 1) or 1
  local txt = (n == 1) and (I.sing or key) or (I.plur or (I.sing or key))
  local disp = I.link and string.format("[[%s|%s]]", I.link, txt) or txt
  return string.format("%d %s", n, disp)
end
 
-- references/render
local FRAME = nil
local function tabber_render_from_content(content)
  if FRAME and FRAME.extensionTag then
    return FRAME:extensionTag('tabber', content)
  end
  return "<tabber>\n" .. content .. "\n</tabber>"
end
local function references_tag()
  if FRAME and FRAME.extensionTag then
    return FRAME:extensionTag('references', '')
  end
  return '<references />'
end
 
-- innerer Tab-Inhalt: je Range
local function build_tabber_content_for(list)
  local out = {}
  for _,it in ipairs(list) do
    local label  = pretty_range(it.range or "")
    local note    = ref_gear_power(it.req_gear_power)
    local content = string.format(
      "%s %s%s",
      fileTag(it.icon or "", { size="30px" }),
      (it.item and item_label(it.item, it.amount or 1)) or (it.title or ""),
      note
    )
    out[#out+1] = "|-|" .. label .. "=\n" .. content
  end
  return table.concat(out, "\n")
end
 
-- Gruppen-Label aus i18n
local function group_label(kind)
  local G = ((i18n().events or {}).group_labels or {})
  return G[kind] or kind
end
 
-- Baut die Avatar-Tabelle für einen Jahrgang
local function build_avatar_table(list, token_icon, token_name)
  local t = {}
  t[#t+1] = '{| class="article-table" style="font-size:14px;"'
  t[#t+1] = '! colspan="2" | Avatar\n! Preis'
  for _,it in ipairs(list) do
    t[#t+1] = '|-'
    t[#t+1] = string.format(
      '| %s || %s || %s %s %s',
      fileTag(it.file,  { size="50px" }),
      it.title or "",
      fileTag(token_icon, { size="25px" }),
      tostring(it.cost or 0),
      token_name
    )
  end
  t[#t+1] = '|}'
  return table.concat(t, "\n")
end
 
-- Avatare als TabberNeue: ein Tab pro Jahr
local function event_avatars(ev, evkey)
local function event_avatars(ev, evkey)
   local AV = ev.avatars
   local AV = ev.avatars
Zeile 187: Zeile 341:


   local token_icon = (ev.currency or {}).icon or ""
   local token_icon = (ev.currency or {}).icon or ""
   local token_name = event_field(ev, "currencies", evkey)
   local token_name = ev_label(evkey, "currencies")


   -- Jahre einsammeln (Zahl oder String), sortieren
   -- Jahre einsammeln und sortieren (aufsteigend)
   local years, out = {}, {}
   local years = {}
   for y,_ in pairs(AV) do table.insert(years, tonumber(y) or y) end
   for y,_ in pairs(AV) do years[#years+1] = tostring(y) end
   table.sort(years, function(a,b) return tostring(a) < tostring(b) end)
  table.sort(years, function(a,b) return a > b end)
   -- Hinweis: Wenn du das neueste Jahr zuerst willst:
  -- table.sort(years, function(a,b) return a > b end)


  -- Tab-Inhalt aufbauen
  local parts = {}
   for _,year in ipairs(years) do
   for _,year in ipairs(years) do
     local list = AV[year] or AV[tostring(year)]
     local list = AV[year] or AV[tonumber(year)]
     if has_items(list) then
     if has_items(list) then
       table.insert(out, ("==== %s ===="):format(year))
       local table_markup = build_avatar_table(list, token_icon, token_name)
      table.insert(out, '{| class="article-table" style="font-size:14px;"')
       parts[#parts+1] = "|-|" .. year .. "=\n" .. table_markup
       table.insert(out, '! colspan="2" | Avatar\n! Preis')
      for _,it in ipairs(list) do
        table.insert(out, "|-")
        table.insert(out, string.format("|%s || %s || %s %s %s",
          fileTag(it.file, { size="50px" }),
          it.title or "",
          fileTag(token_icon, { size="25px" }),
          tostring(it.cost or 0),
          token_name
        ))
      end
      table.insert(out, "|}")
     end
     end
   end
   end


   return (#out > 0) and ("\n" .. table.concat(out, "\n") .. "\n") or ""
   -- Als <tabber> rendern (nutzt deine tabber_render_from_content)
  return tabber_render_from_content(table.concat(parts, "\n"))
end
end


-- Exchange-Tabellen – robuste Längenprüfung + fileTag
-- Austausch-Tabelle (äußerer Tab: Gruppen → innerer Tab: Ranges)
local function event_exchange(ev, evkey)
local function event_exchange_tabs(ev, evkey)
   local ex = ev.exchange or {}
   local ex = ev.exchange or {}
   if not has_items(ex) then return "" end
  local token_icon = (ev.currency or {}).icon or ""
  local token_name = ev_label(evkey, "currencies")
 
   if not (has_items(ex.chests_by_level)
      or has_items(ex.chests_by_stars)
      or has_items(ex.chests_by_oracle)
      or has_items(ex.currencies)) then
    return ""
  end


   local token_icon = (ev.currency or {}).icon or ""
   local outer_parts = {}
   local token_name = event_field(ev, "currencies", evkey)
  local function add_group(kind, list)
    if not has_items(list) then return end
    local inner_content = build_tabber_content_for(list)
    local inner_tabber  = tabber_render_from_content(inner_content)
    outer_parts[#outer_parts+1] = "|-|" .. group_label(kind) .. "=\n" .. inner_tabber
  end
  add_group("level",  ex.chests_by_level)
  add_group("stars",  ex.chests_by_stars)
  add_group("oracle", ex.chests_by_oracle)
 
  local left_tabber = tabber_render_from_content(table.concat(outer_parts, "\n"))
 
  -- Preis/Limit (erste gefundene Gruppe)
  local function pick_price_limit()
    if has_items(ex.chests_by_level)  then return ex.chests_by_level[1].price or 0,  ex.chests_by_level[1].limit or ""  end
    if has_items(ex.chests_by_stars)  then return ex.chests_by_stars[1].price or 0,  ex.chests_by_stars[1].limit or ""  end
    if has_items(ex.chests_by_oracle) then return ex.chests_by_oracle[1].price or 0, ex.chests_by_oracle[1].limit or "" end
    return 0, ""
  end
  local price, limit = pick_price_limit()
 
  local t = {}
  local H_offer, H_price, H_limit = header_labels(evkey)
  t[#t+1] = '{| class="article-table fs-exchange" style="font-size:14px;"'
  t[#t+1] = string.format(
    '! style="width:%s;" | %s !! style="width:%s;" | %s !! style="width:%s;" | %s',
    COL_OFFER, H_offer, COL_PRICE, H_price, COL_LIMIT, H_limit
   )
  t[#t+1] = '|-'
  t[#t+1] = '|' .. left_tabber .. string.format(
    ' || style="text-align:center;" | %s %s %s || style="text-align:center;" | %s',
    fileTag(token_icon, { size="25px" }),
    tostring(price), token_name,
    tostring(limit)
  )


   local function table_for(list, header)
   if has_items(ex.currencies) then
    if not has_items(list) then return "" end
     for _,it in ipairs(ex.currencies) do
    local t = {}
       local offer = string.format('%s %s',
    table.insert(t, '{| class="article-table" style="font-size:14px;"')
    table.insert(t, "! " .. header .. " !! Preis !! Limit")
     for _,it in ipairs(list) do
      table.insert(t, "|-")
       local offer = string.format("%s %s",
         fileTag(it.icon or "", { size="30px" }),
         fileTag(it.icon or "", { size="30px" }),
         it.title or ""
         item_label(it.item, it.amount or 1)
       )
       )
       if it.range then offer = "level " .. it.range .. ":&nbsp;&nbsp;" .. offer end
       t[#t+1] = '|-'
       table.insert(t, string.format("| %s || %s %s %s || %s",
       t[#t+1] = string.format(
        '| %s || style="text-align:center;" | %s %s %s || style="text-align:center;" | %s',
         offer,
         offer,
         fileTag(token_icon, { size="25px" }),
         fileTag(token_icon, { size="25px" }),
         tostring(it.price or 0),
         tostring(it.price or 0), token_name,
        token_name,
         tostring(it.limit or "")
         tostring(it.limit or "")
       ))
       )
     end
     end
    table.insert(t, "|}")
    return table.concat(t, "\n")
   end
   end


   local parts = {}
   t[#t+1] = '|}'
   if ex.chests_by_level  then table.insert(parts, table_for(ex.chests_by_level,  "Angebot")) end
   t[#t+1] = references_tag()
  if ex.chests_by_stars  then table.insert(parts, table_for(ex.chests_by_stars,  "Angebot")) end
   return '\n' .. table.concat(t, '\n')
  if ex.chests_by_oracle then table.insert(parts, table_for(ex.chests_by_oracle, "Angebot")) end
  if ex.currencies      then table.insert(parts, table_for(ex.currencies,      "Angebot")) end
 
   return (#parts > 0) and ("\n" .. table.concat(parts, "\n") .. "\n") or ""
end
end


-- Shop-Angebote
local function event_offers(ev, evkey, which)
local function event_offers(ev, evkey, which)
   local list = (ev.shop or {})[which]
   local list = (ev.shop or {})[which]
   if type(list) ~= "table" or list[1]==nil then return "" end
   if not has_items(list) then return "" end
   local token_icon = (ev.currency or {}).icon or ""
   local token_icon = (ev.currency or {}).icon or ""
   local token_name = event_field(ev, "currencies", evkey)
   local token_name = ev_label(evkey, "currencies")


   local t = {}
   local t = {}
   table.insert(t, '{| class="article-table" style="font-size:14px;"')
   t[#t+1] = '{| class="article-table" style="font-size:14px;"'
   table.insert(t, "! Angebot !! Menge")
   t[#t+1] = "! Angebot !! Menge"
   for _,it in ipairs(list) do
   for _,it in ipairs(list) do
     table.insert(t, "|-")
     t[#t+1] = "|-"
     table.insert(t, string.format("| ''%s''<br />%s || %s %s %s",
     t[#t+1] = string.format("| ''%s''<br />%s || %s %s %s",
       it.name or "",
       it.name or "",
       fileTag(it.image, { size="x50px" }),
       fileTag(it.image, { size="x50px" }),
Zeile 276: Zeile 453:
       tostring(it.token or 0),
       tostring(it.token or 0),
       token_name
       token_name
     ))
     )
   end
   end
   table.insert(t, "|}")
   t[#t+1] = "|}"
   return "\n" .. table.concat(t, "\n") .. "\n"
  return table.concat(t, "\n")
end
 
-- ========== SiteNotice-Support (Datum/Auto-Pick) ==========
local function event_window(ev)
  local S = ev.schedule or ev
  local s = S.start_date; if isempty(s) then return nil end
 
  local st = parse_iso_date(s); if not st then return nil end
 
  local en_excl, en_incl
  if not isempty(S.end_date) then
    -- end_date wird als inklusives Kalenderdatum verstanden
    en_incl = parse_iso_date(S.end_date)
    if not en_incl then return st, nil, nil end
    en_excl = en_incl + DAY
  else
    -- Dauer in Tagen (Standard 14), exklusives Ende = st + d*DAY
    local d = tonumber(S.duration_days) or 14
    en_excl = st + d * DAY
    en_incl = en_excl - DAY
  end
 
  return st, en_excl, en_incl
end
 
local function pick_notice_eventkey()
  local cfg = ((i18n().events or {}).notice or {})
  local pre  = tonumber(cfg.pre_days)  or 7
  local post = tonumber(cfg.post_days) or 3
  local now = os.time()
 
  local running, upcoming, ended_recent = {}, {}, {}
 
  for k, ev in pairs(events()) do
    local st, en_excl = event_window(ev)
    if st and en_excl then
      if now >= st and now < en_excl then
        table.insert(running, { k=k, st=st, en=en_excl })
      elseif now >= st - pre*DAY and now < st then
        table.insert(upcoming, { k=k, st=st, en=en_excl })
      elseif now >= en_excl and now < en_excl + post*DAY then
        table.insert(ended_recent, { k=k, st=st, en=en_excl })
      end
    end
  end
 
  local function by_end(a,b)  return a.en < b.en end
  local function by_start(a,b) return a.st < b.st end
  if #running      > 0 then table.sort(running,      by_end)  ; return running[1].k      end
  if #upcoming    > 0 then table.sort(upcoming,    by_start) ; return upcoming[1].k    end
  if #ended_recent > 0 then table.sort(ended_recent, by_end)  ; return ended_recent[1].k end
  return nil
end
 
local function event_notice(ev, evkey)
  local phrases = (((i18n().events or {}).phrases) or {})
  local PH_RUNNING = phrases.event_notice  -- "Das $1 läuft von $2 bis $3"
  local PH_ENDED  = phrases.event_ended    -- "Das $1 ist beendet. Du kannst verbleibende $4 noch im $5 eintauschen."
 
  -- Ohne Texte kein Hinweis
  if isempty(PH_RUNNING) then return "" end
 
  local st, en_excl, en_incl = event_window(ev); if not st or not en_excl then return "" end
 
  local cfg  = ((i18n().events or {}).notice or {})
  local pre  = tonumber(cfg.pre_days)  or 7  -- z. B. 7 Tage vorher
  local post = tonumber(cfg.post_days) or 3  -- z. B. 3 Tage danach
  local now  = os.time()
 
  local show_from = st - pre  * DAY
  local show_till = en_excl + post * DAY
   if now < show_from or now >= show_till then
    return ""
  end
 
  -- Anzeigename (aus i18n sections oder names)
  local name_label = ev_label(evkey, "eventname")
  if isempty(name_label) then name_label = ev_name(evkey) end
 
  -- Verlinken NUR wenn ev.page existiert
  local name = ev_pagelink(ev, evkey, name_label)
 
  -- Icon aus EventData → currency.icon
  local ic = ev_notice_icon_wikitext(ev)  -- "" wenn kein currency.icon
 
  local text
  if now < en_excl then
    -- läuft / startet bald
    text = PH_RUNNING
      :gsub("$1", name)
      :gsub("$2", format_date_de(st))
      :gsub("$3", format_date_de(en_excl))
  else
    -- beendet (bis zu post_days danach)
    if isempty(PH_ENDED) then return "" end
    -- Labels aus i18n.sections (wie gehabt)
    local curr = ev_label(evkey, "currencies")
    local shop = ev_label(evkey, "ex_shop")
    if isempty(curr) or isempty(shop) then return "" end
    text = PH_ENDED
      :gsub("$1", name)
      :gsub("$4", curr)
      :gsub("$5", shop)
  end
 
  local prefix = (ic ~= "" and (ic .. " ") or "")
  local suffix = (ic ~= "" and (" " .. ic) or "")
 
  return string.format(
    '<div class="fs-notice" data-ev="%s">%s%s%s</div>',
    mw.text.encode(evkey),
    prefix, text, suffix
  )
end
 
-- ========== Dispatcher ==========
local function get_event(evkey)
  local evs = events()
  local real = pick_key(evs, evkey)
  return real and evs[real] or nil, real or evkey
end
end


-- ===== main dispatcher for Event =====
local function dispatch_event(args)
local function dispatch_event(args)
   local evkey = args[2]; if isempty(evkey) then return "" end
   local evkey = args[2]
 
  -- {{Firestone|Event|notice}}  → automatisch wählen
  if evkey and norm(evkey) == "notice" then
    local pick = pick_notice_eventkey()
    if not pick then return "" end
    local ev, r = get_event(pick); if not ev then return "" end
    return event_notice(ev, r)
  end
 
  -- normaler Weg
  if isempty(evkey) then return "" end
   local ev, realkey = get_event(evkey); if not ev then return "" end
   local ev, realkey = get_event(evkey); if not ev then return "" end


   local a3, a4 = args[3], args[4]
   local a3, a4 = args[3], args[4]
   if isempty(a3) or a3=="eventname" then
   if isempty(a3) then return ev_name(realkey) end
    return ev_name(realkey)
  end


  -- plain fields & labels (inkl. 'shop')
   if a3=="notice" then return event_notice(ev, realkey)
   if a3=="banner" or a3=="type" or a3=="month" or a3=="duration"
  elseif a3=="file"   then return event_file(ev, realkey, a4 or "")
    or a3=="character_level_10"
  elseif a3=="link"   then return event_link(ev, realkey, a4 or "")
    or a3=="currency" or a3=="currencies"
  elseif a3=="h2"     then return htag(2, ev_label(realkey, a4 or ""))
    or a3=="ex_shop" or a3=="shop" or a3=="shopname"
  elseif a3=="h3"     then return htag(3, ev_label(realkey, a4 or ""))
    or a3=="fullname" or a3=="shortname"
  elseif a3=="history" then return event_history(ev, realkey, a)
    or a3=="currency_exchange" or a3=="avatar_exchange"
  elseif a3=="avatars" then return event_avatars(ev, realkey)
    or a3=="name" then
  elseif a3=="exchange" then return event_exchange_tabs(ev, realkey)
    return event_field(ev, a3, realkey)
  elseif a3=="offers"   then
    if a4=="start" then return event_offers(ev, realkey, "initial")
    elseif a4=="more" then return event_offers(ev, realkey, "after") end
    return ""
   end
   end


   -- file / headings
   -- Sonderfälle für Währungsnamen aus i18n.sections
   if a3=="file" then return event_file(ev, realkey, a4 or "")
   if a3 == "currency"  then return ev_label(realkey, "currency") end
  elseif a3=="h2"  then return htag(2, ev_label(realkey, a4 or ""))
   if a3 == "currencies" then return ev_label(realkey, "currencies") end
   elseif a3=="h3" then return htag(3, ev_label(realkey, a4 or "")) end


   -- composed blocks
   -- generischer Pfad in EventData
   if a3=="history"  then return event_history(ev)
   local path, i = {}, 3
   elseif a3=="avatars"  then return event_avatars(ev, realkey)
   while args[i] do table.insert(path, args[i]); i=i+1 end
   elseif a3=="exchange" then return event_exchange(ev, realkey)
   local v = deep_get(ev, path)
   elseif a3=="offers" and (a4=="start" or a4=="more") then
   if v ~= nil then
     return event_offers(ev, realkey, (a4=="start") and "initial" or "after")
    local last = path[#path] and tostring(path[#path]) or ""
    v = autotr(last, v)
     return tostring(v)
   end
   end
  -- Fallback auf Labels/Name/Unlocks
  local last = norm(path[#path] or a3)
  if last=="eventname" or last=="name" then return ev_name(realkey) end
  local lbl = ev_label(realkey, last); if not isempty(lbl) then return lbl end
  local U = ((i18n().events or {}).unlocks or {})[last]
  if U then return U end


   return ""
   return ""
end
end


-- ===== hero (für Vollständigkeit) =====
local function dispatch_hero(args)
local function dispatch_hero(args)
   local name = args[2]; if isempty(name) then return "" end
   local name = args[2]; if isempty(name) then return "" end
Zeile 328: Zeile 643:
   while args[i] do table.insert(path, args[i]); i=i+1 end
   while args[i] do table.insert(path, args[i]); i=i+1 end
   local v = deep_get(h, path)
   local v = deep_get(h, path)
  local last = path[#path] and tostring(path[#path]) or ""
  v = autotr(last, v)
   return tostring(v or "")
   return tostring(v or "")
end
end


-- ===== main =====
-- ========== main ==========
function M.main(frame)
function M.main(frame)
  FRAME = frame
   local a = getArgs(frame)
   local a = getArgs(frame)
   for i=1,10 do a[i] = a[i] and tostring(a[i]) or nil end
   for i=1,10 do a[i] = a[i] and tostring(a[i]) or nil end
   local dom = a[1] and norm(a[1]) or ""
   local dom = a[1] and norm(a[1]) or ""
   if dom=="event" then return dispatch_event(a)
   if dom=="event" then
   elseif dom=="hero" then return dispatch_hero(a)
    return dispatch_event(a)
   else return "" end
   elseif dom=="hero" then
    return dispatch_hero(a)
   else
    return ""
  end
end
end


return M
return M

Aktuelle Version vom 28. Oktober 2025, 13:53 Uhr

Die Dokumentation für dieses Modul kann unter Modul:Firestone/Doku erstellt werden

-- Modul:Firestone
local M = {}

-- ========== kleine Helfer ==========
local COL_OFFER, COL_PRICE, COL_LIMIT = "50%", "25%", "25%"
local DAY = 86400
local function parse_iso_date(s)
  local y,m,d = tostring(s or ""):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
  if not y then return nil end
  return os.time{ year=tonumber(y), month=tonumber(m), day=tonumber(d), hour=12 }
end
local function ts_to_mw(ts) return os.date("!%Y%m%d%H%M%S", ts) end
local function format_date_de(v)
  local ts = (type(v)=="number") and v or parse_iso_date(v)
  if not ts then return "" end
  local lang = mw.getContentLanguage()
  return lang:formatDate("d.m.Y", ts_to_mw(ts))
end

local function isempty(v) return v == nil or v == "" end
local function norm(s)
  if s == nil then return "" end
  s = mw.text.trim(tostring(s))
  return mw.ustring.lower(s):gsub("%s+"," ")
end

-- bevorzugt Parent-Args (Vorlagen)
local function getArgs(frame)
  local p = frame:getParent()
  local src = (p and p.args) or frame.args or {}
  local a = {}
  for k,v in pairs(src) do
    if v ~= nil and v ~= "" then a[k] = mw.text.trim(tostring(v)) end
  end
  return a
end

local function has_items(t)
  if type(t) ~= "table" then return false end
  for _ in pairs(t) do return true end
  return false
end

local function pick_key(tbl, key)
  if type(tbl) ~= "table" then return nil end
  if tbl[key] ~= nil then return key end
  local k2 = tostring(key)
  if tbl[k2] ~= nil then return k2 end
  local kn = norm(k2)
  for k,_ in pairs(tbl) do if norm(k) == kn then return k end end
  return nil
end

local function deep_get(tbl, path)
  local cur = tbl
  for _,k in ipairs(path) do
    if type(cur) ~= "table" then return nil end
    local real = pick_key(cur, k); if not real then return nil end
    cur = cur[real]
  end
  return cur
end

local function fileTag(file, opts) -- [[Datei:...]]
  if isempty(file) then return "" end
  local parts = { "Datei:"..file }
  if opts and opts.size  then table.insert(parts, opts.size)  end
  if opts and opts.param then table.insert(parts, opts.param) end -- z.B. "thumb|Text"
  return string.format("[[%s]]", table.concat(parts, "|"))
end

local function pretty_range(s)
  if not s or s=="" then return "" end
  s = tostring(s)
  if s:sub(-1) == "-" then return s:sub(1, -2) .. "+" end
  return s:gsub("%-","–")
end

-- ========== Lazy-Loads ==========
local I18N, EVENTS, HEROES
local function i18n()
  if I18N then return I18N end
  for _,t in ipairs{ "Modul:HeroI18n", "Module:HeroI18n" } do
    local ok, data = pcall(mw.loadData, t) ; if ok and type(data)=="table" then I18N=data; return I18N end
    local ok2, mod = pcall(require, t)      ; if ok2 and type(mod) =="table" then I18N=mod ; return I18N end
  end
  I18N = { i18n={}, events={ names={}, sections={default={}}, phrases={}, links={}, unlocks={} } }
  return I18N
end
local function events()
  if EVENTS then return EVENTS end
  for _,t in ipairs{ "Modul:EventData", "Module:EventData" } do
    local ok, data = pcall(mw.loadData, t) ; if ok and type(data)=="table" and type(data.events)=="table" then EVENTS=data.events; return EVENTS end
    local ok2, mod = pcall(require, t)     ; if ok2 and type(mod) =="table" and type(mod.events)=="table" then EVENTS=mod.events ; return EVENTS end
  end
  EVENTS = {}; return EVENTS
end
local function heroes()
  if HEROES then return HEROES end
  for _,t in ipairs{ "Modul:HeroData", "Module:HeroData" } do
    local ok, data = pcall(mw.loadData, t) ; if ok and type(data)=="table" and type(data.heroes)=="table" then HEROES=data.heroes; return HEROES end
    local ok2, mod = pcall(require, t)     ; if ok2 and type(mod) =="table" and type(mod.heroes)=="table" then HEROES=mod.heroes ; return HEROES end
  end
  HEROES = {}; return HEROES
end

-- ========== i18n / Labels ==========
local function ev_label(evkey, which)
  local E = i18n().events or {}
  local spec = (E.sections and E.sections[norm(evkey)]) or {}
  local def  = (E.sections and E.sections.default) or {}
  return (spec[which] or def[which] or "")
end
local function ev_name(evkey)
  local names = (i18n().events or {}).names or {}
  return names[norm(evkey)] or evkey
end
local function header_labels(evkey)
  return ev_label(evkey, "offer"), ev_label(evkey, "price"), ev_label(evkey, "limit")
end
local function autotr(key_name, value)
  if type(value) ~= "string" then return value end
  local k = norm(key_name)

  -- 1) Standardwortschatz (falls vorhanden)
  local m1 = (i18n().i18n or {})[k]
  local v  = m1 and m1[norm(value)]
  if v then return v end

  -- 2) Spezialfall Event-Typen: i18n().events.infobox.type
  if k == "type" then
    local m2 = (((i18n().events or {}).infobox) or {}).type or {}
    return m2[norm(value)] or value
  end

  return value
end

local function ev_notice_icon_wikitext(ev)
  local file = ev.currency and ev.currency.icon
  if not file or file == "" then return "" end
  local img = fileTag(file, { size = "24px", param = "frameless|link=" })
  return '<span class="fs-ic">' .. img .. '</span>'
end

local function ev_pagelink(ev, evkey, label)
  local page = ev.page
  if page and page ~= "" then
    return string.format("[[%s|%s]]", page, label)
  end
  return label
end


-- ========== Nummern + <ref> (voll i18n-gesteuert) ==========
local function parse_sci(v)
  if type(v) == "number" then return v end
  if type(v) == "string" then
    local a,e = v:match("^%s*([%d%.]+)[eE]([%+%-]?%d+)%s*$")
    if a and e then return (tonumber(a) or 0) * 10^(tonumber(e) or 0) end
    local n = tonumber(v); if n then return n end
  end
  return nil
end
local function i18n_big_units()
  local N = (i18n().numbers or {})
  return {
    {1e12, N.trillion_sing,  N.trillion_plur},
    {1e9,  N.billion_sing,   N.billion_plur},
    {1e6,  N.million_sing,   N.million_plur},
    {1e3,  N.thousand_sing,  N.thousand_plur},
  }
end
local function format_big(v)
  local n = parse_sci(v); if not n then return tostring(v or "") end
  for _,u in ipairs(i18n_big_units()) do
    local base, sing, plur = u[1], u[2], u[3]
    if base and sing and plur and n >= base and math.floor(n % base) == 0 then
      local q = math.floor(n / base)
      return string.format("%d %s", q, (q==1) and sing or plur)
    end
  end
  return tostring(n)
end
local function ref_gear_power(v)
  if isempty(v) then return "" end
  local phr = (((i18n().events or {}).phrases) or {}).req_gear_power
  if isempty(phr) then return "" end
  local name = "gear-" .. tostring(v):gsub("%s","")
  return string.format('<ref name="%s">%s</ref>', name, (phr:gsub("$1", format_big(v))))
end

-- ========== Event-Renderer ==========
local function htag(level, text)
  if isempty(text) then return "" end
  local eq = level==3 and "===" or "=="
  return string.format("%s %s %s", eq, text, eq)
end

local function event_file(ev, evkey, which)
  if which=="deco" then
    local file   = ev.images and ev.images.deco or ""
    local legend = ev_label(evkey, "deco")
    return fileTag(file, { param = "thumb|"..legend })
  elseif which=="fullname" then
    local file   = ev.images and ev.images.fullname or ""
    local legend = ev_label(evkey, "fullname")
    return fileTag(file, { param = "thumb|"..legend })
  elseif which=="ex_shop" then
    local file   = ev.images and ev.images.ex_shop or ""
    local legend = ev_label(evkey, "ex_shop")
    return fileTag(file, { param = "thumb|"..legend })
  elseif which=="banner" then
    return fileTag(ev.banner, { param="thumb" })
  end
  return ""
end

local function event_link(ev, evkey, which)
  if which=="type" then
    local E = i18n().events or {}
    local page  = (E.links and E.links.type and E.links.type[ev.type or ""])
                  or (E.links and E.links.type) or "Calendar Events"
    local map   = (E.infobox and E.infobox.type) or {}
    local label = map[norm(ev.type or "")] or (ev.type or "")
    return string.format("[[%s|%s]]", page, label)
  end
  return ""
end

local function event_history(ev)
  local H = ev.history or {}
  if not has_items(H) then return "" end

  local alt_gp = ((i18n().events or {}).phrases or {}).alt_platform or "auf Google Play"

  local function fmt_range_de(s1, s2)
    return string.format("%s bis %s", format_date_de(s1), format_date_de(s2))
  end

  -- "2023-10-24 bis 2023-11-07" → "24.10.2023 bis 07.11.2023"
  local function fmt_alt_platform(val)
    local y1,m1,d1,y2,m2,d2 =
      tostring(val or ""):match("^%s*(%d%d%d%d)%-(%d%d)%-(%d%d)%s+bis%s+(%d%d%d%d)%-(%d%d)%-(%d%d)%s*$")
    if y1 then
      return fmt_range_de(y1.."-"..m1.."-"..d1, y2.."-"..m2.."-"..d2)
    end
    -- falls kein ISO-Bereich erkannt wurde: unverändert ausgeben
    return tostring(val or "")
  end

  local out = {}
  for _,h in ipairs(H) do
    local year = tostring(h.year or "")
    local s    = h.start or ""
    local e    = h["end"] or ""

    -- Start/Ende immer DE-formatiert
    local line = string.format("* %s: %s", year, fmt_range_de(s, e))

    -- optionaler Alternativ-Bereich (auch DE-formatiert)
    if h.alt_platform then
      line = line .. string.format(" (%s %s)", fmt_alt_platform(h.alt_platform), alt_gp)
    end

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

local function item_label(key, amount)
  local I = (i18n().items or {})[key] or {}
  local n = tonumber(amount or 1) or 1
  local txt = (n == 1) and (I.sing or key) or (I.plur or (I.sing or key))
  local disp = I.link and string.format("[[%s|%s]]", I.link, txt) or txt
  return string.format("%d %s", n, disp)
end

-- references/render
local FRAME = nil
local function tabber_render_from_content(content)
  if FRAME and FRAME.extensionTag then
    return FRAME:extensionTag('tabber', content)
  end
  return "<tabber>\n" .. content .. "\n</tabber>"
end
local function references_tag()
  if FRAME and FRAME.extensionTag then
    return FRAME:extensionTag('references', '')
  end
  return '<references />'
end

-- innerer Tab-Inhalt: je Range
local function build_tabber_content_for(list)
  local out = {}
  for _,it in ipairs(list) do
    local label   = pretty_range(it.range or "")
    local note    = ref_gear_power(it.req_gear_power)
    local content = string.format(
      "%s %s%s",
      fileTag(it.icon or "", { size="30px" }),
      (it.item and item_label(it.item, it.amount or 1)) or (it.title or ""),
      note
    )
    out[#out+1] = "|-|" .. label .. "=\n" .. content
  end
  return table.concat(out, "\n")
end

-- Gruppen-Label aus i18n
local function group_label(kind)
  local G = ((i18n().events or {}).group_labels or {})
  return G[kind] or kind
end

-- Baut die Avatar-Tabelle für einen Jahrgang
local function build_avatar_table(list, token_icon, token_name)
  local t = {}
  t[#t+1] = '{| class="article-table" style="font-size:14px;"'
  t[#t+1] = '! colspan="2" | Avatar\n! Preis'
  for _,it in ipairs(list) do
    t[#t+1] = '|-'
    t[#t+1] = string.format(
      '| %s || %s || %s %s %s',
      fileTag(it.file,  { size="50px" }),
      it.title or "",
      fileTag(token_icon, { size="25px" }),
      tostring(it.cost or 0),
      token_name
    )
  end
  t[#t+1] = '|}'
  return table.concat(t, "\n")
end

-- Avatare als TabberNeue: ein Tab pro Jahr
local function event_avatars(ev, evkey)
  local AV = ev.avatars
  if not has_items(AV) then return "" end

  local token_icon = (ev.currency or {}).icon or ""
  local token_name = ev_label(evkey, "currencies")

  -- Jahre einsammeln und sortieren (aufsteigend)
  local years = {}
  for y,_ in pairs(AV) do years[#years+1] = tostring(y) end
  table.sort(years, function(a,b) return a > b end)
  -- Hinweis: Wenn du das neueste Jahr zuerst willst:
  -- table.sort(years, function(a,b) return a > b end)

  -- Tab-Inhalt aufbauen
  local parts = {}
  for _,year in ipairs(years) do
    local list = AV[year] or AV[tonumber(year)]
    if has_items(list) then
      local table_markup = build_avatar_table(list, token_icon, token_name)
      parts[#parts+1] = "|-|" .. year .. "=\n" .. table_markup
    end
  end

  -- Als <tabber> rendern (nutzt deine tabber_render_from_content)
  return tabber_render_from_content(table.concat(parts, "\n"))
end

-- Austausch-Tabelle (äußerer Tab: Gruppen → innerer Tab: Ranges)
local function event_exchange_tabs(ev, evkey)
  local ex = ev.exchange or {}
  local token_icon = (ev.currency or {}).icon or ""
  local token_name = ev_label(evkey, "currencies")

  if not (has_items(ex.chests_by_level)
       or has_items(ex.chests_by_stars)
       or has_items(ex.chests_by_oracle)
       or has_items(ex.currencies)) then
    return ""
  end

  local outer_parts = {}
  local function add_group(kind, list)
    if not has_items(list) then return end
    local inner_content = build_tabber_content_for(list)
    local inner_tabber  = tabber_render_from_content(inner_content)
    outer_parts[#outer_parts+1] = "|-|" .. group_label(kind) .. "=\n" .. inner_tabber
  end
  add_group("level",  ex.chests_by_level)
  add_group("stars",  ex.chests_by_stars)
  add_group("oracle", ex.chests_by_oracle)

  local left_tabber = tabber_render_from_content(table.concat(outer_parts, "\n"))

  -- Preis/Limit (erste gefundene Gruppe)
  local function pick_price_limit()
    if has_items(ex.chests_by_level)  then return ex.chests_by_level[1].price or 0,  ex.chests_by_level[1].limit or ""  end
    if has_items(ex.chests_by_stars)  then return ex.chests_by_stars[1].price or 0,  ex.chests_by_stars[1].limit or ""  end
    if has_items(ex.chests_by_oracle) then return ex.chests_by_oracle[1].price or 0, ex.chests_by_oracle[1].limit or "" end
    return 0, ""
  end
  local price, limit = pick_price_limit()

  local t = {}
  local H_offer, H_price, H_limit = header_labels(evkey)
  t[#t+1] = '{| class="article-table fs-exchange" style="font-size:14px;"'
  t[#t+1] = string.format(
    '! style="width:%s;" | %s !! style="width:%s;" | %s !! style="width:%s;" | %s',
    COL_OFFER, H_offer, COL_PRICE, H_price, COL_LIMIT, H_limit
  )
  t[#t+1] = '|-'
  t[#t+1] = '|' .. left_tabber .. string.format(
    ' || style="text-align:center;" | %s %s %s || style="text-align:center;" | %s',
    fileTag(token_icon, { size="25px" }),
    tostring(price), token_name,
    tostring(limit)
  )

  if has_items(ex.currencies) then
    for _,it in ipairs(ex.currencies) do
      local offer = string.format('%s %s',
        fileTag(it.icon or "", { size="30px" }),
        item_label(it.item, it.amount or 1)
      )
      t[#t+1] = '|-'
      t[#t+1] = string.format(
        '| %s || style="text-align:center;" | %s %s %s || style="text-align:center;" | %s',
        offer,
        fileTag(token_icon, { size="25px" }),
        tostring(it.price or 0), token_name,
        tostring(it.limit or "")
      )
    end
  end

  t[#t+1] = '|}'
  t[#t+1] = references_tag()
  return '\n' .. table.concat(t, '\n')
end

local function event_offers(ev, evkey, which)
  local list = (ev.shop or {})[which]
  if not has_items(list) then return "" end
  local token_icon = (ev.currency or {}).icon or ""
  local token_name = ev_label(evkey, "currencies")

  local t = {}
  t[#t+1] = '{| class="article-table" style="font-size:14px;"'
  t[#t+1] = "! Angebot !! Menge"
  for _,it in ipairs(list) do
    t[#t+1] = "|-"
    t[#t+1] = string.format("| ''%s''<br />%s || %s %s %s",
      it.name or "",
      fileTag(it.image, { size="x50px" }),
      fileTag(token_icon, { size="30px" }),
      tostring(it.token or 0),
      token_name
    )
  end
  t[#t+1] = "|}"
  return table.concat(t, "\n")
end

-- ========== SiteNotice-Support (Datum/Auto-Pick) ==========
local function event_window(ev)
  local S = ev.schedule or ev
  local s = S.start_date; if isempty(s) then return nil end

  local st = parse_iso_date(s); if not st then return nil end

  local en_excl, en_incl
  if not isempty(S.end_date) then
    -- end_date wird als inklusives Kalenderdatum verstanden
    en_incl = parse_iso_date(S.end_date)
    if not en_incl then return st, nil, nil end
    en_excl = en_incl + DAY
  else
    -- Dauer in Tagen (Standard 14), exklusives Ende = st + d*DAY
    local d = tonumber(S.duration_days) or 14
    en_excl = st + d * DAY
    en_incl = en_excl - DAY
  end

  return st, en_excl, en_incl
end

local function pick_notice_eventkey()
  local cfg = ((i18n().events or {}).notice or {})
  local pre  = tonumber(cfg.pre_days)  or 7
  local post = tonumber(cfg.post_days) or 3
  local now = os.time()

  local running, upcoming, ended_recent = {}, {}, {}

  for k, ev in pairs(events()) do
    local st, en_excl = event_window(ev)
    if st and en_excl then
      if now >= st and now < en_excl then
        table.insert(running, { k=k, st=st, en=en_excl })
      elseif now >= st - pre*DAY and now < st then
        table.insert(upcoming, { k=k, st=st, en=en_excl })
      elseif now >= en_excl and now < en_excl + post*DAY then
        table.insert(ended_recent, { k=k, st=st, en=en_excl })
      end
    end
  end

  local function by_end(a,b)   return a.en < b.en end
  local function by_start(a,b) return a.st < b.st end
  if #running      > 0 then table.sort(running,      by_end)   ; return running[1].k      end
  if #upcoming     > 0 then table.sort(upcoming,     by_start) ; return upcoming[1].k     end
  if #ended_recent > 0 then table.sort(ended_recent, by_end)   ; return ended_recent[1].k end
  return nil
end

local function event_notice(ev, evkey)
  local phrases = (((i18n().events or {}).phrases) or {})
  local PH_RUNNING = phrases.event_notice   -- "Das $1 läuft von $2 bis $3"
  local PH_ENDED   = phrases.event_ended    -- "Das $1 ist beendet. Du kannst verbleibende $4 noch im $5 eintauschen."

  -- Ohne Texte kein Hinweis
  if isempty(PH_RUNNING) then return "" end

  local st, en_excl, en_incl = event_window(ev); if not st or not en_excl then return "" end

  local cfg  = ((i18n().events or {}).notice or {})
  local pre  = tonumber(cfg.pre_days)  or 7   -- z. B. 7 Tage vorher
  local post = tonumber(cfg.post_days) or 3   -- z. B. 3 Tage danach
  local now  = os.time()

  local show_from = st - pre  * DAY
  local show_till = en_excl + post * DAY
  if now < show_from or now >= show_till then
    return ""
  end

  -- Anzeigename (aus i18n sections oder names)
  local name_label = ev_label(evkey, "eventname")
  if isempty(name_label) then name_label = ev_name(evkey) end

  -- Verlinken NUR wenn ev.page existiert
  local name = ev_pagelink(ev, evkey, name_label)

  -- Icon aus EventData → currency.icon
  local ic = ev_notice_icon_wikitext(ev)  -- "" wenn kein currency.icon

  local text
  if now < en_excl then
    -- läuft / startet bald
    text = PH_RUNNING
      :gsub("$1", name)
      :gsub("$2", format_date_de(st))
      :gsub("$3", format_date_de(en_excl))
  else
    -- beendet (bis zu post_days danach)
    if isempty(PH_ENDED) then return "" end
    -- Labels aus i18n.sections (wie gehabt)
    local curr = ev_label(evkey, "currencies")
    local shop = ev_label(evkey, "ex_shop")
    if isempty(curr) or isempty(shop) then return "" end
    text = PH_ENDED
      :gsub("$1", name)
      :gsub("$4", curr)
      :gsub("$5", shop)
  end

  local prefix = (ic ~= "" and (ic .. " ") or "")
  local suffix = (ic ~= "" and (" " .. ic) or "")

  return string.format(
    '<div class="fs-notice" data-ev="%s">%s%s%s</div>',
    mw.text.encode(evkey),
    prefix, text, suffix
  )
end

-- ========== Dispatcher ==========
local function get_event(evkey)
  local evs = events()
  local real = pick_key(evs, evkey)
  return real and evs[real] or nil, real or evkey
end

local function dispatch_event(args)
  local evkey = args[2]

  -- {{Firestone|Event|notice}}  → automatisch wählen
  if evkey and norm(evkey) == "notice" then
    local pick = pick_notice_eventkey()
    if not pick then return "" end
    local ev, r = get_event(pick); if not ev then return "" end
    return event_notice(ev, r)
  end

  -- normaler Weg
  if isempty(evkey) then return "" end
  local ev, realkey = get_event(evkey); if not ev then return "" end

  local a3, a4 = args[3], args[4]
  if isempty(a3) then return ev_name(realkey) end

  if a3=="notice"  then return event_notice(ev, realkey)
  elseif a3=="file"   then return event_file(ev, realkey, a4 or "")
  elseif a3=="link"   then return event_link(ev, realkey, a4 or "")
  elseif a3=="h2"     then return htag(2, ev_label(realkey, a4 or ""))
  elseif a3=="h3"     then return htag(3, ev_label(realkey, a4 or ""))
  elseif a3=="history"  then return event_history(ev, realkey, a)
  elseif a3=="avatars"  then return event_avatars(ev, realkey)
  elseif a3=="exchange" then return event_exchange_tabs(ev, realkey)
  elseif a3=="offers"   then
    if a4=="start" then return event_offers(ev, realkey, "initial")
    elseif a4=="more" then return event_offers(ev, realkey, "after") end
    return ""
  end

  -- Sonderfälle für Währungsnamen aus i18n.sections
  if a3 == "currency"  then return ev_label(realkey, "currency")  end
  if a3 == "currencies" then return ev_label(realkey, "currencies") end

  -- generischer Pfad in EventData
  local path, i = {}, 3
  while args[i] do table.insert(path, args[i]); i=i+1 end
  local v = deep_get(ev, path)
  if v ~= nil then
    local last = path[#path] and tostring(path[#path]) or ""
    v = autotr(last, v)
    return tostring(v)
  end

  -- Fallback auf Labels/Name/Unlocks
  local last = norm(path[#path] or a3)
  if last=="eventname" or last=="name" then return ev_name(realkey) end
  local lbl = ev_label(realkey, last); if not isempty(lbl) then return lbl end
  local U = ((i18n().events or {}).unlocks or {})[last]
  if U then return U end

  return ""
end

local function dispatch_hero(args)
  local name = args[2]; if isempty(name) then return "" end
  local H = heroes()
  local real = pick_key(H, name); if not real then return "" end
  local h = H[real]
  local path, i = {}, 3
  while args[i] do table.insert(path, args[i]); i=i+1 end
  local v = deep_get(h, path)
  local last = path[#path] and tostring(path[#path]) or ""
  v = autotr(last, v)
  return tostring(v or "")
end

-- ========== main ==========
function M.main(frame)
  FRAME = frame
  local a = getArgs(frame)
  for i=1,10 do a[i] = a[i] and tostring(a[i]) or nil end
  local dom = a[1] and norm(a[1]) or ""
  if dom=="event" then
    return dispatch_event(a)
  elseif dom=="hero" then
    return dispatch_hero(a)
  else
    return ""
  end
end

return M