---
title: Tag 模型
hide_meta: true
---

# Tag 模型

PmmoJS 会暴露 PMMO 的 NBT tag，因为 PMMO 本身就是用 tag 在对象数据、trigger、perk 和 XP 奖励之间传递信息。不要只把它们看成同一种 `CompoundTag`。同一个类型在不同入口里可能代表“静态配置”“当前事件上下文”“perk 生命周期状态”或“一次性输出”。

这一页基于 PMMO 1.20.1 源码中的 `PerkRegistry.executePerk`、`EventTriggerRegistry.executeEventListeners`、`TagUtils.mergeTags`、`APIUtils.serializeAwardMap`，以及 PmmoJS 的 `PerkTagJS`、`PMMOTriggerEventJS`、`PMMOInternalEventJS` 包装类。

## 数据流图

大多数 PMMO 游戏流程可以理解成这样：

```text
settings / perks config / startup defaults
        |
        v
PMMO 事件处理器构建输入 CompoundTag
        |
        v
trigger listener 修改或返回运行时上下文
        |
        v
PMMO 为每个 perk 构建合并后的 settings tag
        |
        v
perk start 可以给后续 perk 写入 result output
        |
        v
PMMO 计算 XP、需求、伤害、速度或其他最终效果
        |
        v
xp hook 观察最终 XP 事件上下文
```

`globalsConfig` 不在这条运行时 tag 流里。PMMO 只在求值 NBT 逻辑时使用它：`paths` 在读取对象 NBT 前展开 `#别名`，`constants` 在比较值前展开 `#别名`。

## 最重要的规则

数据应该写到生命周期匹配的位置：

| 需求 | 写到哪里 | 原因 |
|---|---|---|
| 物品、方块、实体、生物群系或维度上的稳定规则 | `PmmoJS.settings(...)` | PMMO 能把它当作正常对象数据加载和求值 |
| 可复用的 NBT 路径或比较值别名 | `PmmoJS.globalsConfig(...)` | PMMO 只会在 NBT paths 和 comparators 中展开 globals |
| 自定义 perk 的可配置参数 | `PmmoJS.registerPerk(...).withInt(...)`、`.defaults(...)`，必要时再用 `PmmoJS.perksConfig(...)` | PMMO 会在回调运行前合并默认值和 perk 配置 |
| `tick()` 或 `stop()` 后续要读的状态 | `ctx.getSettings()` | PMMO 会把 settings tag 的副本放入 active tick schedule |
| 同一执行趟次中给后续 perk 看的输出 | `start()` 内的 `ctx.getResult()` | PMMO 会把 start result 合并进本次执行输出 |
| 单次 PMMO trigger 的额外 XP 或取消标记 | `PmmoJS.trigger(...)` 的 context helper | 这个上下文只属于一次 trigger 调度 |
| 不阻止游戏行为，只跳过 PMMO 内部处理器 | `PmmoJS.internal(...).skipPmmo()` | internal hook 控制 PMMO handler 是否继续 |
| 取消底层 Forge 行为 | `PmmoJS.internal(...).deny()` | 它会设置动作取消并跳过 PMMO |
| 观察最终 XP 变化 | `PmmoJS.xp(...)` | 这个事件在 PMMO 给玩家结算 XP 时触发 |

规则如果是稳定的，先写成 PMMO 数据。只有静态数据不知道的实时状态，才放进运行时 tag。

## Tag 分类

| 分类 | 例子 | 生命周期 |
|---|---|---|
| 静态对象数据 | 需求、XP 奖励、加成、效果、salvage、vein data | 存在 PMMO 数据/配置中 |
| 静态 perk 数据 | startup defaults 和 `perks.toml` 条目 | 持续到 PMMO 重载配置 |
| 运行时 trigger 上下文 | `PmmoJS.trigger(...)` 事件 context | 一次 trigger 调度 |
| 运行时 internal 上下文 | `PmmoJS.internal(...)` 事件 context | 一次 internal hook 调度 |
| Perk 执行 settings | `ctx.getSettings()` | 一次 perk 激活，之后是 active tick schedule 的副本 |
| Perk result output | `ctx.getResult()` | 一次 `start()` 调用和当前 perk 执行趟次 |
| XP 事件上下文 | `PmmoJS.xp(...).getContext()` | 一次 XP 事件 |

## Perk Settings 和 Result

PMMO 执行 perk 时，会为每个配置的 perk 条目构建一个新的 settings tag：

1. 注册 perk 时的 `propertyDefaults()`。
2. PMMO perk settings 中匹配的条目。
3. 当前事件传入的数据。
4. 同一执行趟次中更早 perk 的输出。
5. 最后根据合并后的 `skill` 注入 `level`。

PMMO 用 `CompoundTag.merge(...)` 构建这个 tag，所以越靠后的层会覆盖越靠前的同名标量值。这和 trigger 输出合并用的 `TagUtils.mergeTags(...)` 不是同一个规则。

