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
 
(46 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


-- bevorzugt Parent-Args (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 38: Zeile 62:
end
end


local function fileTag(file, opts)
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
   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 54: 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
   I18N = { i18n={}, events={ names={}, sections={default={}}, infobox={}, links={}, phrases={}, unlocks={} } }
   I18N = { i18n={}, events={ names={}, sections={default={}}, phrases={}, links={}, unlocks={} } }
   return I18N
   return I18N
end
end
Zeile 74: 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 92: Zeile 115:
   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
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
end


-- ========= event core =========
local function ev_pagelink(ev, evkey, label)
local function get_event(evkey)
   local page = ev.page
   local evs = events()
   if page and page ~= "" then
   local real = pick_key(evs, evkey)
    return string.format("[[%s|%s]]", page, label)
   return real and evs[real] or nil, real or evkey
  end
   return label
end
end


-- nur die in der Vorlage verwendeten Keys
 
local function event_field(ev, key, evkey)
-- ========== Nummern + <ref> (voll i18n-gesteuert) ==========
   if key == "banner" then
local function parse_sci(v)
    return ev.banner or ""
   if type(v) == "number" then return v end
   elseif key == "type" then
   if type(v) == "string" then
     local t = ev.type or ""
     local a,e = v:match("^%s*([%d%.]+)[eE]([%+%-]?%d+)%s*$")
     local map = ((i18n().events or {}).infobox or {}).type or {}
     if a and e then return (tonumber(a) or 0) * 10^(tonumber(e) or 0) end
     return map[norm(t)] or t
     local n = tonumber(v); if n then return n end
   elseif key == "month" then
   end
    return tr_month(ev.month or ev.date or "")
  return nil
  elseif key == "duration" then
end
    return tr_duration(ev.duration or "")
local function i18n_big_units()
   elseif key == "character_level_10" then
   local N = (i18n().numbers or {})
    local u = ((i18n().events or {}).unlocks or {})["character_level_10"]
  return {
    return u or ""                       -- kein Fallback-Text
    {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


-- ========== 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 155: Zeile 219:
local function event_link(ev, evkey, which)
local function event_link(ev, evkey, which)
   if which=="type" then
   if which=="type" then
     local page = (((i18n().events or {}).links or {}).type or {})[norm(ev.type or "")]
     local E = i18n().events or {}
    if isempty(page) then return event_field(ev, "type", evkey) end
    local page  = (E.links and E.links.type and E.links.type[ev.type or ""])
     return string.format("[[%s|%s]]", page, event_field(ev, "type", evkey))
                  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
   end
   return ""
   return ""
Zeile 163: Zeile 230:


local function event_history(ev)
local function event_history(ev)
   local H = ev.history or {}; if #H==0 then return "" end
   local H = ev.history or {}
   local alt_gp = ((i18n().events or {}).phrases or {}).alt_platform or ""
  if not has_items(H) then return "" end
   local t = {}
 
   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 line = string.format("* %s: %s bis %s", tostring(h.year or ""), tostring(h.start or ""), tostring(h["end"] or ""))
     local year = tostring(h.year or "")
     if h.alt_platform and alt_gp ~= "" then
    local s    = h.start or ""
       line = line .. string.format(" (%s %s)", tostring(h.alt_platform), alt_gp)
    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
     end
     table.insert(t, line)
 
     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
   end
   return "\n"..table.concat(t, "\n").."\n"
  t[#t+1] = '|}'
   return table.concat(t, "\n")
end
end


-- Avatare als TabberNeue: ein Tab pro Jahr
local function event_avatars(ev, evkey)
local function event_avatars(ev, evkey)
   local AV = ev.avatars or {}
   local AV = ev.avatars
   local token_icon = ev.currency and ev.currency.icon or ""
  if not has_items(AV) then return "" end
   local token_name = event_field(ev, "currencies", evkey)
 
   local out, years = {}, {}
   local token_icon = (ev.currency or {}).icon or ""
   for y,_ in pairs(AV) do table.insert(years, y) end
   local token_name = ev_label(evkey, "currencies")
   table.sort(years)
 
  -- 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
   for _,year in ipairs(years) do
     table.insert(out, string.format("==== %s ====", year))
     local list = AV[year] or AV[tonumber(year)]
     table.insert(out, '{| class="article-table" style="font-size:14px;"')
     if has_items(list) then
    table.insert(out, '! colspan="2" | Avatar\n! Preis')
      local table_markup = build_avatar_table(list, token_icon, token_name)
    for _,it in ipairs(AV[year]) do
      parts[#parts+1] = "|-|" .. year .. "=\n" .. table_markup
      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
     end
    table.insert(out, "|}")
   end
   end
   return table.concat(out, "\n")
 
  -- Als <tabber> rendern (nutzt deine tabber_render_from_content)
   return tabber_render_from_content(table.concat(parts, "\n"))
end
end


local function event_exchange(ev, evkey)
-- Austausch-Tabelle (äußerer Tab: Gruppen → innerer Tab: Ranges)
local function event_exchange_tabs(ev, evkey)
   local ex = ev.exchange or {}
   local ex = ev.exchange or {}
   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")
 
  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)
  )


   local function table_for(list, header)
   if has_items(ex.currencies) then
    if type(list) ~= "table" or #list==0 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;"')
        fileTag(it.icon or "", { size="30px" }),
    table.insert(t, "! "..header.." !! Preis !! Limit")
        item_label(it.item, it.amount or 1)
     for _,it in ipairs(list) do
      )
      table.insert(t, "|-")
      t[#t+1] = '|-'
       local offer = string.format("[[Datei:%s|30px]] %s", it.icon or "", it.title or "")
       t[#t+1] = string.format(
      if it.range then offer = "level "..it.range..":&nbsp;&nbsp;"..offer end
        '| %s || style="text-align:center;" | %s %s %s || style="text-align:center;" | %s',
       table.insert(t, string.format("| %s || %s %s %s || %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 table.concat(parts, "\n")
end
end


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==0 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 !! Preis")
   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 253: 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 table.concat(t, "\n")
   return table.concat(t, "\n")
end
end


-- ===== dispatcher =====
-- ========== 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 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


   if a3=="banner" or a3=="type" or a3=="month" or a3=="duration"
   if a3=="notice" then return event_notice(ev, realkey)
    or a3=="character_level_10"
  elseif a3=="file"   then return event_file(ev, realkey, a4 or "")
    or a3=="currency" or a3=="currencies"
  elseif a3=="link"   then return event_link(ev, realkey, a4 or "")
    or a3=="ex_shop" or a3=="shop" or a3=="shopname"
  elseif a3=="h2"     then return htag(2, ev_label(realkey, a4 or ""))
    or a3=="fullname" or a3=="shortname"
  elseif a3=="h3"     then return htag(3, ev_label(realkey, a4 or ""))
    or a3=="currency_exchange" or a3=="avatar_exchange"
  elseif a3=="history" then return event_history(ev, realkey, a)
    or a3=="name" then
  elseif a3=="avatars" then return event_avatars(ev, realkey)
    return event_field(ev, a3, 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
   end


   if a3=="file" then return event_file(ev, realkey, a4 or "")
   -- Sonderfälle für Währungsnamen aus i18n.sections
   elseif a3=="link" then return event_link(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


   if a3=="history"  then return event_history(ev)
   -- generischer Pfad in EventData
   elseif a3=="avatars" then return event_avatars(ev, realkey)
  local path, i = {}, 3
   elseif a3=="exchange" then return event_exchange(ev, realkey)
   while args[i] do table.insert(path, args[i]); i=i+1 end
   elseif a3=="offers" and (a4=="start" or a4=="more") then
   local v = deep_get(ev, path)
     return event_offers(ev, realkey, (a4=="start") and "initial" or "after")
   if v ~= nil then
    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 ""
Zeile 302: 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 ==========
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