12.3 节点运营与内容日历

活动有了模板、赛季有了节奏,最后一件事是把它们排到同一张日历上:节日、版本、赛季、平台活动、日常活动怎么错开、不撞车、不超产能。本节给出一份节点排期配置,用 S 层脚本做四条校验——同类间隔、重类型间隔、重节点并发、每周产能——把「排期靠拍脑袋」变成能报错的数字。

前两节把「一个活动怎么发奖」和「一个赛季怎么排节奏」讲完了,这一节解决最后一个问题:把它们全部排到同一张日历上。同样先说边界——本节只做排期与校验,不讲单个活动的奖励设计(12.1)、不讲赛季内节奏(12.2)、更不碰版本列车与生命周期退场(第 13 章)。也要说明与站内内容的分工:小游戏服务平台专题 那 25 篇讲的是怎么搭平台(平台侧的能力、分发、结算),H5 游戏产业全景 讲的是产业大盘;本节站在一款游戏的运营台前,只回答一个问题——未来 12 周,节日、版本、赛季、活动怎么排才不打架。

内容日历要解决的三个问题

单个活动再漂亮,排到一起就会出三类事故,而且这三类都不是「审美问题」,是会真实发生的事故:

  • 撞车:两个重节点叠在同一周,玩家的注意力被撕成两半,两个活动的效果都不如单独上;团队也会在同一时刻被两个上线日拖垮。
  • 超产:一周内塞了太多节点,运营团队(配置、素材、客服、投放)根本做不完。opsPoints 就是把「做完一个节点要多少人力」量化成一个数,一周的总点数不能超过产能上限。
  • 空窗:反过来,某些周一个节点都没有,活跃自然滑落,白丢了一整段可运营的时间。

这三类问题的共同点是:它们都能被机器提前发现。撞车是「同一时刻在跑的节点数超限」,超产是「一周点数超上限」,空窗是「某周负载为 0」。所以本节的核心不是画一张好看的甘特图,而是给排期写一套会报错的规则——排期表交上去,先跑校验,违规就报,改完再跑。

四类节点与两条硬约束

《合成小岛》的日历上有五类节点,其中三类是「外部给定、不能挪」的:

  • 节日(holiday):双十一、双十二、元旦——日期是平台和用户共同约定的,挪不了,只能围绕它排。
  • 版本(version):1.1 / 1.2 / 1.3——研发节奏给定,通常和节日对齐发布。
  • 赛季(season):第一、第二赛季——起止由运营规划,可以微调。
  • 平台(platform):平台方举办的活动周——时间由平台定,只能配合。
  • 活动(event):收集、签到这类日常活动——最灵活,用来填空窗。

排期有两条硬约束。第一条是间隔:同类节点之间必须隔够天数,否则用户疲劳、团队也吃不消。规则分两档——同类节点最小间隔(如两个节日至少隔 14 天)和重类型最小间隔(版本 / 赛季这类重节点至少隔 21 天)。第二条是并发:同一时刻在跑的重节点数不能超过上限(这里定 2 个)。重节点指会占用大量运营资源、抢占用户注意力的节点,比如赛季和版本。

四条校验规则

把上面的约束落成四条机器可查的规则:

  1. 同类间隔:按起始天排序,同类相邻两个节点差值 < minGapDaysSameType(14)即违规。
  2. 重类型间隔:version / season 相邻差值 < minGapDaysHeavyType(21)即违规。
  3. 重节点并发:逐日统计 major 为真的在跑节点数,超过 maxConcurrentMajor(2)即违规。
  4. 每周产能:把每个节点的 opsPoints 归到它的起始周,某周总和超过 weeklyOpsPoints(40)即违规。

这四条都是纯计数,没有任何主观判断——这正是它们可靠的原因。排期表怎么改都行,只要四条规则全过,这张日历在「不打架、不超产」这个维度上就是干净的。

D 层:节点排期配置

下面这份是 D 层(静态层)配置:horizon 是日历窗口(起始日 + 84 天 + 12 周)、capacity 是每周产能与重节点并发上限、constraints 是间隔规则,nodes 是 12 个节点的排期(类型、起止天、是否重节点、opsPoints、负责人)。全整数,禁浮点。

