KNOWLEDGE MODULE 08

工程配置与运行诊断

使用 Flag、CPU 指标、自动降载、通知、事件日志和房间级异常隔离观察系统,把配置、性能保护和故障恢复从具体业务动作中分离出来。

专题内进度3 / 6查看完整学习顺序 →

Screeps 进阶开发

Screeps CPU Bucket 一直下降怎么办:任务分级、降载与自动恢复

当 Game.cpu.bucket 持续下降时,用带迟滞的 NORMAL、CONSERVE、EMERGENCY 与 RECOVERY 状态机保护 Spawn、采集、Controller 和防御,逐级暂停高成本任务,并在恢复后分阶段重新启用。

难度
基础
适用阶段
配置与性能保护
模块位置
3 / 6
前置知识
Game.cpu.getUsed() 和 bucket 怎么监控 CPU

KNOWLEDGE GRAPH

把这篇文章连接到 API、错误码和工具

这里由现有 API Reference、Object Hub 与知识库顺序自动生成,用来继续排查和学习;它不替代正文中的具体前置条件与返回值说明。

NEXT TOPIC

继续这个知识模块

当前属于“工程配置与运行诊断”第 3 / 6 篇。

下一篇:Game.notify() 怎么发送可靠的限频提醒
VERIFICATION文档已核对 · 语法已检查 · Console待测试 · 主循环待验证查看验证详情
官方文档
已核对
JavaScript 语法
已检查
Screeps Console
待测试
真实主循环
待验证
最后核对
2026年8月5日
测试环境
Node.js 22 离线模拟(CPU 模式转换、迟滞、连续 tick 确认、任务验证、任务等级与执行间隔;不是 Screeps 官方服务器)
测试日期
2026年8月5日
测试结果
30 个离线状态转换、损坏任务与任务许可场景通过;完整调度器通过 JavaScript 语法检查。真实 CPU 单位、bucket 趋势和官方 shard 主循环仍待验证。
本文目录22 个主要章节
  1. 常见错误

Game.cpu.bucket 偶尔下降并不一定是故障。路径搜索、全局重置、首次读取 Memory、战斗或可见对象数量变化,都可能让某些 tick 比平时更贵。

真正危险的是:bucket 长期单向下降,而主循环仍然每 tick 执行市场扫描、路径预计算、统计、RoomVisual 和其他非关键任务。 当储备接近耗尽时,脚本可能在 Spawn 恢复、采集或防御执行前就停止。

本文解决一个明确问题:

当 CPU 储备持续恶化时,怎样优先保住房间生存逻辑,逐级降低非关键任务频率,并在 bucket 恢复后避免所有高成本任务同一 tick 一起重启。

快速结论

不要只写一个阈值:

if (Game.cpu.bucket > 5000) {
  runEverything();
}

它没有区分“必须运行”和“可以暂停”的任务,也会在 49995001 附近反复开关。

更稳定的 CPU 降载方案至少需要:

  1. 将任务分为关键、重要、可选三级;
  2. 使用 NORMALCONSERVEEMERGENCYRECOVERY 四种模式;
  3. 进入与退出使用不同阈值;
  4. 连续多个 tick 满足条件后再切换;
  5. 关键任务始终优先执行;
  6. 恢复时分阶段放开任务;
  7. 无效任务必须在排序前隔离;
  8. 只在模式真正变化时记录或提醒。

与已有 CPU 文章的明确区别

站内现有的 Game.cpu.getUsed() 和 bucket 怎么监控 CPU 负责回答:

  • limittickLimitbucket 分别表示什么;
  • 怎样用 getUsed() 测量代码段;
  • 怎样记录平均值和峰值;
  • 为什么一次样本不能证明长期性能。

本文不重复基础测量。本页的独立搜索意图是:

已经知道 bucket 在下降
→ 怎样自动减少工作
→ 哪些逻辑绝对不能停
→ 什么时候可以恢复

因此它值得使用独立 Slug,而不是把监控页扩写成同时覆盖测量、诊断、调度和恢复的宽泛文章。

