打开/关闭菜单
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

本站正在进行早期测试,目前仍存在许多内容的缺失。

模块:Schedule

来自星砂岛百科
Sizau-bot留言 | 贡献2026年3月28日 (六) 10:15的版本 (adjust location labels and regular schedule ordering for Chuxia)

概述

Schedule 用于读取并展示角色行程数据,供 {{Schedule}} 与人物页“日程”章节调用。

用法

{{#invoke:Schedule|getField|晨星|calendar_id}}
{{#invoke:Schedule|renderSchedule|晨星}}

示例

{{#invoke:Schedule|renderSchedule|晨星}}

函数

  • getField:读取行程记录的顶层字段,如 calendar_idregular_countspecial_count
  • renderSchedule:输出角色的日程区块,按常规、特殊、同居三类分组展示。

数据来源


local common = require('Module:Common')

local p = {}

local schedule_data
local mapping
local location_labels

local source_order = {
    regular = 1,
    special = 2,
    living_together = 3,
}

local source_labels = {
    regular = '常规日程',
    special = '特殊日程',
    living_together = '同居日程',
}

local month_labels = {
    spring = '春季',
    summer = '夏季',
    autumn = '秋季',
    winter = '冬季',
}

local weekday_labels = {
    monday = '周一',
    tuesday = '周二',
    wednesday = '周三',
    thursday = '周四',
    friday = '周五',
    saturday = '周六',
    sunday = '周日',
}

local season_number_labels = {
    ['1'] = '春季',
    ['2'] = '夏季',
    ['3'] = '秋季',
    ['4'] = '冬季',
}

local season_keys = {
    ['1'] = 'spring',
    ['2'] = 'summer',
    ['3'] = 'autumn',
    ['4'] = 'winter',
}

local season_template_keys = {
    ['1'] = '1',
    ['2'] = '2',
    ['3'] = '3',
    ['4'] = '4',
    spring = 'spring',
    summer = 'summer',
    autumn = 'fall',
    fall = 'fall',
    winter = 'winter',
}

local weekday_number_labels = {
    ['0'] = '周日',
    ['1'] = '周一',
    ['2'] = '周二',
    ['3'] = '周三',
    ['4'] = '周四',
    ['5'] = '周五',
    ['6'] = '周六',
}

local weekday_keys = {
    ['0'] = 'sunday',
    ['1'] = 'monday',
    ['2'] = 'tuesday',
    ['3'] = 'wednesday',
    ['4'] = 'thursday',
    ['5'] = 'friday',
    ['6'] = 'saturday',
}

local weather_labels = {
    Rain = '雨天',
    ClearSky = '晴天',
    Snow = '雪天',
}

local weather_template_keys = {
    Rain = 'Rainy',
    ClearSky = 'Sunny',
    Snow = 'Snowy',
}

local festival_labels = {
    ['Festival.BeachFestival_Formal'] = '沙滩节',
    ['Festival.SpringFestival_Formal'] = '春节',
}

local festival_heading_labels = {
    ['Festival.SpringFestivalActivity_POI_AfterFestival'] = '春节准备',
    ['Festival.SpringFestivalActivity_POI_InFestival'] = '春节期间',
}

local festival_date_ranges = {
    ['Festival.BeachFestival_Formal'] = {
        month = 2,
        day_min = 27,
        day_max = 27,
    },
    ['Festival.SpringFestivalActivity_POI_AfterFestival'] = {
        month = 4,
        day_min = 25,
        day_max = 28,
    },
    ['Festival.SpringFestivalActivity_POI_InFestival'] = {
        month = 1,
        day_min = 1,
        day_max = 4,
    },
}

local buildable_labels = {
    BuildableRegion_Boat = '渡口建造',
}

local compare_op_labels = {
    Equal = '等于',
    GreatOrEqualThan = '大于等于',
    GreatThan = '大于',
    LessOrEqualThan = '小于等于',
    LessThan = '小于',
    Between = '介于',
}

local generic_condition_descs = {
    ['On Raining Days'] = true,
    ['雨天日程'] = true,
    ['雨雪天'] = true,
    ['雨雪天日程'] = true,
}

local generic_mission_goals = {
    ['执行互动'] = true,
}

local function default_mapping()
    return {
        name_to_id = {},
        id_to_name = {},
        aliases = {},
        overrides = {
            name_to_id = {},
            aliases = {},
        },
    }
end

local function normalize_key(value)
    return common.normalizeKey(value)
end

local function copy_array(values)
    local out = {}
    if type(values) ~= 'table' then
        return out
    end
    for _, value in ipairs(values) do
        out[#out + 1] = value
    end
    return out
end

local function normalize_number_array(values)
    local out = {}
    if type(values) ~= 'table' then
        return out
    end
    for _, value in ipairs(values) do
        local number = tonumber(value)
        if number ~= nil then
            out[#out + 1] = number
        end
    end
    return out
end

local function has_ipairs_items(values)
    if type(values) ~= 'table' then
        return false
    end
    for _ in ipairs(values) do
        return true
    end
    return false
end

local function append_unique(target, values)
    if type(target) ~= 'table' or type(values) ~= 'table' then
        return
    end

    local seen = {}
    for _, value in ipairs(target) do
        seen[tostring(value)] = true
    end
    for _, value in ipairs(values) do
        local key = tostring(value)
        if not seen[key] then
            target[#target + 1] = value
            seen[key] = true
        end
    end
end

local function sort_text_list(values, order_map)
    table.sort(values, function(a, b)
        local a_key = tostring(a)
        local b_key = tostring(b)
        local a_rank = order_map and order_map[a_key] or nil
        local b_rank = order_map and order_map[b_key] or nil
        if a_rank and b_rank and a_rank ~= b_rank then
            return a_rank < b_rank
        end
        if a_rank and not b_rank then
            return true
        end
        if b_rank and not a_rank then
            return false
        end
        return a_key < b_key
    end)
end

local function sort_number_list(values)
    table.sort(values, function(a, b)
        return tonumber(a) < tonumber(b)
    end)
end

local function pick_primary_action(candidates)
    if type(candidates) ~= 'table' or #candidates == 0 then
        return {}
    end

    local ranked = {}
    for _, candidate in ipairs(candidates) do
        if type(candidate) == 'table' then
            ranked[#ranked + 1] = candidate
        end
    end

    table.sort(ranked, function(a, b)
        local a_weight = tonumber(a.weight or 0) or 0
        local b_weight = tonumber(b.weight or 0) or 0
        if a_weight ~= b_weight then
            return a_weight > b_weight
        end

        local a_score = tonumber(a.score or 0) or 0
        local b_score = tonumber(b.score or 0) or 0
        if a_score ~= b_score then
            return a_score > b_score
        end

        return common.trim(a.template) > common.trim(b.template)
    end)

    return ranked[1] or {}
end

local function build_slot_summary(slot)
    if type(slot) ~= 'table' then
        return ''
    end

    local mission_template = common.trim(slot.mission_template)
    if mission_template ~= '' then
        local mission_display = common.trim(slot.mission_display)
        if mission_display ~= '' then
            return mission_display
        end

        local mission_title = common.trim(slot.mission_title)
        if mission_title ~= '' then
            return mission_title
        end

        return mission_template
    end

    local primary_action = slot.primary_action
    if type(primary_action) ~= 'table' then
        return ''
    end

    local display_name = common.trim(primary_action.display_name)
    local location_hint = common.trim(primary_action.location_hint)
    local template_name = common.trim(primary_action.template)

    if display_name ~= '' and location_hint ~= '' then
        return display_name .. ' (' .. location_hint .. ')'
    end
    if display_name ~= '' then
        return display_name
    end
    if location_hint ~= '' and template_name ~= '' then
        return template_name .. ' (' .. location_hint .. ')'
    end
    return template_name
end

local function expand_candidate_entry(raw_candidate)
    if type(raw_candidate) ~= 'table' then
        return {}
    end

    return {
        template = common.trim(raw_candidate.t),
        display_name = common.trim(raw_candidate.d),
        location_hint = common.trim(raw_candidate.l),
        location_label = common.trim(raw_candidate.ll),
        merged_display_name = common.trim(raw_candidate.md),
        weight = raw_candidate.w or 0,
        score = raw_candidate.s or 0,
    }
end

local function expand_compact_slot(raw_slot, candidate_registry)
    if type(raw_slot) ~= 'table' then
        return {}
    end

    local slot = {
        begin_time = common.trim(raw_slot.b),
        end_time = common.trim(raw_slot.e),
        kind = common.trim(raw_slot.k),
    }

    if slot.kind == 'actions' then
        local action_candidates = {}
        if type(raw_slot.a) == 'table' then
            for _, raw_index in ipairs(raw_slot.a) do
                local index = tonumber(raw_index)
                if index ~= nil then
                    local candidate = candidate_registry[index + 1]
                    if type(candidate) == 'table' then
                        action_candidates[#action_candidates + 1] = expand_candidate_entry(candidate)
                    end
                end
            end
        end
        slot.action_candidates = action_candidates
        slot.primary_action = pick_primary_action(action_candidates)
    elseif slot.kind == 'mission' then
        slot.mission_template = common.trim(raw_slot.mt)
        slot.mission_title = common.trim(raw_slot.tt)
        slot.mission_goal = common.trim(raw_slot.g)
        slot.mission_display = common.trim(raw_slot.md)
    end

    slot.summary = build_slot_summary(slot)
    return slot
end

local function expand_compact_variant(raw_variant, candidate_registry)
    if type(raw_variant) ~= 'table' then
        return {}
    end

    local months = normalize_number_array(raw_variant.m)
    local weekdays = normalize_number_array(raw_variant.w)
    local days = normalize_number_array(raw_variant.d)
    local month_keys = {}
    local weekday_label_keys = {}

    for _, value in ipairs(months) do
        local key = season_keys[tostring(value)]
        if key then
            month_keys[#month_keys + 1] = key
        end
    end
    for _, value in ipairs(weekdays) do
        local key = weekday_keys[tostring(value)]
        if key then
            weekday_label_keys[#weekday_label_keys + 1] = key
        end
    end

    local slots = {}
    if type(raw_variant.sl) == 'table' then
        for _, raw_slot in ipairs(raw_variant.sl) do
            if type(raw_slot) == 'table' then
                slots[#slots + 1] = expand_compact_slot(raw_slot, candidate_registry)
            end
        end
    end

    return {
        source = common.trim(raw_variant.o),
        schedule_id = common.trim(raw_variant.i),
        months = months,
        month_labels = month_keys,
        weekdays = weekdays,
        weekday_labels = weekday_label_keys,
        days = days,
        priority = '',
        condition_desc = common.trim(raw_variant.c),
        condition_summary = type(raw_variant.cs) == 'table' and raw_variant.cs or {},
        condition_raw = {},
        slots = slots,
    }
end

local function expand_compact_record(record_key, raw_record, candidate_registry)
    if type(raw_record) ~= 'table' then
        return nil
    end

    local counts = type(raw_record.r) == 'table' and raw_record.r or {}
    local variants = {}
    if type(raw_record.v) == 'table' then
        for _, raw_variant in ipairs(raw_record.v) do
            if type(raw_variant) == 'table' then
                variants[#variants + 1] = expand_compact_variant(raw_variant, candidate_registry)
            end
        end
    end

    return {
        id = common.trim(raw_record.i),
        name = common.trim(raw_record.n),
        key = record_key,
        calendar_id = common.trim(raw_record.c),
        regular_count = tonumber(counts[1] or 0) or 0,
        special_count = tonumber(counts[2] or 0) or 0,
        living_together_count = tonumber(counts[3] or 0) or 0,
        variants = variants,
    }
end

local function expand_compact_schedule_data(raw_data)
    if type(raw_data) ~= 'table' then
        return {}
    end

    if type(raw_data.records) ~= 'table' or type(raw_data.candidate_registry) ~= 'table' then
        return raw_data
    end

    local expanded = {}
    for record_key, raw_record in pairs(raw_data.records) do
        local normalized = normalize_key(record_key)
        local record = expand_compact_record(normalized, raw_record, raw_data.candidate_registry)
        if normalized ~= '' and record then
            expanded[normalized] = record
        end
    end

    return expanded
end

local function load_data()
    if schedule_data then
        return
    end

    schedule_data = expand_compact_schedule_data(common.loadJsonData('数据:Character/character_schedule_index.json') or {})
    mapping = common.loadJsonData('数据:Character/character_mapping.json') or default_mapping()
    location_labels = common.loadJsonData('数据:Character/schedule_location_labels.json') or {}
end

local function find_record(key)
    load_data()

    local resolved = common.trim(key)
    if resolved == '' then
        resolved = common.getCurrentTitleText()
    end

    local normalized = normalize_key(resolved)
    if schedule_data[normalized] then
        return schedule_data[normalized]
    end

    local override_id = mapping.overrides and mapping.overrides.name_to_id and mapping.overrides.name_to_id[normalized]
    if override_id and schedule_data[normalize_key(override_id)] then
        return schedule_data[normalize_key(override_id)]
    end

    local override_alias = mapping.overrides and mapping.overrides.aliases and mapping.overrides.aliases[normalized]
    if override_alias and schedule_data[normalize_key(override_alias)] then
        return schedule_data[normalize_key(override_alias)]
    end

    local mapped_id = mapping.name_to_id and mapping.name_to_id[normalized]
    if mapped_id and schedule_data[normalize_key(mapped_id)] then
        return schedule_data[normalize_key(mapped_id)]
    end

    local alias_id = mapping.aliases and mapping.aliases[normalized]
    if alias_id and schedule_data[normalize_key(alias_id)] then
        return schedule_data[normalize_key(alias_id)]
    end

    return nil
end

local function list_key(values)
    if type(values) ~= 'table' then
        return ''
    end

    local out = {}
    for _, value in ipairs(values) do
        out[#out + 1] = tostring(value)
    end
    return table.concat(out, ',')
end

local function build_condition_key(items)
    if type(items) ~= 'table' then
        return ''
    end

    local out = {}
    for _, item in ipairs(items) do
        if type(item) == 'table' then
            out[#out + 1] = table.concat({
                common.trim(item.type),
                tostring(item.reverse),
                common.trim(item.dest_type),
                common.trim(item.key),
                common.trim(item.tag),
                common.trim(item.op),
                common.trim(item.value),
                tostring(item.check_day),
                tostring(item.check_month),
                tostring(item.check_weekday),
                tostring(item.check_year),
                common.trim(item.year_op),
                common.trim(item.year_value),
            }, '~')
        end
    end
    return table.concat(out, '||')
end

local function build_slot_key(slots)
    if type(slots) ~= 'table' then
        return ''
    end

    local out = {}
    for _, slot in ipairs(slots) do
        if type(slot) == 'table' then
            local primary_action = slot.primary_action
            local primary_template = ''
            if type(primary_action) == 'table' then
                primary_template = common.trim(primary_action.template)
            end
            out[#out + 1] = table.concat({
                common.trim(slot.begin_time),
                common.trim(slot.end_time),
                common.trim(slot.kind),
                common.trim(slot.summary),
                common.trim(slot.mission_template),
                primary_template,
            }, '~')
        end
    end
    return table.concat(out, '||')
end

local function clone_variant(variant)
    return {
        source = common.trim(variant.source),
        schedule_id = common.trim(variant.schedule_id),
        months = copy_array(variant.months),
        month_labels = copy_array(variant.month_labels),
        weekdays = copy_array(variant.weekdays),
        weekday_labels = copy_array(variant.weekday_labels),
        days = copy_array(variant.days),
        priority = variant.priority,
        condition_desc = common.trim(variant.condition_desc),
        condition_summary = variant.condition_summary or {},
        condition_raw = variant.condition_raw or {},
        slots = variant.slots or {},
    }
end

local function merge_variants(variants)
    local grouped = {}
    local order = {}

    if type(variants) ~= 'table' then
        return order
    end

    for _, variant in ipairs(variants) do
        if type(variant) == 'table' then
            local key = table.concat({
                common.trim(variant.source),
                common.trim(variant.schedule_id),
                list_key(variant.months),
                list_key(variant.days),
                common.trim(variant.condition_desc),
                build_condition_key(variant.condition_summary),
                build_slot_key(variant.slots),
            }, '|')

            if not grouped[key] then
                grouped[key] = clone_variant(variant)
                order[#order + 1] = grouped[key]
            else
                append_unique(grouped[key].weekdays, variant.weekdays or {})
                append_unique(grouped[key].weekday_labels, variant.weekday_labels or {})
                append_unique(grouped[key].months, variant.months or {})
                append_unique(grouped[key].month_labels, variant.month_labels or {})
                append_unique(grouped[key].days, variant.days or {})
            end
        end
    end

    for _, variant in ipairs(order) do
        sort_number_list(variant.months)
        sort_number_list(variant.weekdays)
        sort_number_list(variant.days)
        sort_text_list(variant.month_labels, {
            spring = 1,
            summer = 2,
            autumn = 3,
            winter = 4,
        })
        sort_text_list(variant.weekday_labels, {
            monday = 1,
            tuesday = 2,
            wednesday = 3,
            thursday = 4,
            friday = 5,
            saturday = 6,
            sunday = 7,
        })
    end

    local function condition_priority(variant)
        if type(variant.condition_summary) == 'table' then
            for _, item in ipairs(variant.condition_summary) do
                if type(item) == 'table' then
                    local item_type = common.trim(item.type)
                    local is_reverse = item.reverse == true
                    local dest_type = common.trim(item.dest_type)
                    if item_type == 'weather_today' and not is_reverse then
                        if dest_type == 'Sunny' then
                            return 0
                        end
                        if dest_type == 'Rain' then
                            if type(variant.weekdays) == 'table' and #variant.weekdays > 0 then
                                return 3
                            end
                            return 1
                        end
                        return 4
                    end
                end
            end
        end

        if type(variant.weekdays) == 'table' and #variant.weekdays > 0 then
            return 2
        end

        return 4
    end

    table.sort(order, function(a, b)
        local a_source = source_order[a.source] or 99
        local b_source = source_order[b.source] or 99
        if a_source ~= b_source then
            return a_source < b_source
        end

        local a_condition_priority = condition_priority(a)
        local b_condition_priority = condition_priority(b)
        if a_condition_priority ~= b_condition_priority then
            return a_condition_priority < b_condition_priority
        end

        local a_day = tonumber(a.days[1] or 99) or 99
        local b_day = tonumber(b.days[1] or 99) or 99
        if a_day ~= b_day then
            return a_day < b_day
        end

        local a_month = tonumber(a.months[1] or 99) or 99
        local b_month = tonumber(b.months[1] or 99) or 99
        if a_month ~= b_month then
            return a_month < b_month
        end

        local a_weekday = tonumber(a.weekdays[1] or 99) or 99
        local b_weekday = tonumber(b.weekdays[1] or 99) or 99
        if a_weekday ~= b_weekday then
            return a_weekday < b_weekday
        end

        local a_desc = common.trim(a.condition_desc)
        local b_desc = common.trim(b.condition_desc)
        if a_desc ~= b_desc then
            return a_desc < b_desc
        end

        return common.trim(a.schedule_id) < common.trim(b.schedule_id)
    end)

    return order
end

local function map_labels(values, label_map)
    if type(values) ~= 'table' then
        return {}
    end

    local out = {}
    for _, value in ipairs(values) do
        local text = common.trim(value)
        if text ~= '' then
            out[#out + 1] = label_map[text] or text
        end
    end
    return out
end

local function join_parts(values)
    if type(values) ~= 'table' or #values == 0 then
        return ''
    end
    return table.concat(values, '、')
end

local function unique_texts(values)
    local out = {}
    local seen = {}
    if type(values) ~= 'table' then
        return out
    end

    for _, value in ipairs(values) do
        local text = common.trim(value)
        if text ~= '' and not seen[text] then
            out[#out + 1] = text
            seen[text] = true
        end
    end
    return out
end

local function rank_candidates(candidates)
    local ranked = {}
    if type(candidates) ~= 'table' then
        return ranked
    end

    for _, candidate in ipairs(candidates) do
        if type(candidate) == 'table' then
            ranked[#ranked + 1] = candidate
        end
    end

    table.sort(ranked, function(a, b)
        local a_weight = tonumber(a.weight or 0) or 0
        local b_weight = tonumber(b.weight or 0) or 0
        if a_weight ~= b_weight then
            return a_weight > b_weight
        end

        local a_score = tonumber(a.score or 0) or 0
        local b_score = tonumber(b.score or 0) or 0
        if a_score ~= b_score then
            return a_score > b_score
        end

        return common.trim(a.template) > common.trim(b.template)
    end)

    return ranked
end

local function get_top_candidates(candidates)
    local ranked = rank_candidates(candidates)
    if #ranked == 0 then
        return ranked, {}
    end

    local first = ranked[1]
    local top_weight = tonumber(first.weight or 0) or 0
    local top_score = tonumber(first.score or 0) or 0
    local top = {}

    for _, candidate in ipairs(ranked) do
        local weight = tonumber(candidate.weight or 0) or 0
        local score = tonumber(candidate.score or 0) or 0
        if weight == top_weight and score == top_score then
            top[#top + 1] = candidate
        else
            break
        end
    end

    return ranked, top
end

local function clean_condition_desc(value)
    local text = common.trim(value)
    text = mw.ustring.gsub(text, '^%*+', '')
    return common.trim(text)
end

local function should_use_structured_condition(desc, summary)
    local cleaned = clean_condition_desc(desc)
    if cleaned == '' then
        return true
    end
    if generic_condition_descs[cleaned] then
        return true
    end
    if mw.ustring.find(cleaned, '[A-Za-z]') and not mw.ustring.find(cleaned, '[一-龥]') then
        return true
    end
    if type(summary) == 'table' then
        for _, item in ipairs(summary) do
            if type(item) == 'table' and (item.type == 'calendar_now_is_festival' or item.type == 'weather_today') then
                return true
            end
        end
    end
    return false
end

local function describe_compare_range(op, value, min_value, max_value, label_map, suffix)
    local resolved_op = common.trim(op)
    local resolved_value = common.trim(value)
    local resolved_min = common.trim(min_value)
    local resolved_max = common.trim(max_value)
    local resolved_suffix = suffix or ''

    local function map_value(raw)
        local key = common.trim(raw)
        if key == '' then
            return ''
        end
        if label_map and label_map[key] then
            return label_map[key]
        end
        return key .. resolved_suffix
    end

    if resolved_op == 'Equal' and resolved_value ~= '' then
        return map_value(resolved_value)
    end

    if resolved_op == 'Between' and resolved_min ~= '' and resolved_max ~= '' then
        local left = map_value(resolved_min)
        local right = map_value(resolved_max)
        if label_map then
            return left .. ' - ' .. right
        end
        return resolved_min .. ' - ' .. resolved_max .. resolved_suffix
    end

    local compare_label = compare_op_labels[resolved_op] or resolved_op
    if compare_label ~= '' and resolved_value ~= '' then
        return compare_label .. map_value(resolved_value)
    end

    return ''
end

local function resolve_season_template_key(value)
    local normalized = mw.ustring.lower(common.trim(value))
    if normalized == '' then
        return nil
    end
    return season_template_keys[normalized] or season_template_keys[season_keys[normalized] or '']
end

local function format_season_token(value)
    local key = resolve_season_template_key(value)
    if key then
        return '{{Season|' .. key .. '}}'
    end
    return ''
end

local function format_season_day_text(months, days)
    if #months == 1 and #days > 0 then
        local key = resolve_season_template_key(months[1])
        if key then
            local season_days = {}
            for _, day in ipairs(days) do
                local value = common.trim(day)
                if value ~= '' then
                    season_days[#season_days + 1] = '{{Season|' .. key .. '|' .. value .. '}}'
                end
            end
            if #season_days > 0 then
                return join_parts(season_days)
            end
        end
    end
    return ''
end

local function format_condition_date_text(month_value, day_value)
    local season_key = resolve_season_template_key(month_value)
    local day_text = common.trim(day_value)
    if season_key ~= nil and day_text ~= '' then
        return '{{Season|' .. season_key .. '|' .. day_text .. '}}'
    end
    if season_key ~= nil then
        return '{{Season|' .. season_key .. '}}'
    end
    if day_text ~= '' then
        return day_text .. '日'
    end
    return ''
end

local function format_condition_date_range_text(month_value, day_min_value, day_max_value)
    local season_key = resolve_season_template_key(month_value)
    local min_text = common.trim(day_min_value)
    local max_text = common.trim(day_max_value)

    if season_key ~= nil and min_text ~= '' and max_text ~= '' then
        if min_text == max_text then
            return '{{Season|' .. season_key .. '|' .. min_text .. '}}'
        end
        return '{{Season|' .. season_key .. '|' .. min_text .. '}} - {{Season|' .. season_key .. '|' .. max_text .. '}}'
    end
    if min_text ~= '' and max_text ~= '' then
        if min_text == max_text then
            return min_text .. '日'
        end
        return min_text .. '日-' .. max_text .. '日'
    end
    return ''
end

local function build_apply_text(variant)
    local parts = {}
    local month_values = variant.month_labels or {}
    local day_values = variant.days or {}
    local month_parts = {}
    local weekdays = map_labels(variant.weekday_labels, weekday_labels)
    local season_day_text = format_season_day_text(month_values, day_values)
    local days = {}

    if season_day_text == '' then
        for _, month in ipairs(month_values) do
            local token = format_season_token(month)
            if token ~= '' then
                month_parts[#month_parts + 1] = token
            end
        end

        for _, day in ipairs(day_values) do
            local value = common.trim(day)
            if value ~= '' then
                days[#days + 1] = value .. '日'
            end
        end

        if #month_parts > 0 then
            parts[#parts + 1] = join_parts(month_parts)
        end
        if #days > 0 then
            parts[#parts + 1] = join_parts(days)
        end
    end

    if #weekdays > 0 then
        parts[#parts + 1] = join_parts(weekdays)
    end
    if season_day_text ~= '' then
        parts[#parts + 1] = season_day_text
    end

    return table.concat(parts, ' / ')
end

local describe_condition_item

local function build_structured_condition_title(summary)
    if type(summary) ~= 'table' then
        return ''
    end

    local festival_template = ''
    local festival_text = ''
    local weather_text = ''
    local exact_date_text = ''
    local ranged_date_text = ''

    for _, item in ipairs(summary) do
        if type(item) == 'table' then
            if item.type == 'calendar_now_is_festival' and not item.reverse then
                festival_template = common.trim(item.festival_template)
                festival_text = festival_labels[festival_template] or festival_heading_labels[festival_template] or festival_template
            elseif item.type == 'weather_today' and weather_text == '' then
                local dest_type = common.trim(item.dest_type)
                local template_key = weather_template_keys[dest_type]
                local label = weather_labels[dest_type] or dest_type
                weather_text = template_key and ('{{Weather|' .. template_key .. '}}') or label
            elseif item.type == 'time_check_date' then
                if exact_date_text == '' and item.check_month and item.check_day and common.trim(item.month_op) == 'Equal' and common.trim(item.day_op) == 'Equal' then
                    exact_date_text = format_condition_date_text(item.month_value, item.day_value)
                elseif ranged_date_text == '' and item.check_month and item.check_day and common.trim(item.month_op) == 'Equal' and common.trim(item.day_op) == 'Between' and not item.reverse then
                    ranged_date_text = format_condition_date_range_text(item.month_value, item.day_min_value, item.day_max_value)
                end
            end
        end
    end

    local fallback_range = festival_date_ranges[festival_template]
    if exact_date_text == '' and fallback_range and fallback_range.day_min == fallback_range.day_max then
        exact_date_text = format_condition_date_text(fallback_range.month, fallback_range.day_min)
    end
    if festival_text ~= '' and exact_date_text ~= '' then
        return exact_date_text .. '|' .. festival_text
    end

    if festival_text ~= '' and ranged_date_text == '' and fallback_range then
        ranged_date_text = format_condition_date_range_text(fallback_range.month, fallback_range.day_min, fallback_range.day_max)
    end
    if festival_text ~= '' and ranged_date_text ~= '' then
        return ranged_date_text .. '|' .. festival_text
    end
    if weather_text ~= '' then
        return weather_text
    end
    return ''
end

describe_condition_item = function(item)
    if type(item) ~= 'table' then
        return ''
    end

    if item.type == 'weather_today' then
        local dest_type = common.trim(item.dest_type)
        local template_key = weather_template_keys[dest_type]
        local label = weather_labels[dest_type] or dest_type
        local weather_text = template_key and ('{{Weather|' .. template_key .. '}}') or label
        if weather_text ~= '' then
            if item.reverse then
                return '非' .. weather_text
            end
            return weather_text
        end
        return '天气条件'
    end

    if item.type == 'entity_data_check_string' then
        local key = common.trim(item.key)
        local op = common.trim(item.op)
        local value = common.trim(item.value)
        if key == 'Schedule_Typhoon' and value == '1' then
            if item.reverse then
                return '非台风天气'
            end
            return '台风天气'
        end
        if key ~= '' and value ~= '' then
            local prefix = item.reverse and '不满足:' or ''
            if op ~= '' then
                return prefix .. key .. ' ' .. op .. ' ' .. value
            end
            return prefix .. key .. ' = ' .. value
        end
    end

    if item.type == 'npc_affection_star' then
        local value = common.trim(item.value)
        if value == '' then
            return item.reverse and '好感条件不满足' or '好感条件'
        end
        local op = common.trim(item.op)
        if op == 'GreatOrEqualThan' then
            return item.reverse and ('好感度未达到 ' .. value .. ' 心') or ('好感度达到 ' .. value .. ' 心')
        end
        if op == 'Equal' then
            return item.reverse and ('好感度不为 ' .. value .. ' 心') or ('好感度为 ' .. value .. ' 心')
        end
        local compare_label = compare_op_labels[op] or op
        if compare_label ~= '' then
            return item.reverse and ('好感条件不满足 ' .. compare_label .. ' ' .. value .. ' 心') or ('好感度 ' .. compare_label .. ' ' .. value .. ' 心')
        end
        return item.reverse and '好感条件不满足' or '好感条件'
    end

    if item.type == 'calendar_now_is_festival' then
        local festival_template = common.trim(item.festival_template)
        local label = festival_labels[festival_template] or festival_heading_labels[festival_template] or festival_template
        if label ~= '' then
            if item.reverse then
                return '非' .. label
            end
            return label
        end
        return item.reverse and '非节庆期间' or '节庆期间'
    end

    if item.type == 'buildable_region_check_completed' then
        local template_name = common.trim(item.template)
        local label = buildable_labels[template_name] or template_name
        if label ~= '' then
            if item.reverse then
                return label .. '未完成'
            end
            return label .. '已完成'
        end
        return item.reverse and '建筑条件未完成' or '建筑条件已完成'
    end

    if item.type == 'check_has_owner' then
        if item.reverse then
            return '未被收养'
        end
        return '已被收养'
    end

    if item.type == 'time_check_date' then
        local parts = {}

        if item.check_month then
            local month_text = describe_compare_range(
                item.month_op,
                item.month_value,
                item.month_min_value,
                item.month_max_value,
                season_number_labels
            )
            if month_text ~= '' then
                parts[#parts + 1] = month_text
            else
                parts[#parts + 1] = '月份'
            end
        end

        if item.check_day then
            local day_text = describe_compare_range(
                item.day_op,
                item.day_value,
                item.day_min_value,
                item.day_max_value,
                nil,
                '日'
            )
            if day_text ~= '' then
                parts[#parts + 1] = day_text
            else
                parts[#parts + 1] = '日期'
            end
        end

        if item.check_weekday then
            local weekday_text = describe_compare_range(
                item.weekday_op,
                item.weekday_value,
                item.weekday_min_value,
                item.weekday_max_value,
                weekday_number_labels
            )
            if weekday_text ~= '' then
                parts[#parts + 1] = weekday_text
            else
                parts[#parts + 1] = '星期'
            end
        end

        if item.check_year then
            local year_text = ''
            if common.trim(item.year_op) == 'Equal' and common.trim(item.year_value) ~= '' then
                year_text = '第' .. common.trim(item.year_value) .. '年'
            else
                year_text = describe_compare_range(
                    item.year_op,
                    item.year_value,
                    item.year_min_value,
                    item.year_max_value
                )
                if year_text ~= '' then
                    year_text = '年份' .. year_text
                end
            end
            if year_text ~= '' then
                parts[#parts + 1] = year_text
            else
                parts[#parts + 1] = '年份'
            end
        end

        if #parts > 0 then
            local text = table.concat(parts, '')
            if item.reverse then
                return '非' .. text
            end
            return text
        end
        return item.reverse and '日期条件不满足' or '日期条件'
    end

    return '特殊条件'
end

local function build_condition_text(variant)
    local condition_desc = clean_condition_desc(variant.condition_desc)
    local summary = variant.condition_summary
    local title_text = build_structured_condition_title(summary)
    local parts = {}
    if type(summary) == 'table' then
        for _, item in ipairs(summary) do
            local text = describe_condition_item(item)
            if text ~= '' then
                parts[#parts + 1] = text
            end
        end
    end

    local structured_text = table.concat(unique_texts(parts), ';')
    if title_text ~= '' then
        return title_text
    end
    if condition_desc == '' then
        return structured_text
    end
    if structured_text == '' then
        return condition_desc
    end
    if should_use_structured_condition(condition_desc, summary) then
        return structured_text
    end
    return condition_desc
end

local function build_variant_heading(variant)
    local parts = {}
    local apply_text = build_apply_text(variant)
    local condition_text = build_condition_text(variant)

    if apply_text ~= '' then
        parts[#parts + 1] = apply_text
    end
    if condition_text ~= '' then
        parts[#parts + 1] = condition_text
    end

    if #parts == 0 then
        return '默认'
    end
    return table.concat(parts, '|')
end

local function format_location_list(values, delimiter)
    local texts = unique_texts(values)
    local compact = {}
    for _, text in ipairs(texts) do
        local value = common.trim(text)
        if value ~= '' then
            compact[#compact + 1] = value
        end
    end
    if #compact == 0 then
        return ''
    end
    return table.concat(compact, delimiter or '<br />')
end

local function build_slot_meta_parts(slot)
    if type(slot) ~= 'table' then
        return {}
    end

    local parts = {}
    if common.trim(slot.kind) == 'mission' then
        parts[#parts + 1] = '任务'
    end

    local ranked_candidates, top_candidates = get_top_candidates(slot.action_candidates)
    if #top_candidates > 1 then
        parts[#parts + 1] = '并列候选 ' .. tostring(#top_candidates) .. ' 项'
    elseif #ranked_candidates > 1 then
        parts[#parts + 1] = '候选 ' .. tostring(#ranked_candidates) .. ' 项'
    end

    return parts
end

local function format_candidate_text(candidate)
    if type(candidate) ~= 'table' then
        return ''
    end

    local merged_display_name = common.trim(candidate.merged_display_name)
    if merged_display_name ~= '' then
        return merged_display_name
    end

    local display_name = common.trim(candidate.display_name)
    if display_name ~= '' then
        return display_name
    end

    local location_label = common.trim(candidate.location_label)
    if location_label ~= '' then
        return location_label
    end

    local template_name = common.trim(candidate.template)
    if template_name ~= '' then
        return template_name
    end

    return common.trim(candidate.location_hint)
end

local function format_probability(weight, total_weight)
    local current = tonumber(weight or 0) or 0
    local total = tonumber(total_weight or 0) or 0
    if current <= 0 then
        return '—'
    end
    if total <= 0 then
        return tostring(current)
    end

    local percent = current * 100 / total
    if math.abs(percent - math.floor(percent + 0.5)) < 0.05 then
        return string.format('%d%%', math.floor(percent + 0.5))
    end
    return string.format('%.1f%%', percent)
end

local function build_slot_schedule_entries(slot)
    local entries = {}
    if type(slot) ~= 'table' then
        return entries
    end

    if common.trim(slot.kind) == 'mission' then
        local mission_activity = format_slot_activity(slot)
        local mission_note = format_slot_note(slot)
        entries[#entries + 1] = {
            activity = mission_note ~= '' and (mission_activity .. '<br /><small>' .. mission_note .. '</small>') or mission_activity,
            probability = '—',
        }
        return entries
    end

    local ranked_candidates = rank_candidates(slot.action_candidates)
    if #ranked_candidates == 0 then
        local activity_text = format_slot_activity(slot)
        if activity_text ~= '' then
            entries[#entries + 1] = {
                activity = activity_text,
                probability = '—',
            }
        end
        return entries
    end

    local total_weight = 0
    for _, candidate in ipairs(ranked_candidates) do
        total_weight = total_weight + (tonumber(candidate.weight or 0) or 0)
    end

    for _, candidate in ipairs(ranked_candidates) do
        local activity_text = format_candidate_text(candidate)
        if activity_text ~= '' then
            entries[#entries + 1] = {
                activity = activity_text,
                probability = format_probability(candidate.weight, total_weight),
            }
        end
    end

    return entries
end

local function format_slot_time(slot)
    local begin_time = common.trim(slot.begin_time)
    local end_time = common.trim(slot.end_time)
    if begin_time ~= '' and end_time ~= '' then
        return begin_time .. '-' .. end_time
    end
    return begin_time ~= '' and begin_time or end_time
end

local function format_slot_activity(slot)
    if type(slot) ~= 'table' then
        return ''
    end

    if common.trim(slot.kind) == 'mission' then
        local mission_display = common.trim(slot.mission_display)
        if mission_display ~= '' then
            return mission_display
        end

        local mission_title = common.trim(slot.mission_title)
        if mission_title ~= '' then
            return mission_title
        end

        return '任务'
    end

    local entries = build_slot_schedule_entries(slot)
    if #entries > 0 then
        return entries[1].activity or ''
    end

    return common.trim(slot.summary)
end

local function format_slot_note(slot)
    if type(slot) ~= 'table' then
        return ''
    end

    if common.trim(slot.kind) == 'mission' then
        local notes = {}
        local mission_goal = common.trim(slot.mission_goal)
        local mission_title = common.trim(slot.mission_title)

        if mission_goal ~= '' and not generic_mission_goals[mission_goal] then
            notes[#notes + 1] = mission_goal
        elseif mission_title ~= '' and mission_title ~= common.trim(slot.mission_display) then
            notes[#notes + 1] = mission_title
        end

        return table.concat(notes, ';')
    end

    return ''
end

local function render_variant_table(variant)
    local root = mw.html.create('table'):addClass('wikitable')
    local head = root:tag('tr')
    head:tag('th'):wikitext('时间')
    head:tag('th'):wikitext('安排')
    head:tag('th'):wikitext('概率')

    for _, slot in ipairs(variant.slots or {}) do
        local entries = build_slot_schedule_entries(slot)
        if #entries == 0 then
            entries = {
                {
                    activity = format_slot_activity(slot),
                    probability = '—',
                }
            }
        end

        local slot_time = format_slot_time(slot)
        local time_text = slot_time ~= '' and slot_time or '—'

        if #entries == 1 then
            local row = root:tag('tr')
            row:tag('td'):wikitext(time_text)
            row:tag('td')
                :attr('colspan', '2')
                :wikitext(common.trim(entries[1].activity) ~= '' and entries[1].activity or '—')
        else
            for index, entry in ipairs(entries) do
                local row = root:tag('tr')
                if index == 1 then
                    row:tag('td')
                        :attr('rowspan', tostring(#entries))
                        :wikitext(time_text)
                end

                row:tag('td'):wikitext(common.trim(entry.activity) ~= '' and entry.activity or '—')
                row:tag('td'):wikitext(common.trim(entry.probability) ~= '' and entry.probability or '—')
            end
        end
    end

    return tostring(root)
end

local function render_group(source, variants)
    local items = {}
    for _, variant in ipairs(variants) do
        if variant.source == source and has_ipairs_items(variant.slots) then
            items[#items + 1] = variant
        end
    end

    if #items == 0 then
        return ''
    end

    local out = {}
    for _, variant in ipairs(items) do
        out[#out + 1] = "'''" .. build_variant_heading(variant) .. "'''"
        out[#out + 1] = render_variant_table(variant)
    end

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

local function filter_display_variants(variants)
    local out = {}
    if type(variants) ~= 'table' then
        return out
    end

    for _, variant in ipairs(variants) do
        if type(variant) == 'table' and has_ipairs_items(variant.slots) then
            out[#out + 1] = variant
        end
    end

    return out
end

local function render_schedule_by_key(key)
    local record = find_record(key)
    if not record then
        return ''
    end

    local variants = filter_display_variants(merge_variants(record.variants))
    if #variants == 0 then
        return '暂无日程数据。'
    end

    local tab_parts = {}
    for _, source in ipairs({ 'regular', 'special', 'living_together' }) do
        local block = render_group(source, variants)
        if block ~= '' then
            if #tab_parts > 0 then
                tab_parts[#tab_parts + 1] = '|-|'
            end
            tab_parts[#tab_parts + 1] = (source_labels[source] or source) .. '='
            tab_parts[#tab_parts + 1] = block
        end
    end

    if #tab_parts == 0 then
        return ''
    end

    return '<tabber>\n' .. table.concat(tab_parts, '\n') .. '\n</tabber>'
end

function p.getField(frame)
    local key = common.getArg(frame, 1, '')
    local field = common.getArg(frame, 2, '')
    if field == '' then
        return ''
    end

    local record = find_record(key)
    if not record then
        return ''
    end

    return common.toText(record[field])
end

function p.renderSchedule(frame)
    local key = common.getArg(frame, 1, '')
    local output = render_schedule_by_key(key)
    if output == '' then
        return ''
    end
    return frame:preprocess(output)
end

return p