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

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

模块:Schedule

来自星砂岛百科
Sizau-bot留言 | 贡献2026年4月1日 (三) 16:08的版本 (修正 Character 行程内层标签页的中文文案)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)

概述

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 character_common = require('Module:CharacterCommon')

local p = {}

local raw_schedule_records
local candidate_registry
local expanded_record_cache
local mapping
local location_labels

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

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

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

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

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

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 weather_plain_labels = {
    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 normalize_key(value)
    return character_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 location_label = common.trim(primary_action.location_label)
    local location_display = location_label ~= '' and location_label or location_hint
    local template_name = common.trim(primary_action.template)

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

local function resolve_location_label(value)
    local text = common.trim(value)
    if text == '' or type(location_labels) ~= 'table' then
        return ''
    end

    local direct = common.trim(location_labels[text] or '')
    if direct ~= '' then
        return direct
    end

    local festival_base = mw.ustring.match(text, '^(.+)_BeachFestival$')
    if festival_base then
        local base = common.trim(location_labels[festival_base] or '')
        if base ~= '' then
            return base .. '(海滩节)'
        end
    end

    local location_key, actor_key = mw.ustring.match(text, '^(.*)_([A-Za-z][A-Za-z0-9]+)$')
    if location_key and actor_key then
        local location_label = common.trim(location_labels[location_key] or '')
        local actor_label = common.trim(location_labels[actor_key] or '')
        if location_label ~= '' and actor_label ~= '' then
            return actor_label .. '(' .. location_label .. ')'
        end
        if location_label ~= '' then
            return location_label .. '(' .. actor_key .. ')'
        end
        if actor_label ~= '' then
            return actor_label
        end
    end

    return ''
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 or resolve_location_label(raw_candidate.l)),
        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 load_data()
    if raw_schedule_records then
        return
    end

    local raw_data = common.loadJsonData('数据:Character/character_schedule_index.json') or {}
    if type(raw_data.records) == 'table' then
        raw_schedule_records = raw_data.records
        candidate_registry = type(raw_data.candidate_registry) == 'table' and raw_data.candidate_registry or {}
    else
        raw_schedule_records = raw_data
        candidate_registry = {}
    end
    expanded_record_cache = {}
    mapping = character_common.loadCharacterMapping()
    location_labels = common.loadJsonData('数据:Character/schedule_location_labels.json') or {}
end

local function expand_record_by_key(record_key)
    if record_key == '' then
        return nil
    end

    if expanded_record_cache[record_key] ~= nil then
        return expanded_record_cache[record_key] or nil
    end

    local raw_record = raw_schedule_records[record_key]
    if type(raw_record) ~= 'table' then
        expanded_record_cache[record_key] = false
        return nil
    end

    local record
    if type(raw_record.v) == 'table' then
        record = expand_compact_record(record_key, raw_record, candidate_registry or {})
    else
        record = raw_record
    end

    expanded_record_cache[record_key] = record or false
    return record
end

local function find_record(key)
    load_data()
    local _, record_key = character_common.findRecordWithKey(raw_schedule_records, mapping, key)
    return expand_record_by_key(record_key)
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)
        local has_weekday = type(variant.weekdays) == 'table' and #variant.weekdays > 0
        local has_rain = false
        local has_clear = false

        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' then
                        if not is_reverse and dest_type == 'Rain' then
                            has_rain = true
                        elseif is_reverse and (dest_type == 'Rain' or dest_type == 'ClearSky') then
                            has_clear = true
                        elseif not is_reverse and dest_type == 'Sunny' then
                            has_clear = true
                        end
                    end
                end
            end
        end

        if has_clear and not has_weekday then
            return 0
        end
        if has_rain and not has_weekday then
            return 1
        end
        if has_weekday and not has_rain then
            return 2
        end
        if has_weekday and has_rain then
            return 3
        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 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 resolve_season_plain_label(value)
    local normalized = mw.ustring.lower(common.trim(value))
    if normalized == '' then
        return ''
    end

    local season_key = season_keys[normalized] or normalized
    if season_number_labels[season_key] then
        return season_number_labels[season_key]
    end
    if season_plain_labels[season_key] then
        return season_plain_labels[season_key]
    end
    return common.trim(value)
end