先区分官方事实与本地策略

在 Screeps 官方在线环境中,未使用的基础 CPU 会进入 bucket,bucket 上限为 10,000。拥有储备时,当前 tick 可以使用高于基础 Game.cpu.limit 的额度,实际可用上限通过 Game.cpu.tickLimit 读取。

如果脚本超过当前 tick 可用 CPU,执行会停止。命令和世界状态的结算发生在脚本阶段之后,所以把关键逻辑放在主循环末尾存在真实风险。

本文示例中的:

7000、2000、4500、8500
0.70、0.90、0.55、0.45
3 tick、20 tick、25 tick、50 tick

都是可修改的本地策略值,不是官方推荐阈值。实际配置应根据账号 CPU 额度、房间数量、战斗状态、路径搜索规模和恢复能力调整。

为什么只看 bucket 当前值不够

假设两个账号当前 bucket 都是 6000

  • 账号 A 的平均消耗低于基础额度,bucket 正在恢复;
  • 账号 B 的平均消耗高于基础额度,bucket 仍在持续下降。

它们不应执行完全相同的策略。

第一版调度器至少同时读取:

const used = Game.cpu.getUsed();
const tickLimit = Game.cpu.tickLimit;
const usedRatio = used / tickLimit;
const bucket = Game.cpu.bucket;

这里的 usedRatio 只表示调度器运行到当前位置时已使用的比例,不代表本 tick 最终总消耗。它适合判断“是否还应启动下一项非关键任务”,不能替代完整的长期 CPU 统计。

为什么单阈值会抖动

下面的代码会造成模式抖动:

const lowCpu = Game.cpu.bucket < 5000;

当 bucket 在阈值附近来回变化时,市场扫描、路径缓存和可视化会不断关闭、重开。重新启用本身可能制造高峰,又把 bucket 压回阈值下方。

稳定状态机需要迟滞:

进入降载:bucket < 7000
退出降载:bucket >= 8500

进入和退出阈值之间保留缓冲区,再配合连续 tick 确认和最短模式保持时间,可以减少一次异常样本导致的频繁切换。

四种 CPU 运行模式

模式 目标 任务策略
NORMAL 正常运行 按原计划执行全部任务
CONSERVE 阻止 bucket 快速下降 关键任务照常,重要任务降频,可选任务大幅降频
EMERGENCY 保护房间生存能力 关键任务照常,重要任务低频,可选任务停止
RECOVERY 验证恢复是否稳定 关键任务照常,重要任务逐步恢复,可选任务延迟重启

RECOVERY 很重要。没有恢复阶段时,bucket 刚离开紧急区,市场扫描、路径预计算和视觉日志可能一起恢复,立即造成第二次下跌。

哪些任务必须视为关键任务

典型关键任务包括:

关键任务不能写成:

if (Game.cpu.bucket > 8000) {
  runDefense();
}

CPU 越紧张,系统越需要保留自救能力。降载的目标是暂停不紧急的计算,而不是暂停房间生存逻辑。

重要任务与可选任务

重要任务

重要任务可以延迟,但长期完全停止会影响经济或维护:

  • 普通 Builder 调度;
  • Link、Lab、Factory、Terminal 的周期协调;
  • 非紧急维修;
  • 远程房间状态刷新;
  • 常规统计汇总。

可选任务

可选任务可以在紧急模式中完全暂停:

  • 全图市场扫描;
  • 大范围路径预计算;
  • 非必要 RoomVisual;
  • 排行榜或报表生成;
  • 低优先级 Observer 扫描;
  • 不影响当前 tick 决策的历史统计。

任务等级是业务决策,不是 API 属性。战争状态下,原本“重要”的远程情报可能需要临时升为“关键”。

可离线验证的模式状态机

下面的决策代码不依赖 Screeps 对象,可以直接在 Node.js 中测试。

const CPU_MODE = Object.freeze({
  NORMAL: 'NORMAL',
  CONSERVE: 'CONSERVE',
  EMERGENCY: 'EMERGENCY',
  RECOVERY: 'RECOVERY'
});

