Memory、RawMemory.segments 和 InterShardMemory 解决的是三个不同的问题:
| 数据容器 | 写入范围 | 主要用途 |
|---|---|---|
Memory |
当前 shard | 每 tick 常用的持久状态 |
RawMemory.segments |
当前 shard | 大型、按需激活的数据块 |
InterShardMemory |
当前 shard 只能写自己的字符串;其他 shard 只读 | 跨 shard 状态交换 |
官方 API 当前说明:每个 shard 有一份独立的 100 KB 字符串;本 shard 只能通过 setLocal() 替换自己的整份字符串,其他 shard 的数据只能通过 getRemote() 读取。因此,它不是“所有 shard 共同修改的共享对象”,而是多个 shard 各自发布、彼此订阅的状态总线。
快速答案
一个可维护的同步层至少要做到:
- 为整份数据保存
schemaVersion、sourceShard、writerEpoch和revision; - 为每个业务 channel 保存独立 revision;
- 本地字符串损坏时停止自动覆盖;
setLocal()前按 UTF-8 字节数检查容量,并保留安全余量;- 不直接用远端
Game.time与本地Game.time相减判断过期; - 用接收方本地观察窗口记录 revision 多久没有推进;
- writer epoch 改变时把它当作新数据流;
- 跨 shard 交接使用 offer/ack,而不是尝试写远端数据;
- 限制消息历史,避免整份字符串无限增长。
writerEpoch:区分回退与重新初始化
单独使用 revision 不够。数据被清空或迁移后,revision 可能从 0 重新开始;如果没有 epoch,接收方会把合法重启误判为永久回退。
function createWriterEpoch(shardName, now) {
if (
typeof shardName !== 'string'
|| shardName.trim() === ''
|| !Number.isInteger(now)
) {
return null;
}
return shardName + ':' + now;
}
本地 envelope 有效时继续沿用已有 epoch;只有开始一条新本地数据流时才创建新 epoch。
精确计算 UTF-8 字节数
JavaScript 的 text.length 统计 UTF-16 code unit,不等于实际 UTF-8 字节数。中文和 emoji 会占用多个字节。
function utf8ByteLength(value) {
const text = String(value);
let bytes = 0;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code < 0x80) {
bytes += 1;
} else if (code < 0x800) {
bytes += 2;
} else if (
code >= 0xD800
&& code <= 0xDBFF
&& index + 1 < text.length
&& text.charCodeAt(index + 1) >= 0xDC00
&& text.charCodeAt(index + 1) <= 0xDFFF
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
官方文档规定每个 shard 可保存 100 KB 字符串,但本文查到的 API 文档没有定义服务端精确的容量计量算法。下面按 UTF-8 字节数执行 96 * 1024 的项目侧保守预算,为字段增长和计量差异留出余量;不要把这个 96 KiB 阈值描述成 Screeps 的额外官方限制。
定义版本化 envelope
function createEmptyEnvelope(
shardName,
writerEpoch,
now
) {
return {
schemaVersion: 1,
sourceShard: shardName,
writerEpoch,
revision: 0,
writtenAtTick: now,
channels: {}
};
}
writtenAtTick 只作为来源 shard 的诊断字段。官方文档说明各 shard 的脚本和 Memory 分离执行,但没有保证你可以把两个 shard 的 tick 计数当成统一业务时钟。过期判断应使用接收方自己的观察 tick。
安全解析字符串
function parseInterShardEnvelope(
raw,
expectedShard
) {
if (raw == null || raw === '') {
return { status: 'empty', envelope: null };
}
if (typeof raw !== 'string') {
return {
status: 'invalid-raw-type',
envelope: null
};
}
let value;
try {
value = JSON.parse(raw);
} catch {
return {
status: 'invalid-json',
envelope: null
};
}
if (
!value
|| typeof value !== 'object'
|| Array.isArray(value)
) {
return {
status: 'invalid-envelope',
envelope: null
};
}
if (value.schemaVersion !== 1) {
return {
status: 'unsupported-schema',
envelope: null
};
}
if (value.sourceShard !== expectedShard) {
return {
status: 'source-shard-mismatch',
envelope: null
};
}
if (
typeof value.writerEpoch !== 'string'
|| value.writerEpoch === ''
|| !Number.isInteger(value.revision)
|| value.revision < 0
|| !value.channels
|| typeof value.channels !== 'object'
|| Array.isArray(value.channels)
) {
return {
status: 'invalid-envelope-fields',
envelope: null
};
}
return {
status: 'valid',
envelope: value
};
}
不要把损坏 JSON 自动转换为空对象后覆盖。应保留 invalid-json、unsupported-schema、source-shard-mismatch 等独立证据。
加载本地 envelope
let interShardDraftTick = null;
let interShardDraftEnvelope = null;
function loadLocalEnvelope() {
const shardName = Game.shard.name;
if (
interShardDraftTick === Game.time
&& interShardDraftEnvelope
) {
return {
status: 'loaded-draft',
envelope: interShardDraftEnvelope
};
}
const raw = InterShardMemory.getLocal();
const parsed = parseInterShardEnvelope(
raw,
shardName
);
if (parsed.status === 'valid') {
Memory.interShard ??= {};
Memory.interShard.writerEpoch =
parsed.envelope.writerEpoch;
interShardDraftTick = Game.time;
interShardDraftEnvelope = parsed.envelope;
return {
status: 'loaded',
envelope: parsed.envelope
};
}
if (parsed.status !== 'empty') {
return {
status: 'local-data-invalid',
reason: parsed.status,
envelope: null
};
}
Memory.interShard ??= {};
const writerEpoch = createWriterEpoch(
shardName,
Game.time
);
if (writerEpoch === null) {
return {
status: 'writer-epoch-unavailable',
envelope: null
};
}
Memory.interShard.writerEpoch = writerEpoch;
const envelope = createEmptyEnvelope(
shardName,
writerEpoch,
Game.time
);
interShardDraftTick = Game.time;
interShardDraftEnvelope = envelope;
return {
status: 'created-empty',
envelope
};
}
本地数据损坏时返回 local-data-invalid,不要继续调用 setLocal()。
发布本地 channel
const INTERSHARD_SAFE_BYTE_LIMIT = 96 * 1024;
function publishLocalChannel(
channelName,
nextValue
) {
if (
typeof channelName !== 'string'
|| channelName.trim() === ''
) {
return { status: 'invalid-channel-name' };
}
const loaded = loadLocalEnvelope();
if (!loaded.envelope) {
return loaded;
}
const previous =
loaded.envelope.channels[channelName];
const channelRevision =
Number.isInteger(previous?.revision)
? previous.revision + 1
: 1;
const nextEnvelope = {
...loaded.envelope,
revision: loaded.envelope.revision + 1,
writtenAtTick: Game.time,
channels: {
...loaded.envelope.channels,
[channelName]: {
revision: channelRevision,
updatedAtTick: Game.time,
value: nextValue
}
}
};
const serialized = JSON.stringify(nextEnvelope);
const byteLength = utf8ByteLength(serialized);
if (byteLength > INTERSHARD_SAFE_BYTE_LIMIT) {
return {
status: 'payload-too-large',
byteLength
};
}
InterShardMemory.setLocal(serialized);
interShardDraftTick = Game.time;
interShardDraftEnvelope = nextEnvelope;
return {
status: 'local-write-called',
byteLength,
envelopeRevision: nextEnvelope.revision,
channelRevision
};
}
setLocal() 替换整份本地字符串,所以必须先读取、合并,再完整写回。上面的协调器还保存当前 tick 的 interShardDraftEnvelope,后续 channel 更新优先合并这个 draft,而不是假设 setLocal() 后再次 getLocal() 一定能读到刚刚暂存的值。API 没有文档化 OK 返回码,因此本文只记录 local-write-called:它表示本地函数已调用,不表示远端 shard 已观察到新 revision。
读取远端 channel
function readRemoteChannel(
remoteShard,
channelName
) {
if (
typeof remoteShard !== 'string'
|| remoteShard === ''
|| remoteShard === Game.shard.name
) {
return { status: 'invalid-remote-shard' };
}
const raw = InterShardMemory.getRemote(
remoteShard
);
const parsed = parseInterShardEnvelope(
raw,
remoteShard
);
if (parsed.status !== 'valid') {
return {
status: parsed.status,
remoteShard
};
}
const channel =
parsed.envelope.channels[channelName];
if (
!channel
|| !Number.isInteger(channel.revision)
|| channel.revision < 0
) {
return {
status: 'channel-missing',
remoteShard,
writerEpoch: parsed.envelope.writerEpoch
};
}
return {
status: 'channel-read',
remoteShard,
writerEpoch: parsed.envelope.writerEpoch,
envelopeRevision: parsed.envelope.revision,
channelRevision: channel.revision,
value: channel.value
};
}
远端数据只读。回应、确认和 acknowledgement 必须写在接收方自己的本地 channel 中。
用接收方本地窗口判断停滞
function observeRemoteChannel(
remoteShard,
channelName,
maxSilentTicks = 100
) {
const result = readRemoteChannel(
remoteShard,
channelName
);
if (result.status !== 'channel-read') {
return result;
}
Memory.interShardObservers ??= {};
const key = remoteShard + ':' + channelName;
const previous =
Memory.interShardObservers[key];
const streamChanged =
!previous
|| previous.writerEpoch !== result.writerEpoch;
if (
!streamChanged
&& result.channelRevision
< previous.channelRevision
) {
return {
status: 'revision-regressed',
remoteShard,
channelName,
previousRevision: previous.channelRevision,
observedRevision: result.channelRevision
};
}
const advanced =
streamChanged
|| result.channelRevision
> previous.channelRevision;
const next = {
writerEpoch: result.writerEpoch,
channelRevision: result.channelRevision,
lastCheckedAt: Game.time,
lastAdvancedAt: advanced
? Game.time
: previous.lastAdvancedAt
};
Memory.interShardObservers[key] = next;
if (
!advanced
&& Game.time - next.lastAdvancedAt
> maxSilentTicks
) {
return {
...result,
status: 'channel-stale',
silentTicks:
Game.time - next.lastAdvancedAt
};
}
return {
...result,
status: streamChanged
? 'stream-started'
: advanced
? 'channel-advanced'
: 'channel-unchanged',
silentTicks:
Game.time - next.lastAdvancedAt
};
}
同一 writer epoch 下 revision 下降是 revision-regressed;writer epoch 改变则是新流。channel-stale 只说明接收方在自己的观察窗口内没有看到推进,不能证明远端 shard 已停止运行。
跨 shard 交接:offer/ack
来源 shard 发布 offer:
function buildOutboundHandoff(
creep,
targetShard
) {
if (
!creep
|| creep.my !== true
|| typeof targetShard !== 'string'
|| targetShard === ''
) {
return null;
}
return {
handoffId:
Game.shard.name
+ ':'
+ creep.name
+ ':'
+ Game.time,
creepName: creep.name,
sourceShard: Game.shard.name,
targetShard,
state: 'offered',
offeredAtTick: Game.time,
memory: {
role: creep.memory.role ?? null,
missionId: creep.memory.missionId ?? null
}
};
}
buildOutboundHandoff() 只构造一条 offer 记录。来源 shard 仍应像 acknowledgement 一样,把它合并进自己已有的 handoffOffers map,并经过 pruneRecordMap() 后再由同一个本地协调器发布,避免覆盖尚未完成的交接。
目标 shard 真正看到指定 Creep 后,在自己的字符串中发布确认:
function acknowledgeRemoteHandoff(
offer,
observedCreep
) {
if (
!offer
|| offer.targetShard !== Game.shard.name
|| !observedCreep
|| observedCreep.my !== true
|| observedCreep.name !== offer.creepName
) {
return { status: 'handoff-not-confirmed' };
}
const acknowledgement = {
handoffId: offer.handoffId,
sourceShard: offer.sourceShard,
targetShard: Game.shard.name,
creepName: observedCreep.name,
state: 'observed-on-target',
observedAtTick: Game.time
};
const loaded = loadLocalEnvelope();
if (!loaded.envelope) {
return loaded;
}
const previousValue =
loaded.envelope.channels
.handoffAcknowledgements?.value;
const previousRecords =
previousValue
&& typeof previousValue === 'object'
&& !Array.isArray(previousValue)
? previousValue
: {};
return publishLocalChannel(
'handoffAcknowledgements',
pruneRecordMap({
...previousRecords,
[offer.handoffId]: acknowledgement
})
);
}
双方都只写自己的数据:source 写 offer,target 写 acknowledgement,source 再读取 target 的远端确认。
限制历史记录
function pruneRecordMap(
records,
maxRecords = 32
) {
if (
!records
|| typeof records !== 'object'
|| Array.isArray(records)
) {
return {};
}
return Object.fromEntries(
Object.entries(records)
.sort((left, right) => {
const leftTick =
left[1]?.updatedAtTick
?? left[1]?.observedAtTick
?? left[1]?.offeredAtTick
?? 0;
const rightTick =
right[1]?.updatedAtTick
?? right[1]?.observedAtTick
?? right[1]?.offeredAtTick
?? 0;
return rightTick - leftTick
|| left[0].localeCompare(right[0]);
})
.slice(0, maxRecords)
);
}
只保留当前快照、未确认消息和少量最近完成记录。不要序列化完整 Room、Creep 或结构对象,也不要写入外部服务密钥。
完整同步循环
function runInterShardSync(
remoteShards
) {
const localStatus = {
shard: Game.shard.name,
tick: Game.time,
ownedRooms: Object.values(Game.rooms)
.filter(room => room.controller?.my)
.map(room => room.name)
.sort()
};
const publication = publishLocalChannel(
'empireStatus',
localStatus
);
const observations = [];
for (const remoteShard of remoteShards) {
observations.push(
observeRemoteChannel(
remoteShard,
'empireStatus',
100
)
);
}
return {
publication,
observations
};
}
这个循环没有声称远端更新会在固定 tick 内可见,因为官方 API 没有给出传播延迟保证。
常见错误
- 把 InterShardMemory 当成所有 shard 共同可写的对象;
- 直接
JSON.parse(getRemote()),不区分空、损坏和旧 schema; - 每次只写一个字段,导致其他 channel 被整份替换;
- 用
text.length当作 UTF-8 字节数; - 用本地
Game.time减远端writtenAtTick; - 只使用 revision,不使用 writerEpoch;
- writerEpoch 变化时仍要求 revision 单调增加;
- 把
setLocal()调用描述成“远端同步完成”; - 无限追加消息历史。
排查清单
- 打印当前
Game.shard.name; - 区分本地空字符串与损坏 JSON;
- 检查
schemaVersion、sourceShard和 writer epoch; - 检查 envelope revision 与 channel revision;
- 用 UTF-8 字节数检查真实体积;
- 确认所有写入经过同一个本地协调器;
- 检查接收方保存的
lastAdvancedAt; - 分开记录 stale、regressed、missing 和 invalid;
- 检查 offer 与 acknowledgement 是否由各自拥有的 shard 发布;
- 清理已完成消息并限制历史数量。
验证状态与适用边界
仓库离线模拟覆盖:UTF-8 字节计数、空数据、损坏 JSON、schema/source 校验、revision 推进与回退、writerEpoch 重启、本地观察停滞、容量拒绝和 handoff 身份约束。
这些测试不能证明官方 shard 间的实际传播延迟、不同 shard tick 的现实关系、Creep 穿过 inter-shard portal 的时序、官方服务器 CPU 成本或 restricted shard access 的具体影响。因此 consoleTested 和 liveTested 保持为 false。
相关站内内容
- RawMemory segments 怎么跨 tick 安全读取和写回
- Screeps Memory 入门
- Screeps global 缓存怎么安全重建
- Game.getObjectById 怎么恢复对象
- 房间异常隔离