在 PmmoJS 回调里：

- `ctx.getSettings()` 包装的是合并后的 source tag。
- `ctx.getResult()` 只存在于 `start()`，初始为空。
- PmmoJS 会把 `start()` 中的 `ctx.getResult()` 副本返回给 PMMO。
- JS 友好的 `tick()` 和 `stop()` overload 会返回空 tag，所以生命周期状态要写进 `ctx.getSettings()`。

这个区别很关键：

```js
PmmoJS.registerPerk(event => {
  event
    .create('kubejs:heated_pickaxe', PMMOPerkSide.SERVER)
    .withSkill('mining')
    .withDuration(100)
    .withInt('heat_per_tick', 1)
    .start(ctx => {
      // PMMO 会在 start() 返回后把 settings 复制进 active tick schedule，
      // 所以 tick() 和 stop() 能读到这个值。
      ctx.getSettings().putInt('heat', 0)

      // 这是给同一执行趟次中后续 perk 看的“一次性输出”。
      ctx.getResult().putBoolean('heated_pickaxe_started', true)
    })
    .tick(ctx => {
      const settings = ctx.getSettings()
      settings.putInt('heat', settings.getIntOr('heat', 0) + settings.getIntOr('heat_per_tick', 1))
    })
    .stop(ctx => {
      ctx.getSettings().remove('heat')
    })
    .register()
})
```

如果把 `heat` 写进 `ctx.getResult()`，`tick()` 读不到它。如果把 `heated_pickaxe_started` 只写进 `ctx.getSettings()`，同一执行趟次中后面的 perk 不会通过 PMMO 的 output merge 收到它。

## PerkTagJS

`PerkTagJS` 是 `CompoundTag` 的 JS 友好包装。

读取：

- `keys()`、`has(key)`、`hasCompound(key)`
- `getString(key)`、`getStringOr(key, fallback)`
- `getInt(key)`、`getIntOr(key, fallback)`
- `getLong(key)`、`getLongOr(key, fallback)`
- `getFloat(key)`、`getFloatOr(key, fallback)`
- `getDouble(key)`、`getDoubleOr(key, fallback)`
- `getBoolean(key)`、`getBooleanOr(key, fallback)`
- `getOrCreateCompound(key)`

写入：

- `remove(key)`、`clear()`
- `putString(key, value)`、`putBoolean(key, value)`
- `putInt(key, value)`、`putLong(key, value)`
- `putFloat(key, value)`、`putDouble(key, value)`
- `putCompound(key, value)`
- `putStringList(key, values)`
- `putNumberList(key, values)`
- `merge(value)`
- `copy()`

PMMO 辅助读取：

- `getSkill()` 读取 `skill`。
- `getResolvedLevel()` 读取注入后的 `level`。
- `getCooldown()` 读取 `cooldown`。
- `getDuration()` 读取 `duration`。
- `getChance()` 读取 `chance`。

## 三种合并模型

PMMO 不是所有地方都用同一种合并规则。

| 位置 | 合并行为 |
|---|---|
| `PerkRegistry.executePerk` 中的 perk source tag | `CompoundTag.merge(...)` 依次合并 defaults、config、incoming event data、prior perk output。后面的标量值覆盖前面的标量值。 |
| `EventTriggerRegistry.executeEventListeners` 中的 trigger listener output | `TagUtils.mergeTags(...)` 会把同名数字相加。非数字同名键保留更早 output 的值。`is_cancelled` 额外按“只要 true 就保持 true”的逻辑处理。 |
| PmmoJS XP helper | XP map 会通过 `APIUtils.serializeAwardMap(...)` 编码到 `serialized_award_map`。除非你完全匹配 PMMO codec 格式，否则不要手写这个 compound。 |
| `PerkTagJS.merge(value)` | 调用的是 Minecraft 的 `CompoundTag.merge(...)`，不是 PMMO 会累加数字的 `TagUtils.mergeTags(...)`。 |

实际后果是：两个 trigger listener 都写入 int 类型的 `bonus_roll` 时，PMMO 的 trigger output 可以把它们相加；两个 perk source layer 都写入 `cooldown` 时，后写入的那层生效。

## Trigger Context

`PmmoJS.trigger(type, event => {})` 包装的是 PMMO trigger registry。规则依赖 PMMO `EventType` 的实时状态时用它。

```js
PmmoJS.trigger(EventType.BLOCK_BREAK, event => {
  const player = event.getPlayer()
  if (!player) return

  if (player.stages.has('festival_mining_bonus')) {
    event.addXpAward('mining', 25)
  }

  if (player.isCreative()) {
    event.setCancelled(true)
  }
})
```

优先使用包装方法：

- `getContext*` 和 `putContext*` 读写普通 context 字段。
- `getXpAwards()`、`setXpAwards(map)`、`setXpAward(skill, amount)`、`addXpAward(skill, amount)`、`clearXpAwards()` 管理 XP map。
- `setCancelled(true)` 告诉 PMMO 跳过这个 trigger 的处理。