const CPU_POLICY = Object.freeze({
  hardEmergencyBucket: 500,
  conserveBelow: 7000,
  emergencyBelow: 2000,
  recoveryAbove: 4500,
  normalAbove: 8500,
  conserveUsedRatio: 0.70,
  hardUsedRatio: 0.90,
  recoveryUsedRatio: 0.55,
  normalUsedRatio: 0.45,
  degradeConfirmTicks: 3,
  recoverConfirmTicks: 20,
  minimumModeTicks: 25,
  recoveryWarmupTicks: 50,
  nonCriticalHeadroom: 0.15,
  historyLimit: 20,
  failureLimit: 20
});

const MODE_SEVERITY = Object.freeze({
  [CPU_MODE.NORMAL]: 0,
  [CPU_MODE.RECOVERY]: 1,
  [CPU_MODE.CONSERVE]: 2,
  [CPU_MODE.EMERGENCY]: 3
});

function validMode(mode) {
  return Object.prototype.hasOwnProperty.call(
    MODE_SEVERITY,
    mode
  );
}

function normalizeCpuState(value, gameTime) {
  const mode = validMode(value?.mode)
    ? value.mode
    : CPU_MODE.NORMAL;

  return {
    mode,
    modeSince: Number.isInteger(value?.modeSince)
      ? value.modeSince
      : gameTime,
    candidateMode: validMode(value?.candidateMode)
      ? value.candidateMode
      : null,
    candidateTicks:
      Number.isInteger(value?.candidateTicks)
      && value.candidateTicks >= 0
        ? value.candidateTicks
        : 0
  };
}

function selectDesiredCpuMode(
  state,
  metrics,
  policy = CPU_POLICY
) {
  const { bucket, usedRatio } = metrics;

  if (
    !Number.isFinite(bucket)
    || !Number.isFinite(usedRatio)
    || usedRatio < 0
  ) {
    return {
      mode: CPU_MODE.EMERGENCY,
      reason: 'invalid-cpu-metrics',
      immediate: true
    };
  }

  if (
    bucket <= policy.hardEmergencyBucket
    || usedRatio >= policy.hardUsedRatio
  ) {
    return {
      mode: CPU_MODE.EMERGENCY,
      reason: 'hard-cpu-risk',
      immediate: true
    };
  }

  if (bucket <= policy.emergencyBelow) {
    return {
      mode: CPU_MODE.EMERGENCY,
      reason: 'bucket-emergency',
      immediate: false
    };
  }

  switch (state.mode) {
    case CPU_MODE.NORMAL:
      return (
        bucket < policy.conserveBelow
        || usedRatio >= policy.conserveUsedRatio
      )
        ? {
            mode: CPU_MODE.CONSERVE,
            reason: 'conserve-threshold',
            immediate: false
          }
        : {
            mode: CPU_MODE.NORMAL,
            reason: 'healthy',
            immediate: false
          };

    case CPU_MODE.CONSERVE:
      return (
        bucket >= policy.normalAbove
        && usedRatio <= policy.normalUsedRatio
      )
        ? {
            mode: CPU_MODE.NORMAL,
            reason: 'normal-threshold',
            immediate: false
          }
        : {
            mode: CPU_MODE.CONSERVE,
            reason: 'conserve-hold',
            immediate: false
          };

    case CPU_MODE.EMERGENCY:
      return (
        bucket >= policy.recoveryAbove
        && usedRatio <= policy.recoveryUsedRatio
      )
        ? {
            mode: CPU_MODE.RECOVERY,
            reason: 'recovery-threshold',
            immediate: false
          }
        : {
            mode: CPU_MODE.EMERGENCY,
            reason: 'emergency-hold',
            immediate: false
          };

    case CPU_MODE.RECOVERY:
      if (
        bucket < policy.conserveBelow
        || usedRatio >= policy.conserveUsedRatio
      ) {
        return {
          mode: CPU_MODE.CONSERVE,
          reason: 'recovery-regressed',
          immediate: false
        };
      }
      return (
        bucket >= policy.normalAbove
        && usedRatio <= policy.normalUsedRatio
      )
        ? {
            mode: CPU_MODE.NORMAL,
            reason: 'recovery-complete',
            immediate: false
          }
        : {
            mode: CPU_MODE.RECOVERY,
            reason: 'recovery-hold',
            immediate: false
          };

    default:
      return {
        mode: CPU_MODE.EMERGENCY,
        reason: 'invalid-state-mode',
        immediate: true
      };
  }
}