{
  "version": 1,
  "series": "《小游戏开发实战》",
  "volume": "卷三《上线变现与长线运营》",
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "节点运营与内容日历配置(D 层)。horizon 声明日历窗口;capacity 给每周运营产能上限与同时在跑的重节点上限;constraints 给同类节点最小间隔、重类型(版本 / 赛季)最小间隔与重类型集合。nodes 是排期表:每个节点带类型(season / holiday / version / platform / event)、起止天、是否重节点、占用的运营点数 opsPoints 与负责人。校验:同类最小间隔、重类型最小间隔、重节点并发上限、每周产能上限。全整数,禁浮点。排期与 opsPoints 均为示例假设值,真实排期需真实业务环境,命令行不可复现(X)。",
  "horizon": { "startDate": "2026-11-02", "days": 84, "weeks": 12 },
  "capacity": { "weeklyOpsPoints": 40, "maxConcurrentMajor": 2 },
  "constraints": { "minGapDaysSameType": 14, "minGapDaysHeavyType": 21, "heavyTypes": ["version", "season"] },
  "nodes": [
    { "id": "s1-season", "name": "第一赛季·珊瑚季开赛", "type": "season", "startDay": 0, "endDay": 55, "major": true, "opsPoints": 30, "owner": "运营" },
    { "id": "double11", "name": "双十一大促", "type": "holiday", "startDay": 8, "endDay": 10, "major": true, "opsPoints": 20, "owner": "商业化" },
    { "id": "v1-1", "name": "1.1 版本更新", "type": "version", "startDay": 14, "endDay": 14, "major": true, "opsPoints": 25, "owner": "客户端" },
    { "id": "harvest", "name": "丰收季收集活动", "type": "event", "startDay": 21, "endDay": 34, "major": true, "opsPoints": 12, "owner": "运营" },
    { "id": "platform-fest", "name": "平台小游戏活动周", "type": "platform", "startDay": 21, "endDay": 27, "major": true, "opsPoints": 15, "owner": "增长" },
    { "id": "v1-2", "name": "1.2 版本更新", "type": "version", "startDay": 42, "endDay": 42, "major": true, "opsPoints": 25, "owner": "客户端" },
    { "id": "double12", "name": "双十二促销", "type": "holiday", "startDay": 49, "endDay": 51, "major": false, "opsPoints": 18, "owner": "商业化" },
    { "id": "s2-season", "name": "第二赛季·季风季开赛", "type": "season", "startDay": 56, "endDay": 83, "major": true, "opsPoints": 30, "owner": "运营" },
    { "id": "winter", "name": "冬季主题活动", "type": "event", "startDay": 63, "endDay": 76, "major": false, "opsPoints": 14, "owner": "运营" },
    { "id": "v1-3", "name": "1.3 版本更新", "type": "version", "startDay": 70, "endDay": 70, "major": true, "opsPoints": 25, "owner": "客户端" },
    { "id": "newyear", "name": "元旦跨年盛典", "type": "holiday", "startDay": 77, "endDay": 79, "major": true, "opsPoints": 60, "owner": "商业化" },
    { "id": "spring-pre", "name": "春节预热", "type": "event", "startDay": 80, "endDay": 83, "major": false, "opsPoints": 12, "owner": "运营" }
  ]
}

S 层:排期校验脚本

下面这段是 S 层脚本:读上面这份配置,跑四条校验,把违规逐条列出来,并算出每周产能负载。产出 JSON + CSV + SVG(甘特式日历 + 产能负载条)。

