Template Extension
Collapse repetitive content into reusable skeletons with a shared extends / params / overrides DSL
Why templates
When a pack ships many near-identical entries - one mastery track per combat skill, one quest per mob tier, the same milestone reward on every skill - a template captures the shared shape once and each concrete entry fills in only what differs. The Mastery Pack's combat tracks dropped from ~210 lines each to ~20-40, and its mastery-point milestone rewards collapsed from 2,386 lines to 6.
Five content types support this extends/params DSL today: Mastery, Quest, Achievement, CommandReward, and Shop (ShopEntry/ShopPool share one store). Dialogues reuse content through a separate mechanism, native Parent inheritance, not this DSL - see the callout below. The (unreleased) Class System has its own resolver in the same engine, but classes are not shipped content yet.
The DSL
Every resolver runs the same pipeline (the shared primitives live in one place - JsonTemplateUtil):
- Deep-clone the template payload (the cached template is never mutated).
- {{paramName}} substitution - every string value is walked and
{{key}}tokens are replaced withparams.get(key). An empty param drops the holding key entirely, so an entry can opt out of an optional template field by passing"". - Field overlay - every top-level field on the concrete entry except the reserved keys (
extends,params, the overrides/extras keys) wins over the template. - Overrides - for each id in the overrides map, find the matching entry in the primary array (by post-substitution
id) and deep-merge: object keys merge recursively; primitives and arrays replace wholesale. - Extras - append wholly new entries. A new id must NOT collide with a template id (use overrides to modify existing entries).
A missing param surfaces as a literal {{key}} in the resolved JSON (never silently dropped), so content typos are visible during validation. Unknown template ids log a warning and the entry is dropped. Template id lookup is case-insensitive.
Per-type field names
| Type | Template store | Overrides | Extras | Array |
|---|---|---|---|---|
| Mastery | MasteryTemplates/ | nodeOverrides | extraNodes | nodes |
| Quest | QuestTemplates/ | objectiveOverrides | extraObjectives | objectives |
| Achievement | AchievementTemplates/ | criterionOverrides | extraCriteria | criteria |
| CommandReward | CommandRewardTemplates/ | levelOverrides | extraLevels | level map |
| Shop | ShopTemplates/ | (none) | (none) | n/a |
| Class (Future, unreleased) | ClassTemplates/ | advancementOverrides + baseGrantsOverrides | extraAdvancements | advancements |
Mastery template example
A track extends a skeleton, plugs in params, tweaks one node, and appends a unique capstone:
{
"Name": "Archery_Mastery",
"Payload": {
"extends": "combat6_standard",
"target": "skill:ARCHERY",
"displayName": "Archery Mastery",
"params": {
"prefix": "arc",
"gateSkill": "ARCHERY",
"dmgIngredient": "Ingredient_Lightning_Essence",
"combatTarget": "ARCHERY"
},
"nodeOverrides": {
"arc_t1_dmg": { "cost": { "items": [ { "id": "Ingredient_Lightning_Essence", "count": 6 } ] } }
},
"extraNodes": [
{ "id": "arc_unique_capstone", "tier": 10, "displayName": "Eagle Eye", "cost": { }, "modifiers": [ ] }
]
}
}Magic and Artillery pass "combatTarget": "" so the empty-resolve rule drops the combatTarget key from every modifier - one template, different behavior per track.
CommandReward templates & {{ALL_SKILLS}}
A CommandReward template is a level → rewards map (the shape a single skill block carries). A CommandRewards payload then references it per skill - or uses the {{ALL_SKILLS}} sentinel as a top-level key to fan the template out to every known skill that isn't otherwise explicitly listed:
// CommandRewardTemplates/MasteryPointMilestones.json
{
"Name": "MasteryPointMilestones",
"Payload": {
"15": [ { "command": "/mmocurrency give --player={player} --currency=mastery_point --amount=1" } ],
"30": [ { "command": "/mmocurrency give --player={player} --currency=mastery_point --amount=1" } ]
}
}
// CommandRewards/MyPack.json - fan to every skill in six lines
{
"Name": "MyPack",
"Payload": { "{{ALL_SKILLS}}": { "extends": "mastery_point_milestones" } }
}The sentinel only fans to skill keys - the special TOTAL and GLOBAL_SKILL keys are never touched, and an explicit per-skill entry always wins over the fan-out. Per-skill blocks can also use levelOverrides (replace a level's rewards) and extraLevels (add new levels).
Shop entries fan out the same way with "forEachSkill": true: a single offer template is cloned into one ShopEntry per active skill, substituting {{SKILL}} (the skill id) and {{SKILL_NAME}} (its display name) into the payload. The generated id comes from the template's own id pattern (e.g. "shop_xp_{{SKILL}}"), and an explicitly authored offer of the same id always wins. A scalar "requireSelfLevel": N gates each generated offer behind the fanned skill's own level. Where {{ALL_SKILLS}} is a top-level fan-out key for CommandRewards, forEachSkill is the per-offer flag for the shop - both run after template resolution.
Quest & achievement templates
{
"Name": "Pack_Daily_Kill_Goblin_T1",
"Payload": {
"extends": "daily_kill_template",
"params": { "id": "pack_daily_kill_goblin_t1", "displayName": "Goblin Hunter I",
"mobId": "Mob_Goblin_T1", "count": "10", "reward": "5" },
"objectiveOverrides": { "kill_target": { "displayText": "Slay 10 Goblins" } }
}
}The easiest way to replace a template's rewards is to supply a fresh rewards: [ … ] at the top level (it overlays the whole array).
Class System - unreleased
ClassTemplates/ resolver with the same extends/params/ advancementOverrides/baseGrantsOverrides shape as the DSL above) exists in the template engine, but classes are a future, unreleased feature - the plugin ships no built-in classes and no class content is live. Do not author or install class-template content on a production server yet.Shop templates
Shop entries and pools share one ShopTemplates/ store. The resolver reserves only extends and params (no id-array merge, since shop rewards carry no ids), so a consumer's "rewards": [ … ] overlays the template's wholesale through the standard non-reserved pass. Pair a template with "forEachSkill": true to fan one offer skeleton out to every active skill:
// ShopTemplates/Xp_Packet_Base.json - one offer per active skill
{
"Name": "Xp_Packet_Base",
"Payload": {
"forEachSkill": true,
"category": "conversion", "pool": "xpexchange",
"displayName": "{{SKILL_NAME}} {{TIER_NAME}}",
"cost": { "currencies": { "bounty_token": "{{TOKENS}}", "life_essence": "{{ESSENCE}}" } },
"rewards": [ { "type": "XP", "skill": "{{SKILL}}", "amount": "{{XP}}" } ]
}
}
// ShopEntries/XP_Packet_Greater.json - a thin per-tier consumer
{
"Name": "XP_Packet_Greater",
"Payload": {
"extends": "xp_packet_base",
"params": { "TIER_NAME": "Training", "TOKENS": "165", "ESSENCE": "65", "XP": "7500" },
"id": "shop_xp_greater_{{SKILL}}", "tier": "greater", "requireSelfLevel": 30
}
}Template resolution runs first, then forEachSkill clones the resolved JSON once per active skill, substituting {{SKILL}} and {{SKILL_NAME}}. A scalar requireSelfLevel gates each generated offer behind the fanned skill's own level, and an explicitly authored offer of the same id always wins.
Dialogues don't use this DSL
"Parent": "<id>" inheritance (keyed per-node merge via the engine's decodeAndInheritJson), not extends/params. The old extends/params/ nodeOverrides/extraNodes/Options!/prune mechanism described in earlier docs was removed. See Dialogue authoring for the current shape.Remember to enable the template store in your Control file - add the matching "<Type>Templates": "add" key (e.g. "MasteryTemplates": "add") so the templates load before the content that extends them.