function updateCpuMode(
  previous,
  metrics,
  gameTime,
  policy = CPU_POLICY
) {
  const state = normalizeCpuState(previous, gameTime);
  const desired = selectDesiredCpuMode(
    state,
    metrics,
    policy
  );

  if (desired.mode === state.mode) {
    return {
      ...state,
      candidateMode: null,
      candidateTicks: 0,
      changed: false,
      reason: desired.reason
    };
  }

  if (desired.immediate) {
    return {
      mode: desired.mode,
      modeSince: gameTime,
      candidateMode: null,
      candidateTicks: 0,
      changed: true,
      reason: desired.reason
    };
  }

  const candidateTicks =
    state.candidateMode === desired.mode
      ? state.candidateTicks + 1
      : 1;
  const degrading =
    MODE_SEVERITY[desired.mode]
    > MODE_SEVERITY[state.mode];
  const requiredTicks = degrading
    ? policy.degradeConfirmTicks
    : policy.recoverConfirmTicks;
  const heldLongEnough = degrading
    || gameTime - state.modeSince
      >= policy.minimumModeTicks;

  if (
    candidateTicks >= requiredTicks
    && heldLongEnough
  ) {
    return {
      mode: desired.mode,
      modeSince: gameTime,
      candidateMode: null,
      candidateTicks: 0,
      changed: true,
      reason: desired.reason
    };
  }

  return {
    ...state,
    candidateMode: desired.mode,
    candidateTicks,
    changed: false,
    reason: `confirm-${desired.reason}`
  };
}

这段状态机实现了“进入快、退出慢”:

  • 极低 bucket 或极高已用比例立即进入紧急模式;
  • 普通降载需要连续 3 tick 确认;
  • 恢复需要更长的 20 tick 确认;
  • 恢复方向受到最短模式保持时间限制;
  • 损坏的 CPU 指标按紧急模式处理,而不是误判为正常。

这是一种保守策略,代价是非关键任务可能暂停更久。

完整 CPU 降载调度器

下面的调度器负责:

  • 保存当前模式;
  • 记录有界的模式转换和失败历史;
  • 按等级和模式计算任务间隔;
  • 用任务名生成稳定错峰;
  • 在启动非关键任务前检查剩余 CPU;
  • 在排序前隔离 null、缺失名称、缺失函数和未知等级任务
  • 隔离单个任务异常;
  • 输出本 tick 执行、跳过和失败结果。

业务函数仍由现有模块提供,调度器不伪造采集、Spawn 或防御逻辑。

const TASK_TIER = Object.freeze({
  CRITICAL: 'critical',
  IMPORTANT: 'important',
  OPTIONAL: 'optional'
});

const TIER_ORDER = Object.freeze({
  [TASK_TIER.CRITICAL]: 0,
  [TASK_TIER.IMPORTANT]: 1,
  [TASK_TIER.OPTIONAL]: 2
});

function readCpuMetrics() {
  const used = Game.cpu.getUsed();
  const tickLimit = Game.cpu.tickLimit;

  return {
    bucket: Game.cpu.bucket,
    used,
    tickLimit,
    usedRatio:
      Number.isFinite(tickLimit)
      && tickLimit > 0
        ? used / tickLimit
        : Number.POSITIVE_INFINITY
  };
}

function pushBounded(list, value, limit) {
  list.push(value);
  while (list.length > limit) list.shift();
}