#!/usr/bin/env python3
# file: sim/l12_calendar.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l12_calendar.py --config config/ --out out/
"""节点运营与内容日历排期校验:读 config/l12_calendar.json。

四条校验:
1) 同类节点最小间隔 minGapDaysSameType(按起始天排序,相邻同类差 < 阈值即违规);
2) 重类型节点最小间隔 minGapDaysHeavyType(version / season);
3) 重节点并发上限 maxConcurrentMajor(逐日统计在跑的重节点数,只报每段连续违规的首日);
4) 每周运营产能上限 weeklyOpsPoints(按节点起始周归集 opsPoints)。
产出:out/l12_calendar.json + .csv + .svg。全整数地板除,禁浮点。排期与 opsPoints 均为示例
假设值,真实排期需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

TYPE_COLOR = {"season": "#2563eb", "holiday": "#f59e0b", "version": "#8b5cf6",
              "platform": "#10b981", "event": "#64748b"}


def load(cfg_dir: str) -> dict:
    with open(os.path.join(cfg_dir, "l12_calendar.json"), encoding="utf-8") as f:
        return json.load(f)


def week_of(day: int) -> int:
    return day // 7 + 1


def check_gap(nodes: list, types: set, min_gap: int, label: str) -> list[str]:
    errs = []
    for t in sorted(types):
        sel = sorted((n for n in nodes if n["type"] == t), key=lambda n: n["startDay"])
        for a, b in zip(sel, sel[1:]):
            if b["startDay"] - a["startDay"] < min_gap:
                errs.append(f"{label} {t}: {a['id']} 与 {b['id']} 间隔 {b['startDay'] - a['startDay']} 天 < {min_gap}")
    return errs


def check_concurrency(nodes: list, days: int, limit: int) -> tuple[list[str], int]:
    errs, peak, prev = [], 0, ""
    for d in range(days):
        active = [n for n in nodes if n["major"] and n["startDay"] <= d <= n["endDay"]]
        peak, sig = max(peak, len(active)), "+".join(n["id"] for n in active)
        if len(active) > limit and sig != prev:
            errs.append(f"并发超限 第 {d} 天: 重节点 {len(active)} 个 > {limit}({sig})")
        prev = sig
    return errs, peak


def weekly_load(nodes: list, weeks: int) -> list[int]:
    load = [0] * (weeks + 1)
    for n in nodes:
        load[week_of(n["startDay"])] += n["opsPoints"]
    return load[1:]


def draw_svg(path: str, nodes: list, weeks: int, wload: list, cap: int) -> int:
    W, ROW, TOP, L = 780, 26, 60, 200
    H = TOP + ROW * len(nodes) + 90
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        "《合成小岛》节点运营日历(示例排期,相对量级)"
    span = W - L - 40
    for i, n in enumerate(nodes):
        y = TOP + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 14), "font-size": "11", "fill": "#374151"}).text = \
            f"{n['name']}({n['opsPoints']}点)"
        x0 = L + span * n["startDay"] // (weeks * 7)
        x1 = L + span * (n["endDay"] + 1) // (weeks * 7)
        ET.SubElement(svg, "rect", {"x": str(x0), "y": str(y + 2), "width": str(max(x1 - x0, 4)), "height": "14",
                                    "rx": "3", "fill": TYPE_COLOR.get(n["type"], "#6b7280"),
                                    "stroke": "#111827" if n["major"] else "none"})
    by, maxl = TOP + ROW * len(nodes) + 24, max(max(wload), cap, 1)
    ET.SubElement(svg, "text", {"x": "16", "y": str(by - 6), "font-size": "12", "fill": "#374151"}).text = \
        f"每周运营产能负载(上限 {cap})"
    for w, v in enumerate(wload, start=1):
        x, h = L + span * (w - 1) // weeks, 44 * v // maxl
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(by + 44 - h), "width": str(span // weeks - 6),
                                    "height": str(h), "rx": "2", "fill": "#dc2626" if v > cap else "#38bdf8"})
        ET.SubElement(svg, "text", {"x": str(x + 2), "y": str(by + 44 - h - 3), "font-size": "10",
                                    "fill": "#6b7280"}).text = str(v)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    nodes, days, weeks = cfg["nodes"], cfg["horizon"]["days"], cfg["horizon"]["weeks"]
    con, cap = cfg["constraints"], cfg["capacity"]
    violations = []
    violations += check_gap(nodes, {n["type"] for n in nodes}, con["minGapDaysSameType"], "同类间隔")
    violations += check_gap(nodes, set(con["heavyTypes"]), con["minGapDaysHeavyType"], "重类型间隔")
    conc_errs, peak = check_concurrency(nodes, days, cap["maxConcurrentMajor"])
    violations += conc_errs
    wload = weekly_load(nodes, weeks)
    for w, v in enumerate(wload, start=1):
        if v > cap["weeklyOpsPoints"]:
            violations.append(f"产能超限 第 {w} 周: {v} 点 > 上限 {cap['weeklyOpsPoints']}")
    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l12_calendar.svg"), nodes, weeks, wload, cap["weeklyOpsPoints"])
    with open(os.path.join(a.out, "l12_calendar.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "type", "major", "startDay", "endDay", "startWeek", "opsPoints", "owner"])
        for n in nodes:
            w.writerow([n["id"], n["name"], n["type"], n["major"], n["startDay"], n["endDay"],
                        week_of(n["startDay"]), n["opsPoints"], n["owner"]])
    report = {"version": cfg["version"], "game": cfg["game"], "startDate": cfg["horizon"]["startDate"],
              "days": days, "weeks": weeks, "nodeCount": len(nodes), "weeklyOpsLoad": wload,
              "weeklyOpsCapacity": cap["weeklyOpsPoints"], "maxConcurrentMajor": peak,
              "violations": violations, "valid": not violations,
              "nodes": [dict(n, startWeek=week_of(n["startDay"])) for n in nodes], "svgBytes": sb}
    with open(os.path.join(a.out, "l12_calendar.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"valid": not violations, "violationCount": len(violations), "violations": violations,
                      "weeklyOpsLoad": wload, "maxConcurrentMajor": peak, "svgBytes": sb},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它(示例排期),输出如下(同机相对量级,绝对数值不可移植):

{
  "valid": false,
  "violationCount": 2,
  "violations": [
    "并发超限 第 21 天: 重节点 3 个 > 2(s1-season+harvest+platform-fest)",
    "产能超限 第 12 周: 72 点 > 上限 40"
  ],
  "weeklyOpsLoad": [
    30,
    20,
    25,
    27,
    0,
    0,
    25,
    18,
    30,
    14,
    25,
    72
  ],
  "maxConcurrentMajor": 3,
  "svgBytes": 4034
}

读这份输出

这张排期不干净,两条违规各代表一类事故。

第一条是撞车:第 21 天有三个重节点同时在跑——s1-season(第一赛季横跨 0–55 天)、harvest(丰收季收集,21–34 天)、platform-fest(平台活动周,21–27 天)。三者叠在同一段里,超过并发上限 2。有意思的是,harvest 和 platform-fest 都从第 21 天开始——两个节点被排在了同一天。这不是巧合,而是排期时没看「当天已有谁在跑」的典型错误。修法很直接:把 platform-fest 推到第 28 天开始(错开丰收季),或者把 harvest 挪到第 35 天。

第二条是超产:第 12 周要 72 个运营点,上限只有 40。原因是 newyear(元旦跨年盛典,60 点)和 spring-pre(春节预热,12 点)都落在第 12 周。跨年盛典的 60 点是全表最高的——它本身就是一个大节点,再叠一个预热活动,一周就爆了。修法:把春节预热拆成两周的轻量预热,或者把跨年盛典的部分工作前置到第 11 周。

再看每周负载这条线:[30, 20, 25, 27, 0, 0, 25, 18, 30, 14, 25, 72]。第 5、6 周负载为 0——这两周一个节点都没有,就是前面说的空窗。虽然脚本没把空窗判成违规(它只是「没超上限」),但一个健康的日历不该连续两周空白。这里正好是 harvest(第 4–5 周)刚结束、platform-fest 还没补上的缺口,用一类轻量的日常活动(如第 5 周开一个收集小活动)填上即可。

这张日历最终要人改,但改之前先让机器报错。排期最怕的不是「排得不好」,而是「排得不好却没人发现」——等到上线前一天才发现两个活动撞在同一天,就来不及了。四条纯计数的规则能在排期阶段就把这两类事故挡住。

小结与下一步

这一节把活动与赛季排进了同一张内容日历,并用四条纯计数规则做校验:同类间隔、重类型间隔、重节点并发、每周产能。三件事记住就够——**节日和平台节点挪不了,活动用来填空窗;重节点并发是撞车,产能超限是超产,两者都要在排期阶段报错;空窗不报错但同样伤活跃。**到这里,第 12 章把「活动与赛季运营」这条线走完了:活动模板与奖励预算(12.1)、赛季结构与节奏(12.2)、节点运营与内容日历(12.3)。下一章进入更长的尺度——版本列车怎么开、内容产能怎么排、一个游戏的生命周期到什么时候该退场。

阅读导航:上一节:赛季结构与节奏 · 下一节:13.1 版本列车与节奏 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制