XP helper 存在的原因是 PMMO 会把 XP 奖励放在 `serialized_award_map` 下，而且这个 compound 必须由 `APIUtils.serializeAwardMap(...)` 生成。普通 JS 对象 `{ mining: 25 }` 不是 PMMO 期望的 NBT 形状。

通过 `PmmoHelper.registerTriggerListener(...)` 注册的 raw direct listener 更底层。PMMO 期望返回的 tag 包含 `is_cancelled`，否则上游 registry 会把 listener 输出视为无效。除非你确实需要 startup-time 的直接注册，否则优先用 `PmmoJS.trigger(...)`。

## Internal Context

`PmmoJS.internal(type, event => {})` 覆盖的是不走 trigger registry 的 PMMO handler：维度旅行、登录、骑乘、活塞、爆炸、玩家死亡、酿造、睡眠等。

Internal hook 初始会拿到一个新的空 `CompoundTag`。桥接层可能会在脚本运行前写入 hook 专属键：

- `EXPLOSION`：`affectedBlockCount`、`affectedEntityCount`
- `POTION_BREW`：`alreadyTracked`、`markBrewed`，有时还有 `serialized_award_map`

Internal hook 用来控制 PMMO handler，不应该替代普通对象数据：

```js
PmmoJS.internal(PMMOInternalType.POTION_BREW, event => {
  if (event.getContextBoolean('alreadyTracked')) {
    return
  }

  event.addXpAward('alchemy', 10)
  event.putContextBoolean('markBrewed', true)
})
```

`event.skipPmmo()` 表示游戏行为继续，但 PMMO handler 跳过。`event.deny()` 会在可取消时取消被包装的 Forge 行为，并且跳过 PMMO。

## XP Event Context

`PmmoJS.xp(...)` 在 PMMO 改变玩家 XP 时触发。它的 `getContext()` 暴露 PMMO 原始 XP event context，所以适合做观察或很小范围的取消逻辑。不要把 XP hook 当成定义对象奖励的主入口。稳定奖励放在 `PmmoJS.settings(...)`；运行时追加通常放在 `PmmoJS.trigger(...)` 或 `PmmoJS.internal(...)`，并使用 XP award helper。

```js
PmmoJS.xp(event => {
  if (event.getSkill() === 'mining' && event.isLevelUp()) {
    event.getEntity().tell(`Mining level: ${event.endLevel()}`)
  }
})
```

## 复合示例

这个例子把对象数据、NBT globals、自定义 perk 和运行时 trigger 串起来，但每一层只负责自己的事。实际整合包里要按脚本阶段拆开写。

```js
// startup_scripts/pmmo_perks.js
PmmoJS.registerPerk(event => {
  event
    .create('kubejs:manyullyn_focus', PMMOPerkSide.SERVER)
    .withSkill('mining')
    .withDuration(80)
    .withInt('bonus_xp', 10)
    .start(ctx => {
      ctx.getSettings().putBoolean('active', true)
      ctx.getResult().putBoolean('manyullyn_focus_active', true)
    })
    .stop(ctx => {
      ctx.getSettings().putBoolean('active', false)
    })
    .register()
})
```

```js
// server_scripts/pmmo_progression.js
PmmoJS.globalsConfig(event => {
  event.addPath('tool_materials', 'tic_materials[]')
  event.addConstant('manyullyn', 'tconstruct:manyullyn')
})

PmmoJS.settings(event => {
  event
    .item('tconstruct:pickaxe')
    .override(true)
    .setRequirement(ReqType.TOOL, 'mining', 5)
    .setXp(EventType.BLOCK_BREAK, 'mining', 4)

    .nbtRequirement(ReqType.TOOL)
    .additive(true)
    .newCase('#tool_materials')
    .equals('#manyullyn', 'mining', 25)
    .done()
})

PmmoJS.perksConfig(event => {
  event
    .addPerk(EventType.BLOCK_BREAK, 'kubejs:manyullyn_focus', 'mining')
    .withMinLevel(30)
    .build()
})

PmmoJS.trigger(EventType.BLOCK_BREAK, event => {
  const player = event.getPlayer()
  if (player && player.stages.has('mining_trial')) {
    event.addXpAward('mining', 10)
  }
})
```

静态 settings 负责普通成长。`globalsConfig` 只让 NBT 规则可读。自定义 perk 把生命周期状态放在 `ctx.getSettings()`，把一次性标记放在 `ctx.getResult()`。trigger 只处理任务阶段带来的临时 XP 加成。

## 相关页面

- [PMMO 编写流程](./pmmo-workflow)
- [PMMO Settings](./serverevents/settings)
- [Config Events](./serverevents/config#pmmojsglobalsconfig)
- [Perk 注册](./startupevents/perks)
- [运行时事件](./serverevents/normal)
- [Internal Hooks](./serverevents/internal)