function getSchedulerState() {
  Memory.cpuScheduler ??= {
    mode: CPU_MODE.NORMAL,
    modeSince: Game.time,
    candidateMode: null,
    candidateTicks: 0,
    transitions: [],
    failures: []
  };

  const state = Memory.cpuScheduler;
  state.transitions ??= [];
  state.failures ??= [];
  return state;
}

function taskIntervalFor(
  mode,
  tier,
  baseInterval,
  recoveryAge,
  policy = CPU_POLICY
) {
  if (
    !Number.isInteger(baseInterval)
    || baseInterval < 1
  ) {
    return null;
  }

  if (tier === TASK_TIER.CRITICAL) {
    return baseInterval;
  }

  if (tier === TASK_TIER.IMPORTANT) {
    if (mode === CPU_MODE.NORMAL) return baseInterval;
    if (mode === CPU_MODE.CONSERVE) {
      return Math.max(baseInterval, 2);
    }
    if (mode === CPU_MODE.EMERGENCY) {
      return Math.max(baseInterval, 10);
    }
    return Math.max(baseInterval, 2);
  }

  if (tier === TASK_TIER.OPTIONAL) {
    if (mode === CPU_MODE.NORMAL) return baseInterval;
    if (mode === CPU_MODE.CONSERVE) {
      return Math.max(baseInterval, 20);
    }
    if (mode === CPU_MODE.EMERGENCY) return null;
    if (recoveryAge < policy.recoveryWarmupTicks) {
      return null;
    }
    return Math.max(baseInterval, 10);
  }

  return null;
}

function hashTaskName(name) {
  let value = 0;
  for (let index = 0; index < name.length; index += 1) {
    value = (value * 31 + name.charCodeAt(index)) >>> 0;
  }
  return value;
}

function isCadenceTick(name, interval) {
  if (interval === 1) return true;
  const offset = hashTaskName(name) % interval;
  return Game.time % interval === offset;
}

function remainingCpuRatio() {
  if (
    !Number.isFinite(Game.cpu.tickLimit)
    || Game.cpu.tickLimit <= 0
  ) {
    return 0;
  }

  return Math.max(
    0,
    (Game.cpu.tickLimit - Game.cpu.getUsed())
      / Game.cpu.tickLimit
  );
}

function isValidTask(task) {
  return Boolean(task)
    && typeof task.name === 'string'
    && task.name.length > 0
    && typeof task.run === 'function'
    && Object.prototype.hasOwnProperty.call(
      TIER_ORDER,
      task.tier
    );
}

function runOneTask(task, context, state) {
  const start = Game.cpu.getUsed();

  try {
    const value = task.run(context);
    return {
      name: task.name,
      tier: task.tier,
      status: 'ran',
      used: Game.cpu.getUsed() - start,
      value
    };
  } catch (error) {
    const failure = {
      tick: Game.time,
      name: task.name,
      tier: task.tier,
      message:
        error instanceof Error
          ? error.message.slice(0, 300)
          : String(error).slice(0, 300)
    };

    pushBounded(
      state.failures,
      failure,
      CPU_POLICY.failureLimit
    );

    return {
      name: task.name,
      tier: task.tier,
      status: 'failed',
      used: Game.cpu.getUsed() - start,
      failure
    };
  }
}