local function build_variant_tab_label(variant)
    local text = build_variant_heading(variant)
    text = mw.ustring.gsub(text, '{{%s*[Ss]eason%s*|%s*([^|}]+)%s*|%s*([^}]+)%s*}}', function(key, day)
        local season_label = resolve_season_plain_label(key)
        local day_text = common.trim(day)
        if season_label ~= '' and day_text ~= '' then
            return season_label .. day_text .. '日'
        end
        if season_label ~= '' then
            return season_label
        end
        return common.trim(key)
    end)
    text = mw.ustring.gsub(text, '{{%s*[Ss]eason%s*|%s*([^}]+)%s*}}', function(key)
        local season_label = resolve_season_plain_label(key)
        return season_label ~= '' and season_label or common.trim(key)
    end)
    text = mw.ustring.gsub(text, '{{%s*[Ww]eather%s*|%s*([^}]+)%s*}}', function(key)
        local weather_key = common.trim(key)
        return weather_plain_labels[weather_key] or weather_labels[weather_key] or weather_key
    end)
    text = mw.ustring.gsub(text, '%[%[([^%]|]+)|([^%]]+)%]%]', '%2')
    text = mw.ustring.gsub(text, '%[%[([^%]]+)%]%]', '%1')
    text = mw.ustring.gsub(text, "'''", '')
    text = mw.ustring.gsub(text, '<[^>]+>', '')
    text = common.trim(text)
    return text ~= '' and text or '默认'
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)
    local location_label = common.trim(candidate.location_label)
    if location_label == '' then
        location_label = resolve_location_label(candidate.location_hint)
    end

    if display_name ~= '' and location_label ~= '' and not mw.ustring.find(display_name, location_label, 1, true) then
        return display_name .. '(' .. location_label .. ')'
    end
    if display_name ~= '' then
        return display_name
    end

    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 format_slot_activity
local format_slot_note

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

format_slot_activity = function(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 primary_action = type(slot.primary_action) == 'table' and slot.primary_action or nil
    if primary_action then
        local activity_text = format_candidate_text(primary_action)
        if activity_text ~= '' then
            return activity_text
        end
    end

    return common.trim(slot.summary)
end

format_slot_note = function(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'):addClass('char-data-table'):addClass('char-schedule-table')
    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'):wikitext(common.trim(entries[1].activity) ~= '' and entries[1].activity or '—')
            row:tag('td'):wikitext(common.trim(entries[1].probability) ~= '' and entries[1].probability 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_schedule_group(variant, include_heading)
    local out = { '<div class="char-schedule-group">' }
    if include_heading ~= false then
        out[#out + 1] = '<div class="char-schedule-heading"><b>' .. build_variant_heading(variant) .. '</b></div>'
    end
    out[#out + 1] = '<div class="char-table-wrap">' .. render_variant_table(variant) .. '</div>'
    out[#out + 1] = '</div>'
    return table.concat(out, '\n')
end

local function render_group(frame, 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

    if #items == 1 then
        return render_schedule_group(items[1], true)
    end

    local tab_parts = {}
    for index, variant in ipairs(items) do
        if index > 1 then
            tab_parts[#tab_parts + 1] = '|-|'
        end
        tab_parts[#tab_parts + 1] = build_variant_tab_label(variant) .. '=\n' .. render_schedule_group(variant, true)
    end

    return frame:extensionTag('tabber', table.concat(tab_parts, '\n'), {
        class = 'char-tabber char-schedule-group-tabber',
    })
end

local function has_multi_candidate_slots(variants)
    if type(variants) ~= 'table' then
        return false
    end

    for _, variant in ipairs(variants) do
        for _, slot in ipairs(variant.slots or {}) do
            if #build_slot_schedule_entries(slot) > 1 then
                return true
            end
        end
    end

    return false
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(frame, key)
    local record = find_record(key)
    if not record then
        return '<div class="char-empty char-schedule-empty">未找到角色行程数据</div>'
    end

    local variants = filter_display_variants(merge_variants(record.variants))
    if #variants == 0 then
        return '<div class="char-empty char-schedule-empty">暂无日程数据。</div>'
    end

    local tab_parts = {}
    for _, source in ipairs({ 'regular', 'special', 'living_together' }) do
        local block = render_group(frame, 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 '<div class="char-empty char-schedule-empty">暂无可展示的行程数据。</div>'
    end

    local out = { '<div class="char-schedule-root">' }
    if has_multi_candidate_slots(variants) then
        out[#out + 1] = '<div class="char-note char-schedule-note">同一时段如出现多行,表示该时段存在多个候选动作;概率为该时段候选动作的相对权重。</div>'
    end
    out[#out + 1] = frame:extensionTag('tabber', table.concat(tab_parts, '\n'), {
        class = 'char-tabber char-schedule-tabber',
    })
    out[#out + 1] = '</div>'
    return table.concat(out, '\n')
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(frame, key)
    if output == '' then
        return ''
    end
    return frame:preprocess(output)
end

return p