function runCpuScheduler(tasks) {
  const state = getSchedulerState();
  const previousMode = state.mode;
  const metrics = readCpuMetrics();
  const next = updateCpuMode(
    state,
    metrics,
    Game.time
  );

  state.mode = next.mode;
  state.modeSince = next.modeSince;
  state.candidateMode = next.candidateMode;
  state.candidateTicks = next.candidateTicks;

  if (next.changed) {
    const transition = {
      tick: Game.time,
      from: previousMode,
      to: next.mode,
      reason: next.reason,
      bucket: metrics.bucket,
      usedRatio: metrics.usedRatio
    };

    pushBounded(
      state.transitions,
      transition,
      CPU_POLICY.historyLimit
    );
    state.lastTransition = transition;
    state.pendingNotification = transition;
  }

  const recoveryAge = state.mode === CPU_MODE.RECOVERY
    ? Game.time - state.modeSince
    : 0;
  const results = [];
  const validTasks = [];
  const rawTasks = Array.isArray(tasks) ? tasks : [];

  if (!Array.isArray(tasks)) {
    results.push({
      name: null,
      status: 'invalid-task-list'
    });
  }

  // Validate before sort. A null or malformed task must never
  // throw in the comparator before critical tasks can run.
  for (const task of rawTasks) {
    if (!isValidTask(task)) {
      results.push({
        name:
          task && typeof task.name === 'string'
            ? task.name
            : null,
        status: 'invalid-task'
      });
      continue;
    }
    validTasks.push(task);
  }

  validTasks.sort(
    (left, right) =>
      TIER_ORDER[left.tier] - TIER_ORDER[right.tier]
      || left.name.localeCompare(right.name)
  );

  for (const task of validTasks) {
    const interval = taskIntervalFor(
      state.mode,
      task.tier,
      task.interval ?? 1,
      recoveryAge
    );

    if (interval === null) {
      results.push({
        name: task.name,
        tier: task.tier,
        status: 'disabled-by-mode'
      });
      continue;
    }

    if (!isCadenceTick(task.name, interval)) {
      results.push({
        name: task.name,
        tier: task.tier,
        status: 'waiting-for-cadence',
        interval
      });
      continue;
    }

    if (
      task.tier !== TASK_TIER.CRITICAL
      && remainingCpuRatio()
        < CPU_POLICY.nonCriticalHeadroom
    ) {
      results.push({
        name: task.name,
        tier: task.tier,
        status: 'insufficient-headroom'
      });
      continue;
    }

    results.push(
      runOneTask(
        task,
        {
          mode: state.mode,
          metrics,
          recoveryAge
        },
        state
      )
    );
  }

  state.lastTick = {
    tick: Game.time,
    mode: state.mode,
    bucketBefore: metrics.bucket,
    usedBefore: metrics.used,
    usedAfter: Game.cpu.getUsed(),
    resultCount: results.length
  };

  return {
    mode: state.mode,
    changed: next.changed,
    reason: next.reason,
    metrics,
    results
  };
}

module.exports = {
  CPU_MODE,
  TASK_TIER,
  runCpuScheduler,
  selectDesiredCpuMode,
  updateCpuMode,
  taskIntervalFor,
  isValidTask
};

怎样接入主循环

把现有业务模块作为回调交给调度器。下面的模块名只是接口示例,需要替换成自己的文件名和导出函数。

const cpuScheduler = require('cpu.scheduler');
const spawnEmergency = require('spawn.emergency');
const spawnManager = require('spawn.manager');
const defenseManager = require('defense.manager');
const creepManager = require('creep.manager');
const roomEconomy = require('room.economy');
const marketScanner = require('market.scanner');
const pathPlanner = require('path.planner');
const roomVisuals = require('room.visuals');

module.exports.loop = function () {
  const summary = cpuScheduler.runCpuScheduler([
    {
      name: 'spawn-emergency',
      tier: cpuScheduler.TASK_TIER.CRITICAL,
      interval: 1,
      run: () => spawnEmergency.run()
    },
    {
      name: 'spawn-manager',
      tier: cpuScheduler.TASK_TIER.CRITICAL,
      interval: 1,
      run: () => spawnManager.run()
    },
    {
      name: 'defense-manager',
      tier: cpuScheduler.TASK_TIER.CRITICAL,
      interval: 1,
      run: () => defenseManager.run()
    },
    {
      name: 'creep-manager',
      tier: cpuScheduler.TASK_TIER.CRITICAL,
      interval: 1,
      run: () => creepManager.run()
    },
    {
      name: 'room-economy',
      tier: cpuScheduler.TASK_TIER.IMPORTANT,
      interval: 1,
      run: () => roomEconomy.run()
    },
    {
      name: 'market-scan',
      tier: cpuScheduler.TASK_TIER.OPTIONAL,
      interval: 50,
      run: () => marketScanner.run()
    },
    {
      name: 'path-planner',
      tier: cpuScheduler.TASK_TIER.OPTIONAL,
      interval: 100,
      run: () => pathPlanner.run()
    },
    {
      name: 'room-visuals',
      tier: cpuScheduler.TASK_TIER.OPTIONAL,
      interval: 1,
      run: () => roomVisuals.run()
    }
  ]);

  if (summary.changed || Game.time % 100 === 0) {
    console.log(JSON.stringify({
      type: 'cpu-scheduler',
      tick: Game.time,
      mode: summary.mode,
      reason: summary.reason,
      bucket: summary.metrics.bucket,
      usedRatio: summary.metrics.usedRatio,
      results: summary.results
    }));
  }
};

为什么必须先验证任务再排序

错误顺序是:

读取 tasks
→ 直接 sort
→ 在循环中验证

如果数组中有 null、缺失 name 或未知 tier,排序比较器可能在读取 left.tier 或调用 left.name.localeCompare() 时抛错。此时整个调度器会在关键任务运行前退出。

正确顺序是:

读取 tasks
→ 分离无效项并记录
→ 只排序有效项
→ 执行关键、重要、可选任务

这也是为什么示例不仅检查未知等级,还检查任务列表本身是否为数组。

为什么关键任务仍应排在前面

即使关键任务不受模式禁用,它们仍应优先执行。原因是:

  • 当前 tick 的 CPU 可能已经很紧张;
  • 某个关键任务仍可能比预期更贵;
  • 非关键任务的 headroom 检查只对“下一项是否启动”有效;
  • 调度器无法把已经消耗的 CPU 退回 bucket。

关键任务内部也需要成本边界。防御模块不应在每个 Tower 上重复全房间排序;Spawn 管理器也不应被多个房间模块重复调用。

异常隔离不等于异常已解决

示例会捕获单个有效任务的异常,并把任务名、等级、tick 和错误消息写入有界历史。这样一个可选任务报错时,不会直接阻止后续 Spawn 或防御任务。

status: 'failed' 不表示问题已经安全处理。关键任务失败时仍应:

  • 保留完整错误上下文;
  • 观察是否连续失败;
  • 在状态变化或失败达到阈值时使用 Game.notify() 限频提醒
  • 修复根因,而不是长期依赖 try...catch 隐藏错误。

为什么任务要错峰

假设三个高成本任务都设置为每 100 tick 执行:

if (Game.time % 100 === 0) {
  runMarketScan();
  rebuildPaths();
  writeStatistics();
}

它们仍会在同一个 tick 形成高峰。

示例用任务名称生成稳定 offset:

market-scan 可能在余数 17 执行
path-planner 可能在余数 63 执行
statistics 可能在余数 84 执行

这不是随机值。同一个任务名会保持相同 offset,便于复现和诊断。

模式变化怎样记录和提醒

只在模式真正变化时保存:

const transition = {
  tick: Game.time,
  from: previousMode,
  to: next.mode,
  reason: next.reason,
  bucket: metrics.bucket,
  usedRatio: metrics.usedRatio
};

不要每 tick 发送“bucket 仍然很低”。推荐关注:

NORMAL → CONSERVE
CONSERVE → EMERGENCY
EMERGENCY → RECOVERY
RECOVERY → NORMAL
RECOVERY → CONSERVE

调度器只保存 pendingNotification,没有直接调用 Game.notify()。提醒频率和合并策略应交给专门的通知模块,避免性能模块自己刷屏。

怎样判断降载是否有效

至少持续观察:

  • bucket 是否停止单向下降;
  • 每 100 或 500 tick 的平均 Game.cpu.getUsed()
  • CPU 峰值是否减少;
  • 关键任务失败数是否增加;
  • Spawn、采集、Controller 和防御是否仍正常;
  • 模式切换是否过于频繁;
  • RECOVERY 是否反复退回 CONSERVE

“进入紧急模式后 bucket 上升”不能证明每个房间行为正确。CPU 恢复与房间功能验证是两组不同证据。

离线验证记录

本次覆盖 30 个离线场景:

  1. 正常指标保持 NORMAL
  2. bucket 低于保守阈值产生 CONSERVE 候选;
  3. 已用比例过高产生 CONSERVE 候选;
  4. bucket 低于紧急阈值产生 EMERGENCY 候选;
  5. 极低 bucket 立即进入 EMERGENCY
  6. 极高已用比例立即进入 EMERGENCY
  7. 无效 CPU 指标按紧急状态处理;
  8. 第一个降载样本只累计候选;
  9. 第二个样本仍不切换;
  10. 第三个连续样本完成降载;
  11. 指标恢复后清除候选;
  12. 硬风险绕过连续确认;
  13. 紧急模式产生 RECOVERY 候选;
  14. 恢复确认和最短保持同时满足后切换;
  15. 恢复失败退回 CONSERVE
  16. 恢复稳定后进入 NORMAL
  17. 最短模式保持时间阻止过早恢复;
  18. 损坏模式状态可安全归一化;
  19. 关键任务在正常模式保持原频率;
  20. 关键任务在紧急模式仍保持原频率;
  21. 重要任务在保守模式降频;
  22. 重要任务在紧急模式进一步降频;
  23. 可选任务在紧急模式停止;
  24. 可选任务在恢复预热期停止;
  25. 恢复预热完成后可选任务低频重启;
  26. 正常模式保留任务原始 interval;
  27. 未知任务等级被拒绝;
  28. null 任务在排序前被隔离;
  29. 缺失名称或运行函数的任务被隔离;
  30. 有效关键任务仍排在有效重要和可选任务之前。

完整调度器通过 JavaScript 语法检查。离线 Node.js 无法生成真实 Screeps CPU 单位,也不能证明官方 shard 中 bucket 会按预期恢复。

常见错误

把所有任务都放进 bucket 条件

这会让 CPU 紧张时停止采集、Spawn 恢复和防御,系统失去自救能力。

只设置一个进入和退出阈值

阈值附近会频繁切换模式,产生额外高峰和日志噪声。

bucket 一恢复就打开全部任务

市场扫描、路径预计算和可视化可能在同一 tick 重新制造高峰。应使用 RECOVERY 和错峰。

只看 bucket,不看代码段成本

降载只能延缓问题。仍需用 CPU 测量文章 找出真正高成本模块。

先排序损坏任务,再尝试验证

无效配置会在比较器中抛错,导致关键任务尚未执行调度器就退出。必须先验证再排序。

把 try...catch 当成修复

异常被记录后仍需要处理。静默忽略关键任务失败,只会让系统表现得“没有报错但也不工作”。

每 tick 写大量 Memory 和日志

调度器本身也有成本。历史数组必须有上限,详细结果应按固定间隔输出,模式没有变化时不要重复通知。

适用边界

本文提供的是单 shard、账号级 CPU 降载起点,不覆盖:

  • 多 shard CPU 分配策略;
  • 精确预测下一 tick 的 CPU;
  • 官方 shard 和私服参数差异;
  • V8 垃圾回收和堆内存分析;
  • 每个业务模块内部的性能优化;
  • 战斗状态下动态提升任务等级;
  • 外部长期监控数据库;
  • 真实主循环中的 bucket 恢复证明。

如果 EMERGENCY 持续很久,应继续定位高成本模块,而不是不断降低更多关键功能。

总结

CPU 降载的核心不是“bucket 低就少运行一点”,而是建立清晰的生存顺序:

先验证任务配置
→ 关键任务始终运行
→ 重要任务逐级降频
→ 可选任务停止
→ 指标稳定后进入恢复期
→ 高成本任务错峰重启

现有 CPU 监控页负责告诉你系统消耗了多少;本文的状态机负责在指标恶化时决定下一项任务是否应该启动。两者结合,才能把 CPU 观察变成可验证的运行策略。

官方参考资料

返回目录