/**
* VANITY — the Vanity Role-Playing Game, for Foundry VTT (v13/v14).
*
* Pool = Attribute + Knack + situation. 5–6 = a Success; fail with two or more 1s
* and you Stumble, banking a Bane of Karma (Draft 12).
* One Success succeeds; extras buy damage or Special Attacks. Push once (+1 Bane).
* No hit points — Grit. Vanity / Karma / Glory drive play.
*/
/* -------------------------------------------- */
/* Constants */
/* -------------------------------------------- */
import { VANITY_CONDITIONS } from "./conditions.mjs";
const ATTRIBUTES = {
brawn: { label: "Brawn", desc: "power · toughness · melee" },
flair: { label: "Flair", desc: "agility · aim · stealth · style" },
wits: { label: "Wits", desc: "mind · senses · arcane magic" },
poise: { label: "Poise", desc: "nerve · charm · divine magic" }
};
const KNACKS = {
fight: { label: "Fight", attr: "brawn" },
shoot: { label: "Shoot", attr: "flair" },
athletics: { label: "Athletics", attr: "brawn" },
stealth: { label: "Stealth", attr: "flair" },
notice: { label: "Notice", attr: "wits" },
lore: { label: "Lore", attr: "wits" },
survive: { label: "Survive", attr: "wits" },
charm: { label: "Charm", attr: "poise" },
command: { label: "Command", attr: "poise" }
};
const RANKS = ["—", "Trained", "Expert", "Master"];
const LOCKED_KNACKS = {
"Martial Prowess": "brawn",
"Larceny": "flair",
"Arcana": "wits",
"Faith": "poise",
"Wildcraft": "wits"
};
/** Class walls never lift — each class sees only its own Locked Knack. */
const CLASS_LOCKED = {
warrior: "Martial Prowess",
rogue: "Larceny",
ranger: "Wildcraft",
mage: "Arcana",
cleric: "Faith"
};
/** Everyone is born armed. */
const UNARMED_STRIKE = {
name: "Unarmed strike",
type: "weapon",
img: "icons/skills/melee/unarmed-punch-fist.webp",
system: {
dice: 0,
hands: 0,
group: "fight",
properties: "Always with you · perfect for Subdue",
cost: "—",
description: "
Fists, knees, forehead, the nearest piece of furniture. +0 dice — the pool is Brawn + Fight, and pride does the rest. Declare Subdue to knock them cold instead of killing.
"
}
};
/** The §11 maneuver menu — costs in net Successes beyond the first. */
const MANEUVERS = [
{ key: "disarm", label: "Disarm", cost: 1, hint: "Target drops a weapon or item.",
desc: "The target drops a weapon or held item of the attacker's choice. Picking it up again costs them their Move — if nobody kicks it away first." },
{ key: "knockdown", label: "Knock Down", cost: 1, hint: "Prone: −1 die; rising costs their move.",
desc: "The target is knocked prone: they take −1 die on everything, and rising costs them their Move. Plate negates Knock Down; a Nemesis may spend 1 Resolve to shrug it off." },
{ key: "driveback", label: "Drive Back", cost: 1, hint: "Shove a pace; break away or reposition.",
desc: "Shove the target a pace — off the bridge's midpoint, out of the doorway, away from the lever. Also how you break away or reposition without giving ground for free. Reach weapons pay 1 less." },
{ key: "wound", label: "Wound", cost: 1, hint: "Lingering injury: −1 die to a fitting action until treated.",
desc: "A lingering injury: −1 die to a fitting kind of action until treated (a slashed sword-arm, a gashed leg, a split brow into their eyes). Mail and plate negate the Wound maneuver." },
{ key: "feint", label: "Feint", cost: 1, hint: "Your next attack on this foe gets +1 die.",
desc: "You sell the lie; they buy it. Your next attack against this foe gets +1 die." },
{ key: "gap", label: "Find the Gap", cost: 1, hint: "Your next strike on this foe ignores 2 armour dice.",
desc: "You've read their guard. Your next strike on this foe ignores 2 armour dice (they always keep their Attribute dice — §11a). Set up the killing blow, or the Sunder." },
{ key: "grapple", label: "Grapple", cost: 1, hint: "Held; they must win a Brawn contest to break free.",
desc: "The target is held — no moving, no clean swings — until they win a Brawn contest to break free. A Nemesis may spend 1 Resolve to shrug it off." },
{ key: "called", label: "Called Shot", cost: 1, hint: "Trade damage for a targeted effect — GM adjudicates.",
desc: "Trade damage for a targeted effect: blind an eye, cripple a leg (halve their move), pin the sleeve to the mast. The GM adjudicates the exact price and result." },
{ key: "environment", label: "Environment", cost: 1, hint: "Spend on the scene — kick the brazier, cut the bridge. GM prices it 1–2.",
desc: "Spend Successes on the scene itself: kick the brazier into the oil, cut the rope bridge, topple the bookcase, slam the portcullis. The GM sets a fair price (usually 1–2) and narrates the chaos." },
{ key: "sunder", label: "Sunder", cost: 2, hint: "Permanently strip 1 armour die or a shield; the first Sunder dents plate (+2→+1).",
desc: "Permanently strip 1 armour die or destroy a shield for the rest of the fight. The first Sunder against plate dents it (+2 → +1). Patience dismantles fortresses." },
{ key: "cleave", label: "Cleave", cost: 2, hint: "Deal your remaining damage to a second adjacent foe.",
desc: "The blow carries through: deal your remaining damage to a second adjacent foe. Best served from a two-hander in a doorway." },
{ key: "offbalance", label: "Off-Balance", cost: 2, hint: "They reel: −2 dice on their next roll.",
desc: "The target is reeling: −2 dice on their next roll, attack or defence. They still act — they just act badly. It never skips a turn, which is exactly why it replaced Stagger." },
{ key: "press", label: "Press", cost: 2, hint: "One extra attack now (at most one extra attack per round).",
desc: "Make one extra attack immediately. Remember the cap: at most one extra attack per round, however it's paid for (Press or Rapid Shot, not both — §11a)." }
];
/**
* The interactive spend panel for attack cards: click maneuvers to buy them
* with Successes, dial in the defender's Successes, read off the damage.
*/
function renderSpendPanel({ successes, spend, sublabel }) {
const s = { defence: 0, damage: 0, maneuvers: [], ...(spend ?? {}) };
const net = successes - s.defence;
const hints = [];
if (/brutal/i.test(sublabel ?? "")) hints.push("Brutal: a free Wound on this hit.");
if (/armour-piercing/i.test(sublabel ?? "")) hints.push("Armour-Piercing: 2 of their armour dice never counted.");
if (/reach/i.test(sublabel ?? "")) hints.push("Reach: Drive Back costs 1 less.");
const defRow = `
`
});
});
// Refuse to Fall: 1 Vanity → stop at 1 Grit, +2 Banes (§5)
html.find(".refuse-fall").click(async () => {
if ((actor.system.vanity ?? 0) < 1) return ui.notifications.warn("Refusing to Fall costs 1 Vanity — the purse is empty.");
await actor.update({ "system.vanity": actor.system.vanity - 1, "system.grit.value": 1 });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `
${actor.name} REFUSES TO FALL — still standing at 1 Grit, one more beat. Fate notices.
`
});
await actor.addBanes(2, "Refused to Fall — cheating fate");
});
// Play the Vice: +1 Vanity now, the GM banks a Bane for later (§8a)
html.find(".vice-play").click(async ev => {
const item = actor.items.get(ev.currentTarget.closest("[data-item-id]").dataset.itemId);
if (!item) return;
await actor.update({ "system.vanity": Math.min(6, (actor.system.vanity ?? 0) + 1) });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `
${actor.name} plays their Vice — ${item.name}(${item.system.trigger}). +1 Vanity now (${actor.system.vanity}); the bill arrives later.
`
});
await actor.addBanes(1, `played their Vice — ${item.name}`);
});
// Conditions: click a badge to toggle the status (actor + tokens) and read it below
html.find(".cond-badge").click(async ev => {
const id = ev.currentTarget.dataset.cond;
this._focusCond = id;
await actor.toggleStatusEffect(id);
this.render(false);
});
// Click a name in the details panel to read it without toggling
html.find(".cond-read").click(ev => {
this._focusCond = ev.currentTarget.dataset.cond;
this.render(false);
});
// Companion roll
html.find(".companion-roll").click(() => {
const c = actor.system.companion;
if (!c?.name) return;
promptAndRoll(actor, { title: `${c.name} (companion)`, base: c.pool || 3, baseLabel: `Companion pool ${c.pool || 3}` });
});
}
}
class VanityNpcSheet extends foundry.appv1.sheets.ActorSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["vanity", "sheet", "actor", "npc"],
template: "systems/vanity/templates/npc-sheet.hbs",
width: 520,
height: 560
});
}
async getData(options) {
const ctx = await super.getData(options);
ctx.sys = this.actor.system;
ctx.categories = NPC_CATEGORIES;
ctx.isNemesis = this.actor.system.category === "nemesis";
ctx.notesHTML = await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.actor.system.notes ?? "", { async: true });
ctx.editable = this.isEditable;
return ctx;
}
activateListeners(html) {
super.activateListeners(html);
if (!this.isEditable) return;
const actor = this.actor;
const sys = actor.system;
html.find(".npc-roll").click(ev => {
const which = ev.currentTarget.dataset.roll;
if (which === "attack1") return promptAndRoll(actor, { title: `${actor.name} — ${sys.attack1.name || "Attack"}`, base: sys.attack1.pool || 1, baseLabel: sys.attack1.note || "attack pool", options: { context: "attack", sublabel: sys.attack1.note } });
if (which === "attack2") return promptAndRoll(actor, { title: `${actor.name} — ${sys.attack2.name || "Attack"}`, base: sys.attack2.pool || 1, baseLabel: sys.attack2.note || "attack pool", options: { context: "attack", sublabel: sys.attack2.note } });
if (which === "defence") return promptAndRoll(actor, { title: `${actor.name} — defends`, base: sys.defence.pool || 1, baseLabel: sys.defence.note || "defence pool" });
if (which === "morale") return promptAndRoll(actor, { title: `${actor.name} — Morale`, base: sys.nerve || 2, baseLabel: "Nerve pool · 1 Success = they hold, 0 = they break" });
if (which === "reaction") return promptAndRoll(actor, { title: `${actor.name} — Reaction (§24)`, base: 2, baseLabel: "2 dice ± approach (add a PC's Charm Successes) · 0 Hostile · 1 Wary · 2 Neutral · 3+ Friendly" });
});
}
}
/* -------------------------------------------- */
/* Item sheet */
/* -------------------------------------------- */
class VanityItemSheet extends foundry.appv1.sheets.ItemSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["vanity", "sheet", "item"],
template: "systems/vanity/templates/item-sheet.hbs",
width: 480,
height: 420
});
}
async getData(options) {
const ctx = await super.getData(options);
ctx.sys = this.item.system;
ctx.spellLists = SPELL_LISTS;
ctx.descriptionHTML = await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.item.system.description ?? "", { async: true });
ctx.editable = this.isEditable;
return ctx;
}
}
/* -------------------------------------------- */
/* Dice So Nice — the peacock dice, enforced */
/* -------------------------------------------- */
Hooks.once("diceSoNiceReady", dice3d => {
dice3d.addSystem({ id: "vanity", name: "VANITY — Peacock & Gold" }, "preferred");
dice3d.addColorset({
name: "vanity",
description: "VANITY — Peacock & Gold",
category: "VANITY",
foreground: "#e0c98a",
background: "#14544e",
outline: "#0e3f3a",
edge: "#b0872f",
material: "metal",
font: "Cinzel",
visibility: "visible"
}, "preferred");
// 5–6 wear the plume and the crown (Successes); 1 wears the Bane diamond.
dice3d.addDicePreset({
type: "d6",
labels: [
"systems/vanity/assets/dice/face1-bane.png",
"2", "3", "4",
"systems/vanity/assets/dice/face5-plume.png",
"systems/vanity/assets/dice/face6-crown.png"
],
system: "vanity",
colorset: "vanity"
});
// The Bane dice — blood-red, rolled only when the GM twists the knife.
dice3d.addColorset({
name: "vanity-bane",
description: "VANITY — The Tab (Bane dice)",
category: "VANITY",
foreground: "#f2e2b3",
background: "#8a1f1f",
outline: "#3d0d0d",
edge: "#e0c98a",
material: "metal",
font: "Cinzel",
visibility: "visible"
}, "default");
});
/* -------------------------------------------- */
/* Twist the Knife — the GM spends the tab */
/* -------------------------------------------- */
const TWIST_TABLE_NAME = "Twist the Knife — spending Banes (d66)";
const RECKONING_TABLE_NAME = "THE RECKONING — the tab reads six (2d6)";
async function getVanityTable(name) {
let table = game.tables.getName(name);
if (!table) {
const pack = game.packs.get("vanity.tables");
if (pack) table = (await pack.getDocuments()).find(d => d.name === name);
}
return table;
}
/** Roll dice on the playing surface in the blood-red Bane set, without ever blocking. */
async function showBaneDice(roll) {
for (const d of roll.dice) d.options.appearance = { colorset: "vanity-bane" };
if (game.dice3d && !game.settings.get("dice-so-nice", "animateRollTable")) {
await Promise.race([
game.dice3d.showForRoll(roll, game.user, true).catch(() => {}),
new Promise(res => setTimeout(res, 3500))
]);
}
}
/**
* GM-only: the tab reads six — cash it. Rolls 2d6 on THE RECKONING with the
* red Bane dice, posts the result, and wipes the character's track to 0.
*/
async function reckoningNow({ actorUuid = "" } = {}) {
if (!game.user.isGM) return ui.notifications.warn("Only the GM may call the Reckoning.");
const table = await getVanityTable(RECKONING_TABLE_NAME);
if (!table) return ui.notifications.error(`"${RECKONING_TABLE_NAME}" not found in world or compendium.`);
const roll = new Roll("2d6");
await roll.evaluate();
await showBaneDice(roll);
// Foundry's table.draw({roll}) re-rolls internally — map our roll to its result so
// the red dice on the table and the posted card always agree.
const results = table.getResultsForRoll(roll.total);
await table.toMessage(results, { roll });
const draw = { roll, results };
if (actorUuid) {
const actor = await fromUuid(actorUuid);
if (actor?.type === "character") {
await actor.update({ "system.karma": 0 });
await ChatMessage.create({
speaker: { alias: "The Tab" },
content: `
The tab is cashed in full. ${actor.name}'s track is wiped to 0. The universe starts a fresh page.
`
});
}
}
return draw;
}
/**
* GM-only: roll the d66 on Twist the Knife with the blood-red Bane dice.
* A paid twist (actorUuid set) deducts the row's Bane cost from the tab.
* A free twist (lone unbanked 1) burns the button on its source card.
*/
async function twistTheKnife({ actorUuid = "", free = false, sourceMessage = null } = {}) {
if (!game.user.isGM) return ui.notifications.warn("Only the GM may twist the knife.");
const table = await getVanityTable(TWIST_TABLE_NAME);
if (!table) return ui.notifications.error(`"${TWIST_TABLE_NAME}" not found in world or compendium.`);
const roll = new Roll("1d6*10 + 1d6");
await roll.evaluate();
await showBaneDice(roll);
// table.draw({roll}) re-rolls internally — map our roll to its result so the red dice
// and the posted card agree.
const results = table.getResultsForRoll(roll.total);
await table.toMessage(results, { roll });
const draw = { roll, results };
if (!free && actorUuid) {
const actor = await fromUuid(actorUuid);
if (actor?.type === "character") {
const cost = draw.roll.total >= 61 ? 2 : 1;
const karma = Math.max(0, (actor.system.karma ?? 0) - cost);
await actor.update({ "system.karma": karma });
await ChatMessage.create({
speaker: { alias: "The Tab" },
content: `
${cost} Bane${cost > 1 ? "s" : ""} spent against ${actor.name} — the tab now reads ${karma} of 6.
`
});
}
}
if (free && sourceMessage?.flags?.vanity) {
const f = sourceMessage.flags.vanity;
await sourceMessage.update({
"flags.vanity.twistUsed": true,
content: renderRollCard({
label: f.label, sublabel: f.sublabel, results: f.results, threshold: f.threshold,
pushed: f.pushed, context: f.context, extra: f.extra, spend: f.spend,
banked: f.banked ?? 0, twistUsed: true, spell: f.spell ?? null
})
});
}
return draw;
}
/* -------------------------------------------- */
/* THE FORGE — generators */
/* -------------------------------------------- */
const rnd = arr => arr[Math.floor(Math.random() * arr.length)];
const rollDice = (n, sides) => Array.from({ length: n }, () => Math.ceil(Math.random() * sides));
const sum = a => a.reduce((x, y) => x + y, 0);
const FORGE_NAMES = {
given: ["Aldous", "Brannoc", "Caswen", "Darrow", "Elsbeth", "Fenna", "Garrick", "Hesper", "Isolde", "Jorun",
"Kestrel", "Lirael", "Maera", "Nolwen", "Osric", "Petra", "Quill", "Rosamund", "Sable", "Tamsin",
"Ulric", "Vesna", "Wren", "Yorick", "Zinnia", "Bram", "Corvin", "Yrsa", "Pinch", "Lark"],
epithets: ["the Magnificent", "the Unbowed", "of the Gilded Hand", "the Peacock", "Thrice-Crowned",
"the Modest (a lie)", "of Nine Sorrows", "the Radiant", "Silver-Tongue", "the Immaculate",
"of the Long Shadow", "the Splendid", "Half-Famous", "the Well-Dressed", "of No Small Talent"],
surnames: ["Ashveil", "Blackbriar", "Copperfield", "Duskwither", "Emberly", "Fairweather", "Goldhollow",
"Harrowgate", "Ironquill", "Larkspur", "Mistvale", "Nightingale", "Oakmantle", "Pridewell",
"Quicksilver", "Ravencourt", "Silverstitch", "Thornbury", "Vainglory", "Winterbourne"]
};
/* ------------ class definitions for the hero forge ------------ */
const FORGE_CLASSES = {
Warrior: {
key: "brawn", base: 8, locked: { name: "Martial Prowess", attribute: "brawn" },
spread: { brawn: 3, flair: 2, wits: 2, poise: 2 },
knacks: { fight: 2, athletics: 1, command: 1 },
style: "Sword & Shield",
gear: ["Long sword", "Mail", "Shield", "Healer's kit"],
edges: ["Second Wind", "Press the Attack", "Weapon Master", "Measure the Foe / Presence of a Killer / Sunder the Way"],
concepts: ["The one who steps into the doorway", "A disgraced knight with excellent posture", "Sell-sword between better wars", "Shield-bearer of a broken oath"]
},
Rogue: {
key: "flair", base: 5, locked: { name: "Larceny", attribute: "flair" },
spread: { brawn: 2, flair: 4, wits: 2, poise: 1 },
knacks: { stealth: 2, notice: 1, shoot: 1, charm: 1 },
style: "Two-Weapon",
gear: ["Short sword", "Short sword", "Padded / Leather", "Thieves' tools", "Blade poison", "Smoke bomb"],
edges: ["Ambush", "Deadly Precision (Backstab)", "Evasion (Uncanny Dodge)", "Cunning Action", "Poisoner", "Slip Away", "Audacity"],
concepts: ["Guttersnipe with a professional's contempt for doors", "Retired cat-burglar (twice)", "A spy between employers", "Tomb-robber with impeccable manners"]
},
Ranger: {
key: "flair", base: 6, locked: { name: "Wildcraft", attribute: "wits" },
spread: { brawn: 2, flair: 3, wits: 2, poise: 2 },
knacks: { shoot: 2, survive: 1, notice: 1 },
style: "Ranged",
gear: ["Long bow", "Dagger", "Dagger", "Padded / Leather", "Hunter's kit"],
edges: ["Favoured Quarry", "Favoured Enemy", "Rapid Shot", "Animal Companion", "Skirmisher's Step", "Trackless Pathfinder"],
tricks: 3,
companions: [["Ash", "hawk"], ["Bracken", "hound"], ["Sorrel", "wolf"], ["Moth", "lynx"], ["Tatters", "raven"]],
concepts: ["Borderer who guards where the map goes blank", "Beast-friend, people-sceptic", "A vengeful hunter with one name left", "Warden of a forest that remembers"]
},
Mage: {
key: "wits", base: 2, locked: { name: "Arcana", attribute: "wits" },
spread: { brawn: 1, flair: 2, wits: 4, poise: 2 },
knacks: { lore: 1, notice: 1 },
style: "—",
gear: ["Quarterstaff", "Arcane focus / spellbook"],
edges: ["Arcane Might", "Free-flowing Power", "Reflexive Ward"],
spells: { list: "arcane", always: ["Firebolt"], t1: 2, t2: 1, t3: 1 },
concepts: ["The most dangerous thing in the room, briefly", "Scholar of what could be made so", "Expelled for being right", "Tower-trained, world-curious"]
},
Cleric: {
key: "poise", base: 6, locked: { name: "Faith", attribute: "poise" },
spread: { brawn: 2, flair: 2, wits: 1, poise: 4 },
knacks: { command: 1, fight: 1 },
style: "Sword & Shield",
gear: ["Mace", "Mail", "Shield", "Holy symbol"],
edges: ["Aegis of Faith", "Sacred Wrath", "The Creed"],
spells: { list: "divine", always: ["Mend", "Bless"], t1: 2, t2: 1, t3: 1 },
creeds: ["protect the helpless", "speak only truth", "leave no debt unpaid", "bury the dead properly", "never refuse hospitality", "humble the proud"],
concepts: ["The reason the hand still lives to strike again", "Conviction wearing mail", "A creed with legs", "Doubt's least favourite argument"]
}
};
async function packDocs(packName) {
const pack = game.packs.get(`vanity.${packName}`);
return pack ? await pack.getDocuments() : [];
}
function pickN(arr, n) {
const pool = [...arr];
const out = [];
while (n-- > 0 && pool.length) out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
return out;
}
/* ------------ Forge a Hero ------------ */
async function forgeHero({ name = "", className = "", wild = false } = {}) {
const cls = FORGE_CLASSES[className] ? className : rnd(Object.keys(FORGE_CLASSES));
const def = FORGE_CLASSES[cls];
name ||= `${rnd(FORGE_NAMES.given)} ${Math.random() < 0.5 ? rnd(FORGE_NAMES.surnames) : rnd(FORGE_NAMES.epithets)}`;
// Attributes — 9 points, each 1–4, key stat 3+
let attrs;
if (wild) {
attrs = { brawn: 1, flair: 1, wits: 1, poise: 1 };
attrs[def.key] = 3;
let left = 9 - 6;
while (left > 0) {
const k = rnd(Object.keys(attrs));
if (attrs[k] < 4) { attrs[k] += 1; left -= 1; }
}
} else {
attrs = { ...def.spread };
}
// Grit roll: 2d6, +1 per 5–6, cap 10
const gritDice = rollDice(2, 6);
const gritBonus = gritDice.filter(d => d >= 5).length;
const grit = Math.min(10, def.base + gritBonus);
// Knacks: class list + one free Common at Trained
const knacks = Object.fromEntries(Object.keys(KNACKS).map(k => [k, { rank: def.knacks[k] ?? 0 }]));
const untrained = Object.keys(KNACKS).filter(k => !knacks[k].rank);
const freeKnack = rnd(untrained);
knacks[freeKnack].rank = 1;
// Items from the compendia
const [weapons, armour, gear, spells, edges, vices] = await Promise.all(
["weapons", "armour", "gear", "spells", "edges", "vices"].map(packDocs));
const byName = new Map([...weapons, ...armour, ...gear, ...spells, ...edges, ...vices].map(d => [d.name, d]));
const items = [];
for (const n of def.gear) { const d = byName.get(n); if (d) items.push(d.toObject()); }
for (const n of def.edges) { const d = byName.get(n); if (d) items.push(d.toObject()); }
let spellNames = [];
if (def.spells) {
const list = spells.filter(s => s.system.list === def.spells.list && !def.spells.always.includes(s.name));
spellNames = [...def.spells.always,
...pickN(list.filter(s => s.system.threshold === 1), def.spells.t1).map(s => s.name),
...pickN(list.filter(s => s.system.threshold === 2), def.spells.t2).map(s => s.name),
...pickN(list.filter(s => s.system.threshold === 3), def.spells.t3).map(s => s.name)];
}
if (def.tricks) {
const tricks = spells.filter(s => s.system.list === "trick");
spellNames = ["Nature's Balm", ...pickN(tricks.filter(t => t.name !== "Nature's Balm"), def.tricks - 1).map(t => t.name)];
}
for (const n of spellNames) { const d = byName.get(n); if (d) items.push(d.toObject()); }
const vice = rnd(vices);
items.push(vice.toObject());
const companion = def.companions ? rnd(def.companions) : null;
const creed = def.creeds ? rnd(def.creeds) : null;
const actor = await Actor.create({
name, type: "character", img: "icons/svg/mystery-man.svg",
system: {
attributes: Object.fromEntries(Object.entries(attrs).map(([k, v]) => [k, { value: v }])),
grit: { value: grit, max: grit },
vanity: 3, karma: 0, glory: 0,
knacks,
locked: { ...def.locked, rank: 1 },
details: {
class: cls, concept: rnd(def.concepts), fightingStyle: def.style,
coin: { gp: Math.floor(Math.random() * 4), sp: Math.floor(Math.random() * 10), cp: 0 }
},
companion: companion
? { name: `${companion[0]} (${companion[1]})`, grit: { value: 3, max: 3 }, pool: 3, notes: "Help +1 die · Harry −1 die/flank · Guard (reaction)" }
: { name: "", grit: { value: 3, max: 3 }, pool: 3, notes: "" },
notes: creed ? `Creed: ${creed}.` : ""
},
items
});
await ChatMessage.create({
speaker: { alias: "The Forge" },
whisper: ChatMessage.getWhisperRecipients("GM"),
content: `
⚒ A hero steps forward${cls} · grit roll ${gritDice.join(", ")} → Grit ${grit}
`
});
return actor;
}
/* ------------ Forge a Face (NPC) ------------ */
const FORGE_FACES = {
occupations: ["innkeeper", "grave-digger", "town watch sergeant", "moneylender", "chandler", "midwife", "rat-catcher",
"wandering friar", "silk merchant", "blacksmith", "herbalist", "ferryman", "scribe", "fence", "minstrel",
"beggar-king", "tax collector", "horse trader", "washerwoman who knows everything", "retired sell-sword",
"apprentice mage (expelled)", "priest of a very small god", "gong farmer", "portrait painter"],
quirks: ["never says a name twice", "collects buttons from the dead", "laughs at the wrong moments", "immaculately dressed, always",
"keeps a peacock on a leash", "speaks of themselves in the third person", "quotes their late mother constantly",
"wildly superstitious about crows", "owes everyone a small favour", "smells faintly of church incense",
"writes everything down in a little book", "chews fennel seeds", "won't cross running water", "flinches at bells",
"is missing the same finger on both hands", "claims to have met a king once", "always eating an apple", "hums when lying"],
wants: ["coin, quickly and quietly", "a letter delivered unopened", "revenge dressed as justice", "to be admired by the right people",
"out of a bad bargain", "a missing sibling found", "protection from someone dangerous", "a secret kept buried",
"to leave this town forever", "an heirloom recovered", "someone else blamed", "a rival humiliated in public",
"a debt forgiven", "one night of real importance"],
secrets: ["informs for the town watch", "is not who the gravestone says they are", "hides a fugitive in the cellar",
"owes the moneylender ruinously", "saw what came out of the barrow", "is gently, hopelessly cursed",
"sells to both sides", "was beautiful once and never recovered", "keeps a stolen holy relic under the floor",
"poisoned a spouse (allegedly)", "is the by-blow of minor nobility", "belongs to a very quiet cult"],
icons: ["icons/skills/social/diplomacy-handshake-yellow.webp", "icons/skills/social/diplomacy-handshake-blue.webp",
"icons/sundries/documents/document-sealed-brown-red.webp", "icons/tools/hand/hammer-and-nail.webp"]
};
async function forgeFace({ name = "", silent = false, folderId = null } = {}) {
name ||= `${rnd(FORGE_NAMES.given)} ${rnd(FORGE_NAMES.surnames)}`;
const occupation = rnd(FORGE_FACES.occupations);
const quirk = rnd(FORGE_FACES.quirks);
const want = rnd(FORGE_FACES.wants);
const secret = rnd(FORGE_FACES.secrets);
// Reaction roll — 2 dice, 5–6 = Success (§24)
const reactionDice = rollDice(2, 6);
const rs = reactionDice.filter(d => d >= 5).length;
const mood = ["Hostile", "Wary", "Neutral", "Friendly"][Math.min(rs, 3)];
const grit = 1 + Math.floor(Math.random() * 2);
const actor = await Actor.create({
name, type: "npc", folder: folderId, img: rnd(FORGE_FACES.icons),
system: {
category: "mook",
attack1: { name: "Scuffle", pool: 2, note: "civilian" },
attack2: { name: "", pool: 0, note: "" },
defence: { pool: 2, note: "dodge" },
grit: { value: grit, max: grit },
resolve: { value: 0, max: 0 },
nerve: 2,
trick: `Quirk: ${quirk}.`,
notes: `${occupation.charAt(0).toUpperCase() + occupation.slice(1)}. First reaction: ${mood}.\nWants: ${want}.\nSecret: ${secret}.`
}
});
if (!silent) await ChatMessage.create({
speaker: { alias: "The Forge" },
whisper: ChatMessage.getWhisperRecipients("GM"),
content: `
⚒ A face in the crowdreaction ${reactionDice.join(", ")} → ${mood}
`
});
return actor;
}
/* ------------ Forge a Hoard (treasure) ------------ */
const FORGE_TRINKETS = [
["A silver hand-mirror that always flatters", 8],
["A peacock-plume brooch, eye still watching", 5],
["A gilded death mask of a forgotten magistrate", 12],
["Opera gloves embroidered with someone else's initials", 3],
["A ring of braided hair under glass", 4],
["A tiny portrait locket — the face has been scratched out", 6],
["A crystal decanter that rings a perfect A when struck", 9],
["Ivory dice, subtly loaded toward sixes", 7],
["A signet ring for a house that no longer exists", 10],
["A vial of perfume that smells like rain on stone", 5],
["A silk banner from a tournament nobody admits losing", 6],
["A jewelled shoe buckle (just the one)", 4],
["A love letter, sealed, addressed only to 'You'", 2],
["An enamelled snuffbox with a hidden second lid", 8],
["A saint's fingerbone in a gold reliquary (provenance dubious)", 15],
["A courtier's fan painted with a scandalous scene", 7],
["A chess queen carved from jet, warm to the touch", 9],
["Spectacles that make everything look slightly grander", 11]
];
const FORGE_CENTERPIECES = [
["A life-size gilt statue of its previous owner, mid-flourish", 60],
["A peacock throne in miniature — a chair for a cat, solid silver", 45],
["A chandelier of stag horn and rock crystal", 50],
["A full-length mirror in a frame of golden laurels", 40],
["A tapestry of a battle that never happened, victors clearly labelled", 35],
["A jewelled crown sized for a child king", 80]
];
const FORGE_HOOKS = [
"Someone knows exactly what was in here, and will notice.",
"One coin in the pile is minted with next year's date.",
"The previous owner's rival would pay double to hear of this.",
"It is all subtly monogrammed. Fencing it will take a professional.",
"A beggar watched them carry it in, years ago. He remembers.",
"The centerpiece is famous. Displaying it is a Vice trigger.",
"A tithe is owed on it to a temple that keeps excellent records.",
"It's cursed, mildly: the owner dreams of its old master weekly."
];
const HOARDS = {
pocket: { label: "Pocket", coin: () => `${sum(rollDice(2, 6))} sp`, draws: 0, consum: 0.3, trinkets: [0, 1], hook: 0.15 },
cache: { label: "Cache", coin: () => `${sum(rollDice(1, 6))} gp, ${sum(rollDice(2, 6))} sp`, draws: 1, consum: 0.8, trinkets: [0, 1], hook: 0.3 },
chest: { label: "Chest", coin: () => `${sum(rollDice(4, 6))} gp`, draws: 2, consum: 1, trinkets: [1, 1], hook: 0.5 },
vault: { label: "Vault", coin: () => `${sum(rollDice(2, 6)) * 10} gp`, draws: 3, consum: 1, trinkets: [1, 2], hook: 1 },
kingly: { label: "Kingly", coin: () => `${sum(rollDice(6, 6)) * 10} gp`, draws: 5, consum: 1, trinkets: [2, 2], hook: 1, centerpiece: true }
};
async function forgeHoard({ size = "chest" } = {}) {
const tier = HOARDS[size] ?? HOARDS.chest;
const [weapons, armour, gear] = await Promise.all(["weapons", "armour", "gear"].map(packDocs));
const consumables = gear.filter(g => g.system.consumable);
const mundane = [...weapons, ...armour, ...gear.filter(g => !g.system.consumable)];
const lines = [];
lines.push(`Coin: ${tier.coin()}`);
const found = [];
if (Math.random() < tier.consum) found.push(rnd(consumables));
for (let i = 0; i < tier.draws; i++) found.push(rnd(mundane));
if (found.length) lines.push(`Goods: ${found.map(d => `@UUID[${d.uuid}]{${d.name}}`).join(" · ")}`);
const nTr = between(tier.trinkets);
if (nTr) lines.push(`Vanities: ${pickN(FORGE_TRINKETS, nTr).map(([t, v]) => `${t} (${v} gp)`).join(" · ")}`);
if (tier.centerpiece) {
const [c, v] = rnd(FORGE_CENTERPIECES);
lines.push(`Centerpiece: ${c} (${v} gp — displayed during a carouse, it counts double toward Glory)`);
}
if (Math.random() < tier.hook) lines.push(`The catch:${rnd(FORGE_HOOKS)}`);
await ChatMessage.create({
speaker: { alias: "The Forge" },
whisper: ChatMessage.getWhisperRecipients("GM"),
content: `
⚒ ${tier.label} hoardgold you cling to buys nothing — gold you squander buys legend (§27)
${lines.map(l => `
${l}
`).join("")}
`
});
}
/* ------------ Forge dialogs & directory buttons ------------ */
function forgeHeroDialog() {
new Dialog({
title: "The Forge — a Hero",
content: ``,
buttons: {
forge: {
icon: '', label: "Forge",
callback: html => {
const f = html[0].querySelector("form");
forgeHero({ name: f.elements.name.value.trim(), className: f.elements.cls.value, wild: f.elements.wild.checked });
}
}
},
default: "forge"
}).render(true);
}
function forgeMonsterDialog() {
new Dialog({
title: "The Forge — a Monster",
content: ``,
buttons: {
forge: {
icon: '', label: "Forge",
callback: html => {
const f = html[0].querySelector("form");
forgeMonster({ name: f.elements.name.value.trim(), type: f.elements.type.value, menace: f.elements.menace.value });
}
}
},
default: "forge"
}).render(true);
}
function forgeHoardDialog() {
new Dialog({
title: "The Forge — a Hoard",
content: ``,
buttons: {
forge: {
icon: '', label: "Pour it out",
callback: html => forgeHoard({ size: html[0].querySelector("form").elements.size.value })
}
},
default: "forge"
}).render(true);
}
Hooks.on("renderActorDirectory", (app, html) => {
if (!game.user.isGM) return;
const el = html instanceof HTMLElement ? html : html[0];
if (el.querySelector(".vanity-forge")) return;
const bar = document.createElement("div");
bar.className = "vanity-forge";
bar.innerHTML = `
The Forge
`;
bar.addEventListener("click", ev => {
const which = ev.target.closest("[data-forge]")?.dataset.forge;
if (which === "hero") forgeHeroDialog();
else if (which === "monster") forgeMonsterDialog();
else if (which === "face") forgeFace();
else if (which === "hoard") forgeHoardDialog();
else if (which === "encounter") forgeEncounterDialog();
else if (which === "stage") forgeStageDialog();
else if (which === "carouse") carouseDialog();
else if (which === "adventure") forgeAdventureDialog();
else if (which === "vaincrown") {
Dialog.confirm({ title: "Stage The Vain Crown?",
content: "
This builds four scenes (village, the green, the wood, the barrow), places every NPC, beast and foe as tokens, rolls the finale hoard, and writes the full adventure journal. It takes a minute and creates a lot of content.
",
yes: () => stageVainCrown() });
}
});
(el.querySelector(".directory-footer") ?? el).appendChild(bar);
});
/* -------------------------------------------- */
/* Content import (first launch of a world) */
/* -------------------------------------------- */
async function offerContentImport() {
if (!game.user.isGM) return;
if (game.settings.get("vanity", "contentImported")) return;
if (game.journal.size > 0 || game.actors.size > 0) return; // not a fresh world
new Dialog({
title: "VANITY — set the stage?",
content: `
Import everything into this world? You'll get the Draft 10 rulebook,
the GM screen, the adventure The Vain Crown, the bestiary, the five ready-to-play heroes,
and all gear, spells and roll tables, filed into tidy folders.
You can always import later from the Compendium tab.
`,
buttons: {
yes: {
icon: '',
label: "Import it all",
callback: async () => {
for (const pack of game.packs.filter(p => p.metadata.packageName === "vanity")) {
await pack.importAll({ folderName: pack.metadata.label.replace("VANITY · ", "") });
}
await game.settings.set("vanity", "contentImported", true);
ui.notifications.info("VANITY: all content imported. Break a leg — gloriously.");
}
},
later: { icon: '', label: "Not now" },
never: {
icon: '',
label: "Don't ask again",
callback: () => game.settings.set("vanity", "contentImported", true)
}
},
default: "yes"
}).render(true);
}
/* -------------------------------------------- */
/* Init */
/* -------------------------------------------- */
Hooks.once("init", () => {
console.log("VANITY | Be vain and you shine — but the brighter you burn, the bigger the bill.");
// Conditions become one-click token markers (§11b/§32).
CONFIG.statusEffects = VANITY_CONDITIONS.map(c => ({
id: c.id,
name: c.name,
img: `systems/vanity/assets/conditions/${c.id}.png`,
_vanity: { short: c.short, ends: c.ends, group: c.group }
}));
CONFIG.specialStatusEffects.DEFEATED = "taken-out";
CONFIG.Actor.documentClass = VanityActor;
CONFIG.Item.documentClass = VanityItem;
CONFIG.Combat.initiative = { formula: "1d6", decimals: 0 };
game.vanity = {
rollPool, promptAndRoll, ATTRIBUTES, KNACKS, MANEUVERS,
spend: handleSpend, push: handlePush, twist: twistTheKnife, reckoning: reckoningNow, vainCrown: stageVainCrown,
forge: { hero: forgeHero, monster: forgeMonster, face: forgeFace, hoard: forgeHoard, encounter: forgeEncounter, stage: forgeStage, adventure: forgeAdventure },
carouse
};
foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet);
foundry.documents.collections.Actors.registerSheet("vanity", VanityActorSheet, {
types: ["character"], makeDefault: true, label: "VANITY Character Sheet"
});
foundry.documents.collections.Actors.registerSheet("vanity", VanityNpcSheet, {
types: ["npc"], makeDefault: true, label: "VANITY NPC Statblock"
});
foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet);
foundry.documents.collections.Items.registerSheet("vanity", VanityItemSheet, { makeDefault: true, label: "VANITY Item Sheet" });
game.settings.register("vanity", "contentImported", {
name: "Starter content imported", scope: "world", config: false, type: Boolean, default: false
});
game.settings.register("vanity", "rerollInitiative", {
name: "Reroll The Draw every round",
hint: "Initiative is 1d6, rerolled at the top of every round, as the rules intend.",
scope: "world", config: true, type: Boolean, default: true
});
// Fonts available to journals & drawings
CONFIG.fontDefinitions["Cinzel"] = {
editor: true,
fonts: [
{ urls: ["systems/vanity/fonts/Cinzel-SemiBold.woff2"], weight: 600 },
{ urls: ["systems/vanity/fonts/Cinzel-Bold.woff2"], weight: 700 }
]
};
CONFIG.fontDefinitions["EB Garamond"] = {
editor: true,
fonts: [
{ urls: ["systems/vanity/fonts/EBGaramond-Regular.woff2"], weight: 400 },
{ urls: ["systems/vanity/fonts/EBGaramond-Italic.woff2"], weight: 400, style: "italic" },
{ urls: ["systems/vanity/fonts/EBGaramond-Medium.woff2"], weight: 500 }
]
};
// Handlebars helpers
Handlebars.registerHelper("eq", (a, b) => a === b);
Handlebars.registerHelper("gt", (a, b) => Number(a) > Number(b));
Handlebars.registerHelper("add", (a, b) => (Number(a) || 0) + (Number(b) || 0));
Handlebars.registerHelper("checkedIf", v => (v ? "checked" : ""));
Handlebars.registerHelper("concat", (...args) => args.slice(0, -1).join(""));
});
Hooks.once("ready", () => {
offerContentImport();
});
/* Reroll The Draw at the top of each round. */
Hooks.on("combatRound", async (combat, updateData, updateOptions) => {
if (!game.user.isGM) return;
if (!game.settings.get("vanity", "rerollInitiative")) return;
if ((updateOptions?.direction ?? 1) < 0) return;
await combat.resetAll();
await combat.rollAll();
});
/* Chat card buttons. */
Hooks.on("renderChatMessageHTML", (message, html) => {
html.querySelectorAll(".vanity-push").forEach(btn => {
btn.addEventListener("click", () => {
if (!game.user.isGM && message.author?.id !== game.user.id) {
return ui.notifications.warn("Only the roller (or the GM) may Push.");
}
handlePush(message);
});
});
html.querySelectorAll(".vanity-fizzle").forEach(btn => {
btn.addEventListener("click", () => {
if (!game.user.isGM && message.author?.id !== game.user.id) return;
handleFizzle(message);
});
});
html.querySelectorAll(".vanity-spend button").forEach(btn => {
btn.addEventListener("click", () => {
if (!game.user.isGM && message.author?.id !== game.user.id) {
return ui.notifications.warn("Only the roller (or the GM) may spend Successes.");
}
handleSpend(message, btn.dataset);
});
});
html.querySelectorAll(".vanity-twist").forEach(btn => {
btn.addEventListener("click", () => {
if (!game.user.isGM) return ui.notifications.warn("Only the GM may twist the knife.");
twistTheKnife({
actorUuid: btn.dataset.actorUuid ?? "",
free: btn.dataset.free === "true",
sourceMessage: btn.dataset.free === "true" ? message : null
});
});
});
html.querySelectorAll(".vanity-reckon").forEach(btn => {
btn.addEventListener("click", () => {
if (!game.user.isGM) return ui.notifications.warn("Only the GM may call the Reckoning.");
reckoningNow({ actorUuid: btn.dataset.actorUuid ?? "" });
});
});
});
/* Token defaults — and nobody goes unarmed. */
Hooks.on("preCreateActor", (actor, data) => {
const bar = { displayBars: CONST.TOKEN_DISPLAY_MODES.OWNER_HOVER, bar1: { attribute: "grit" } };
if (actor.type === "character") {
actor.updateSource({ prototypeToken: { ...bar, actorLink: true, displayName: CONST.TOKEN_DISPLAY_MODES.HOVER } });
const hasUnarmed = actor._source.items?.some(i => i.type === "weapon" && i.name === UNARMED_STRIKE.name);
if (!hasUnarmed) {
actor.updateSource({ items: [...(actor._source.items ?? []), foundry.utils.deepClone(UNARMED_STRIKE)] });
}
} else {
actor.updateSource({ prototypeToken: { ...bar, displayName: CONST.TOKEN_DISPLAY_MODES.HOVER } });
}
});
/* -------------------------------------------- */
/* THE STAGE — battlemap & scene generator */
/* (v14 Levels-safe; recipe shared w/ mapwright)*/
/* -------------------------------------------- */
const STAGE_GS = 100;
const STAGE_SIZES = { small: [24, 16], medium: [30, 20], large: [38, 26] };
const STAGE_NAMES = {
barrow: [["The Barrow of the", "The Tomb of the", "The Vault of the", "The Halls of the"],
["Hollow King", "Vain Queen", "Gilded Court", "Silent Host", "Forgotten Name", "Peacock Prince"]],
cave: [["The Gullet", "The Weeping", "The Howling", "The Black", "The Salt", "The Candle"],
["Caves", "Deep", "Warren", "Throat", "Galleries"]],
fen: [["The Weeping", "The Drowned", "The Mirror", "The Pale", "The Whispering"],
["Fen", "Marsh", "Mire", "Flats"]],
village: [["Nettlebrook", "Marrowdown", "Gallowsmoor", "Peddler's Rest", "Thistlewick", "Mudholt", "Crowbeck"],
["", "", ""]],
forest: [["The Whispering", "The Tanglewood", "The Elder", "The Hollow", "The Thornmarch"],
["Wood", "Forest", "Wilds", "Greenway", "Thicket"]]
};
function stageName(type) { const [a, b] = STAGE_NAMES[type]; const t = `${rnd(a)} ${rnd(b)}`.trim(); return t; }
const d = n => 1 + Math.floor(Math.random() * n);
/* ---------- layouts ---------- */
function genBarrow(cols, rows) {
const g = Array.from({ length: rows }, () => new Array(cols).fill(0));
const rooms = [];
const target = Math.max(4, Math.floor((cols * rows) / 60));
for (let i = 0; i < 90 && rooms.length < target; i++) {
const w = 3 + Math.floor(Math.random() * 5), h = 3 + Math.floor(Math.random() * 4);
const x = 1 + Math.floor(Math.random() * (cols - w - 2));
const y = 1 + Math.floor(Math.random() * (rows - h - 2));
let ok = true;
for (let yy = y - 1; yy <= y + h && ok; yy++) for (let xx = x - 1; xx <= x + w; xx++) {
if (g[yy]?.[xx]) { ok = false; break; }
}
if (!ok) continue;
for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) g[yy][xx] = 1;
rooms.push({ x, y, w, h, cx: x + Math.floor(w / 2), cy: y + Math.floor(h / 2) });
}
rooms.sort((a, b) => a.cx - b.cx);
const doors = [];
const seen = new Set();
for (let i = 1; i < rooms.length; i++) {
const a = rooms[i - 1], b = rooms[i];
let x = a.cx, y = a.cy, prev = null;
const step = (nx, ny) => {
const was = g[ny][nx];
const t = was === 1 ? 1 : 2;
if (!was) g[ny][nx] = 2;
if (prev && prev.t !== t) {
const key = [prev.x, prev.y, nx, ny].join(",");
if (!seen.has(key)) { seen.add(key); doors.push({ ax: prev.x, ay: prev.y, bx: nx, by: ny }); }
}
prev = { x: nx, y: ny, t };
};
step(x, y);
while (x !== b.cx) { x += Math.sign(b.cx - x); step(x, y); }
while (y !== b.cy) { y += Math.sign(b.cy - y); step(x, y); }
}
return { g, rooms, doors };
}
function genCave(cols, rows) {
let g = Array.from({ length: rows }, (_, y) => new Array(cols).fill(0).map((_, x) =>
(x === 0 || y === 0 || x === cols - 1 || y === rows - 1) ? 0 : (Math.random() < 0.55 ? 1 : 0)));
for (let it = 0; it < 4; it++) {
const nx = g.map(r => [...r]);
for (let y = 1; y < rows - 1; y++) for (let x = 1; x < cols - 1; x++) {
let n = 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (dx || dy) n += g[y + dy][x + dx];
}
nx[y][x] = (g[y][x] ? n >= 4 : n >= 5) ? 1 : 0;
}
g = nx;
}
// keep the largest connected floor region
const label = Array.from({ length: rows }, () => new Array(cols).fill(0));
let best = 0, bestId = 0, id = 0;
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
if (!g[y][x] || label[y][x]) continue;
id += 1; let count = 0;
const stack = [[x, y]];
label[y][x] = id;
while (stack.length) {
const [cx, cy] = stack.pop(); count++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const px = cx + dx, py = cy + dy;
if (g[py]?.[px] && !label[py][px]) { label[py][px] = id; stack.push([px, py]); }
}
}
if (count > best) { best = count; bestId = id; }
}
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) g[y][x] = label[y][x] === bestId ? 1 : 0;
return { g, rooms: [], doors: [] };
}
function genFen(cols, rows) {
const g = Array.from({ length: rows }, () => new Array(cols).fill(1));
const pools = [], clumps = [];
const blob = (cx, cy, r) => {
const cells = [];
for (let y = Math.max(1, cy - r); y <= Math.min(rows - 2, cy + r); y++) {
for (let x = Math.max(1, cx - r); x <= Math.min(cols - 2, cx + r); x++) {
if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r + Math.random() * 2) cells.push([x, y]);
}
}
return cells;
};
for (let i = 0; i < 3 + d(3); i++) pools.push(blob(d(cols - 4) + 1, d(rows - 4) + 1, 1 + d(2)));
for (let i = 0; i < 4 + d(4); i++) clumps.push(blob(d(cols - 4) + 1, d(rows - 4) + 1, 1));
return { g, rooms: [], doors: [], pools, clumps };
}
function genVillage(cols, rows) {
const g = Array.from({ length: rows }, () => new Array(cols).fill(1));
const buildings = [];
const cxg = cols / 2, cyg = rows / 2;
const clash = (x, y, w, h) => buildings.some(b => x < b.x + b.w + 1 && x + w + 1 > b.x && y < b.y + b.h + 1 && y + h + 1 > b.y);
const target = Math.max(5, Math.floor((cols * rows) / 70));
for (let i = 0; i < 140 && buildings.length < target; i++) {
const w = 3 + Math.floor(Math.random() * 3), h = 3 + Math.floor(Math.random() * 2);
const x = 2 + Math.floor(Math.random() * (cols - w - 4));
const y = 2 + Math.floor(Math.random() * (rows - h - 4));
if (Math.abs(x + w / 2 - cxg) < 3.5 && Math.abs(y + h / 2 - cyg) < 3.5) continue; // keep the green clear
if (clash(x, y, w, h)) continue;
const edges = ["top", "bottom", "left", "right"];
buildings.push({ x, y, w, h, doorEdge: rnd(edges), roof: rnd(["#7a2f2f", "#6a4a2a", "#5a3d24", "#734a30"]) });
}
const segs = [];
for (const b of buildings) {
const { x, y, w, h, doorEdge } = b;
const run = (x1, y1, x2, y2, edge) => {
if (edge === doorEdge) {
// punch a 1-cell door in the middle of the edge
if (y1 === y2) { const dx = x1 + Math.floor((x2 - x1) / 2);
if (dx > x1) segs.push({ x1, y1, x2: dx, y2, kind: "wall" });
segs.push({ x1: dx, y1, x2: dx + 1, y2, kind: "door" });
if (dx + 1 < x2) segs.push({ x1: dx + 1, y1, x2, y2, kind: "wall" });
} else { const dy = y1 + Math.floor((y2 - y1) / 2);
if (dy > y1) segs.push({ x1, y1, x2, y2: dy, kind: "wall" });
segs.push({ x1, y1: dy, x2, y2: dy + 1, kind: "door" });
if (dy + 1 < y2) segs.push({ x1, y1: dy + 1, x2, y2, kind: "wall" });
}
} else segs.push({ x1, y1, x2, y2, kind: "wall" });
};
run(x, y, x + w, y, "top");
run(x, y + h, x + w, y + h, "bottom");
run(x, y, x, y + h, "left");
run(x + w, y, x + w, y + h, "right");
}
const well = { x: Math.floor(cxg), y: Math.floor(cyg) };
const rooms = buildings.map(b => ({ cx: b.x + Math.floor(b.w / 2), cy: b.y + Math.floor(b.h / 2), ...b }));
return { g, buildings, well, segments: segs, rooms };
}
function genForest(cols, rows) {
const g = Array.from({ length: rows }, () => new Array(cols).fill(1));
const trees = [];
const clearing = { x: cols - 5, y: Math.floor(rows / 2), r: 3.2 };
const nearClear = (x, y) => (x - clearing.x) ** 2 + (y - clearing.y) ** 2 < (clearing.r + 1.5) ** 2;
// a winding path from the west edge to the clearing
const path = [];
let py = Math.floor(rows / 2);
for (let x = 1; x <= clearing.x; x++) {
py = Math.max(2, Math.min(rows - 3, py + (Math.random() < 0.5 ? -1 : 1) * (Math.random() < 0.4 ? 1 : 0)));
path.push([x, py]);
}
const onPath = (x, y) => path.some(([px, py2]) => Math.abs(px - x) <= 1 && Math.abs(py2 - y) <= 1);
const segs = [];
const count = Math.floor(cols * rows / 12);
for (let i = 0; i < count; i++) {
const x = 1 + Math.random() * (cols - 2), y = 1 + Math.random() * (rows - 2);
if (nearClear(x, y) || onPath(Math.round(x), Math.round(y))) continue;
const r = 0.5 + Math.random() * 0.6;
trees.push({ x, y, r });
if (Math.random() < 0.4) { // some trees block line of sight & movement (a thicket)
const gx = Math.round(x), gy = Math.round(y);
segs.push({ x1: gx, y1: gy, x2: gx + 1, y2: gy, kind: "wall" });
segs.push({ x1: gx, y1: gy, x2: gx, y2: gy + 1, kind: "wall" });
}
}
return { g, trees, path, clearing, segments: segs, rooms: [] };
}
/* ---------- walls from a cell grid ---------- */
function stageWalls(g, cols, rows, doors) {
const floor = (x, y) => (g[y]?.[x] ?? 0) > 0;
const segs = [];
// vertical boundaries (between column x-1 and x)
for (let x = 0; x <= cols; x++) {
let run = null;
for (let y = 0; y <= rows; y++) {
const boundary = y < rows && (floor(x - 1, y) !== floor(x, y));
if (boundary && !run) run = y;
if (!boundary && run !== null) { segs.push({ x1: x, y1: run, x2: x, y2: y, kind: "wall" }); run = null; }
}
}
// horizontal boundaries
for (let y = 0; y <= rows; y++) {
let run = null;
for (let x = 0; x <= cols; x++) {
const boundary = x < cols && (floor(x, y - 1) !== floor(x, y));
if (boundary && run === null) run = x;
if (!boundary && run !== null) { segs.push({ x1: run, y1: y, x2: x, y2: y, kind: "wall" }); run = null; }
}
}
for (const dd of doors ?? []) {
if (dd.ay === dd.by) { // horizontal neighbours → vertical door edge
const x = Math.max(dd.ax, dd.bx);
segs.push({ x1: x, y1: dd.ay, x2: x, y2: dd.ay + 1, kind: "door" });
} else {
const y = Math.max(dd.ay, dd.by);
segs.push({ x1: dd.ax, y1: y, x2: dd.ax + 1, y2: y, kind: "door" });
}
}
return segs;
}
/* ---------- rendering ---------- */
function stageSVG(type, model, cols, rows) {
const gs = STAGE_GS, W = cols * gs, H = rows * gs;
const g = model.g;
const floor = (x, y) => (g[y]?.[x] ?? 0) > 0;
const jitter = (x, y, spread) => ((Math.sin(x * 127.1 + y * 311.7) * 43758.5453) % 1 + 1) % 1 * spread;
let s = `";
return s;
}
/* ---------- scene assembly ---------- */
async function stageRasterUpload(svg, W, H, name) {
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const img = new Image();
await new Promise((res, rej) => { img.onload = res; img.onerror = () => rej(new Error("SVG raster failed")); img.src = url; });
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.getContext("2d").drawImage(img, 0, 0, W, H);
const png = await new Promise(res => canvas.toBlob(res, "image/png"));
const FP = foundry.applications?.apps?.FilePicker?.implementation ?? FilePicker;
try { await FP.createDirectory("data", "vanity-stage"); } catch (e) { /* exists */ }
const filename = `${name.slugify()}-${Date.now()}.png`;
const up = await FP.upload("data", "vanity-stage", new File([png], filename, { type: "image/png" }), {}, { notify: false });
if (!up?.path) throw new Error("Upload failed — check file upload permissions.");
return String(up.path).replace(/^\/+/, "");
} finally { URL.revokeObjectURL(url); }
}
async function forgeStage({ type = "barrow", size = "medium", name = "", populate = false, heat = "fight", kind = "", cast = null, activate = true } = {}) {
if (!game.user.isGM) return;
const [cols, rows] = STAGE_SIZES[size] ?? STAGE_SIZES.medium;
name ||= stageName(type);
ui.notifications.info(`The Stage: raising “${name}”…`);
const model = type === "cave" ? genCave(cols, rows) : type === "fen" ? genFen(cols, rows)
: type === "village" ? genVillage(cols, rows) : type === "forest" ? genForest(cols, rows) : genBarrow(cols, rows);
if (type === "barrow" || type === "cave") model.segments = stageWalls(model.g, cols, rows, model.doors);
else if (type === "fen") model.segments = [];
// village/forest already carry model.segments
const svg = stageSVG(type, model, cols, rows);
const gs = STAGE_GS, W = cols * gs, H = rows * gs;
const bgPath = await stageRasterUpload(svg, W, H, name);
const scene = await Scene.create({
name,
width: W, height: H, padding: 0,
grid: { type: CONST.GRID_TYPES?.SQUARE ?? 1, size: gs, distance: 5, units: "ft" },
tokenVision: !["fen", "village", "forest"].includes(type),
fog: { exploration: !["fen", "village", "forest"].includes(type) },
environment: ["fen", "village", "forest"].includes(type) ? { globalLight: { enabled: true } } : {},
navigation: true
});
// v14: the background lives on the scene's default Level.
const lv = scene.levels?.contents?.[0];
if (lv) await scene.updateEmbeddedDocuments("Level", [{ _id: lv.id, background: { src: bgPath, color: "#0a1a18" } }]);
else await scene.update({ background: { src: bgPath } });
if (model.segments.length) {
const K = {
move: CONST.WALL_MOVEMENT_TYPES?.NORMAL ?? 20,
sense: CONST.WALL_SENSE_TYPES?.NORMAL ?? 20,
door: CONST.WALL_DOOR_TYPES?.DOOR ?? 1,
closed: CONST.WALL_DOOR_STATES?.CLOSED ?? 0
};
const walls = model.segments.map(s => ({
c: [s.x1 * gs, s.y1 * gs, s.x2 * gs, s.y2 * gs],
move: K.move, sight: K.sense, sound: K.sense, light: K.sense,
...(s.kind === "door" ? { door: K.door, ds: K.closed } : {})
}));
await scene.createEmbeddedDocuments("Wall", walls);
}
// lights
const lights = [];
const floorCells = [];
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) if ((model.g[y]?.[x] ?? 0) > 0) floorCells.push([x, y]);
if (type === "barrow") {
for (const r of model.rooms) {
if (Math.random() < 0.65) lights.push({
x: (r.cx + 0.5) * gs, y: (r.cy + 0.5) * gs,
config: { bright: 10, dim: 25, color: "#b0872f", alpha: 0.3, animation: { type: "torch", speed: 3, intensity: 4 } }
});
}
} else if (type === "cave") {
for (const [x, y] of pickN(floorCells, 3)) lights.push({
x: (x + 0.5) * gs, y: (y + 0.5) * gs,
config: { bright: 5, dim: 20, color: "#1c6f66", alpha: 0.25, animation: { type: "pulse", speed: 2, intensity: 3 } }
});
}
if (lights.length) await scene.createEmbeddedDocuments("AmbientLight", lights);
// placement: a supplied cast, else an auto-encounter
let placed = cast;
if (!placed && populate) {
const enc = await forgeEncounter({ heat, kind, forStage: name });
placed = enc.actors.map(a => ({ actorId: a.id, name: a.name, img: a.img, disposition: -1 }));
}
if (placed?.length) {
const rooms = model.rooms?.length > 1 ? model.rooms.slice(1).map(r => [r.cx, r.cy]) : [];
const open = pickN(floorCells.filter(([x, y]) => !model.segments.some(sg =>
sg.kind !== "door" && Math.min(sg.x1, sg.x2) <= x && x < Math.max(sg.x1, sg.x2) + 1 &&
Math.min(sg.y1, sg.y2) <= y && y < Math.max(sg.y1, sg.y2) + 1)), placed.length + 4);
const spots = (type === "barrow" && rooms.length) ? rooms : open.length ? open : floorCells;
const tokens = placed.map((a, i) => {
const [x, y] = spots[i % spots.length] ?? [1 + i, 1];
const actor = game.actors.get(a.actorId);
return { name: a.name ?? actor?.name, actorId: a.actorId, texture: { src: a.img ?? actor?.img },
x: x * gs, y: y * gs, width: 1, height: 1, disposition: a.disposition ?? -1, hidden: !!a.hidden };
});
if (tokens.length) await scene.createEmbeddedDocuments("Token", tokens);
}
if (activate) await scene.activate();
await ChatMessage.create({
speaker: { alias: "The Stage" },
whisper: ChatMessage.getWhisperRecipients("GM"),
content: `
🎭 The stage is set${type} · ${cols}×${rows} · ${model.segments.length} walls${populate ? " · populated" : ""}
${name} is raised and active. ${type === "fen" ? "Open ground — no fog, daylight." : "Token vision and fog are on; doors are gold on the map."}
`
});
return scene;
}
/* -------------------------------------------- */
/* THE ENCOUNTER — situation, foes & spoils */
/* -------------------------------------------- */
const ENCOUNTER_SITUATIONS = [
"They're arguing over the split of fresh loot — half of them aren't watching the door.",
"An ambush: they saw you first. No Reaction roll — Hostile, and they have position.",
"Mid-ritual. Interrupting it is loud; letting it finish is worse.",
"Feeding. It does not want to share.",
"Lost, frightened, and twice as dangerous for it.",
"Guarding a door they were told never to open. They're curious too.",
"Burying one of their own — grief has made them either vicious or reasonable.",
"Counting heads. One of them is missing, and they think you know why.",
"Holding a prisoner who might be worth more to you than to them.",
"Asleep — with one terrible, wide-awake sentry.",
"Tracking something, and it isn't you. Yet.",
"Setting a trap meant for something much bigger.",
"In tense parley with a second party who like you even less.",
"Licking wounds from an earlier fight. Someone owes them.",
"Celebrating loudly. The wine is doing half your work.",
"Waiting for a signal that is never going to come.",
"Praying — to something that occasionally answers.",
"Dividing the last of their food. Desperation has a smell."
];
const ENCOUNTER_TERRAIN = [
"A brazier full of hot coals, begging to be kicked (Environment, 1 Success).",
"A rope bridge over the dark — Drive Back has opinions here.",
"Chest-high tombs in tight rows: cover everywhere, charging nowhere.",
"An oil slick from a shattered lamp, one spark from mattering.",
"A scree slope — Athletics to charge across, or arrive winded.",
"A great bell. Any Bane spent may ring it, and everything will know.",
"A deep black pool splits the field in two.",
"Scaffolding overhead, groaning, held by exactly one rope.",
"Knee-deep mist: everything below the waist is a guess.",
"A portcullis winch in the far corner — first to it owns the exit.",
"A long ledge and a longer drop.",
"Pillars everywhere — Backstab country."
];
const ENCOUNTER_COMPLICATIONS = [
"A prisoner here knows the way you need. Keep them breathing.",
"One of them wants to defect — if given one dignified excuse.",
"Reinforcements arrive in 3 rounds if this gets loud.",
"Their loot is cursed, and they know it. They'd love you to take it.",
"A rival crew watches from cover, waiting to pick off the winners.",
"The roof is unstable. Every 2-Success Environment spend brings some down.",
"One carries a letter naming their employer. That name matters.",
"They've mistaken you for someone who owes them money. A lot of it.",
"The smallest one is the actual leader.",
"Something bigger hunts THEM. It is closer than anyone thinks.",
"They'll fold fast before real showmanship — Vice bait, +1 Vanity for grandstanding.",
"The way out closes at the end of round 5. Everyone can hear it grinding."
];
const HEAT_BUILDS = {
skirmish: () => ({ mook: 2 + d(3), standard: Math.random() < 0.5 ? 1 : 0, threat: 0, nemesis: 0 }),
fight: () => ({ mook: d(4), standard: 1 + (Math.random() < 0.5 ? 1 : 0), threat: 0, nemesis: 0 }),
battle: () => ({ mook: d(4), standard: 1, threat: 1, nemesis: 0 }),
nightmare: () => ({ mook: 0, standard: d(3), threat: Math.random() < 0.4 ? 1 : 0, nemesis: 1 })
};
const HEAT_HOARD = { skirmish: "pocket", fight: "cache", battle: "chest", nightmare: "vault" };
async function forgeEncounter({ heat = "fight", kind = "", forStage = "" } = {}) {
const t = FORGE_MONSTERS[kind] ? kind : rnd(Object.keys(FORGE_MONSTERS));
const build = (HEAT_BUILDS[heat] ?? HEAT_BUILDS.fight)();
const folder = await Folder.create({ name: `Encounter — ${t} (${heat})`, type: "Actor" });
const actors = [];
for (const [menace, count] of Object.entries(build)) {
for (let i = 0; i < count; i++) actors.push(await forgeMonster({ type: t, menace, silent: true, folderId: folder.id }));
}
const situation = rnd(ENCOUNTER_SITUATIONS);
const terrain = rnd(ENCOUNTER_TERRAIN);
const complication = rnd(ENCOUNTER_COMPLICATIONS);
const ambush = situation.startsWith("An ambush");
let mood = "Hostile", moodDice = [];
if (!ambush) {
moodDice = rollDice(2, 6);
const rs = moodDice.filter(x => x >= 5).length;
mood = ["Hostile", "Wary", "Neutral", "Friendly"][Math.min(rs, 3)];
}
await ChatMessage.create({
speaker: { alias: "The Forge" },
whisper: ChatMessage.getWhisperRecipients("GM"),
content: `
⚒ An encounter takes shape${heat} · ${t}${forStage ? ` · staged at ${forStage}` : ""} · mood: ${mood}${moodDice.length ? ` (${moodDice.join(", ")})` : " (they saw you first)"}
`
});
await forgeHoard({ size: HEAT_HOARD[heat] ?? "cache" });
return { actors, situation, terrain, complication, mood, folder };
}
/* -------------------------------------------- */
/* THE CAROUSE — burn gold gloriously (§27) */
/* -------------------------------------------- */
async function carouse({ actorId = "", gold = 20, rate = 20 } = {}) {
const actor = game.actors.get(actorId);
if (!actor || actor.type !== "character") return ui.notifications.warn("Pick a character to carouse.");
gold = Math.max(0, Math.floor(gold));
const glory = Math.floor(gold / rate);
if (glory < 1) return ui.notifications.warn(`It takes ~${rate} gp to buy a single point of Glory. Spend like you mean it.`);
const purse = actor.system.details.coin ?? { gp: 0, sp: 0, cp: 0 };
if ((purse.gp ?? 0) < gold) return ui.notifications.warn(`${actor.name} carries only ${purse.gp ?? 0} gp.`);
await actor.update({
"system.details.coin.gp": (purse.gp ?? 0) - gold,
"system.glory": (actor.system.glory ?? 0) + glory
});
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `
🍷 The Carousegold you cling to buys nothing — gold you squander buys legend (§27)
${actor.name} burns ${gold} gp in one spectacular blow-out — feasts, statues, scandalous commissions —
and wakes famous: +${glory} Glory (now ${actor.system.glory}).
And then, the morning after…
`
});
const table = await getVanityTable("The Morning After (carousing)");
if (table) await table.draw({ displayChat: true });
}
/* ---------- dialogs for the new tools ---------- */
function forgeEncounterDialog() {
new Dialog({
title: "The Forge — an Encounter",
content: ``,
buttons: {
forge: {
icon: '', label: "Forge",
callback: html => {
const f = html[0].querySelector("form");
forgeEncounter({ kind: f.elements.kind.value, heat: f.elements.heat.value });
}
}
},
default: "forge"
}).render(true);
}
function forgeStageDialog() {
new Dialog({
title: "The Stage — raise a battlemap",
content: ``,
buttons: {
forge: {
icon: '', label: "Raise the stage",
callback: html => {
const f = html[0].querySelector("form");
forgeStage({
name: f.elements.name.value.trim(),
type: f.elements.type.value,
size: f.elements.size.value,
populate: f.elements.populate.checked,
heat: f.elements.heat.value
});
}
}
},
default: "forge"
}).render(true);
}
function carouseDialog() {
const chars = game.actors.filter(a => a.type === "character");
new Dialog({
title: "The Carouse — buy legend with gold",
content: ``,
buttons: {
carouse: {
icon: '', label: "Pour",
callback: html => {
const f = html[0].querySelector("form");
carouse({ actorId: f.elements.actor.value, gold: parseInt(f.elements.gold.value) || 0 });
}
}
},
default: "carouse"
}).render(true);
}
/* -------------------------------------------- */
/* THE COMMISSION — a one-shot adventure */
/* -------------------------------------------- */
const COMMISSION_TITLES = [
["The Gilded", "The Hollow", "The Borrowed", "The Drowned", "The Crimson", "The Whispering", "The Counterfeit", "The Thrice-Sold", "The Unburied", "The Peacock's"],
["Crown", "Reliquary", "Wedding", "Inheritance", "Portrait", "Menagerie", "Funeral", "Masquerade", "Debt", "Songbird", "Mirror", "Testament"]
];
const COMMISSION_PRIZES = [
"a reliquary said to hold a saint's smile",
"a portrait that flatters whoever owns it",
"a circlet last worn at a hanging",
"a songbird that repeats one dead man's words",
"a wedding dress sewn with borrowed pearls",
"a deed to land that is definitely underwater",
"a mirror that shows you five years younger",
"an urn of ashes that are the wrong ashes",
"a chess set carved from a saint's pew",
"a love letter that would end two houses",
"a mask worn by the last three usurpers",
"a bell clapper stolen so a tower stays silent"
];
const COMMISSION_TASKS = [
"recover {PRIZE} before the next full moon makes it worse",
"steal back {PRIZE} — quietly, and without admitting it was ever lost",
"deliver {PRIZE}, sealed, unopened, and unexamined. Especially unexamined",
"prove {PRIZE} is a forgery without revealing who commissioned the original",
"bury {PRIZE} properly, in the right grave, this time",
"escort {PRIZE} to the border and pretend to be robbed on the way",
"authenticate {PRIZE} in front of witnesses who must not survive being wrong",
"swap {PRIZE} for a perfect copy before the unveiling",
"find who really owns {PRIZE} and make the question go away",
"retrieve {PRIZE} from where the patron very foolishly hid it"
];
const COMMISSION_VILLAIN_MOTIVES = [
"wants the prize for exactly the reason the patron fears",
"already has the prize, and is waiting to see who comes",
"is the patron's sibling, and arguably in the right",
"was cheated of the prize first — the paperwork agrees",
"believes the prize is cursed and means to destroy it (it is; they do)",
"is being paid by the patron's rival — same job, other end",
"worships what is inside the prize",
"needs the prize to pay a Reckoning of their own",
"loved the prize's last owner and will not forgive",
"is the previous crew, still alive, still owed"
];
const COMMISSION_TWISTS = [
"The patron is lying about what the prize is.",
"The prize is cursed, mildly, and contagiously.",
"Two patrons hired two crews. You are the second.",
"The villain is right, and everyone important knows it.",
"The prize must not travel by daylight. Nobody says why.",
"The patron's payment is itself stolen, and marked.",
"Whoever holds the prize starts dreaming its owner's dreams.",
"The prize is heavier every mile it moves from home.",
"A carousing bard has already put the job in a song.",
"The real prize is inside the obvious prize.",
"The patron intends to report it stolen either way.",
"Someone in the patron's house is the villain's eyes."
];
const COMMISSION_DANGER = {
cozy: { heat: "fight", hoard: "cache", fee: () => 20 + sum(rollDice(2, 6)) * 2, villain: "threat" },
proper: { heat: "battle", hoard: "chest", fee: () => 40 + sum(rollDice(3, 6)) * 3, villain: "threat" },
deadly: { heat: "nightmare", hoard: "vault", fee: () => 80 + sum(rollDice(4, 6)) * 4, villain: "nemesis" }
};
async function forgeAdventure({ danger = "proper", ground = "", raiseStage = true, name = "" } = {}) {
if (!game.user.isGM) return;
const cfg = COMMISSION_DANGER[danger] ?? COMMISSION_DANGER.proper;
name ||= `${rnd(COMMISSION_TITLES[0])} ${rnd(COMMISSION_TITLES[1])}`;
const type = ["barrow", "cave", "fen"].includes(ground) ? ground : rnd(["barrow", "cave", "fen"]);
ui.notifications.info(`The Commission: drafting “${name}”…`);
const folder = await Folder.create({ name: `Commission — ${name}`, type: "Actor" });
const patron = await forgeFace({ silent: true, folderId: folder.id });
const kind = rnd(Object.keys(FORGE_MONSTERS));
const villain = await forgeMonster({ type: kind, menace: cfg.villain, silent: true, folderId: folder.id });
const prize = rnd(COMMISSION_PRIZES);
const task = rnd(COMMISSION_TASKS).replace("{PRIZE}", `${prize}`);
const motive = rnd(COMMISSION_VILLAIN_MOTIVES);
const twist = rnd(COMMISSION_TWISTS);
const fee = cfg.fee();
let scene = null;
if (raiseStage) scene = await forgeStage({ type, populate: true, heat: cfg.heat, kind });
const patronLine = patron.system.notes.split("\n")[0] ?? "";
const overview = `
${name}
A one-sitting commission for VANITY — danger: ${danger}.
The Patron
@UUID[${patron.uuid}]{${patron.name}} — ${patronLine} ${patron.system.trick}
Wants: ${patron.system.notes.match(/Wants: (.*)/)?.[1] ?? "the job done quietly"}
Secret:${patron.system.notes.match(/Secret: (.*)/)?.[1] ?? "more than one"}
The Job
${task.charAt(0).toUpperCase() + task.slice(1)}. The fee is ${fee} gp — half now, half on delivery, all of it beautifully carousable (§27).
The Opposition
@UUID[${villain.uuid}]{${villain.name}} — a ${kind} who ${motive}.
${villain.system.trick}
The Ground
${scene ? `@UUID[${scene.uuid}]{${scene.name}} — raised, walled, lit and populated (${cfg.heat}).` : `A ${type} of the GM's choosing — raise one with The Stage when ready.`}
The Twist
${twist}
On Site
Whatever else is found: the hoard below. And the prize itself, which is worth more than the fee — a fact the table will notice.
📜 A commission is drafted${danger} · ${type} · fee ${fee} gp
@UUID[${journal.uuid}]{${name}} — patron @UUID[${patron.uuid}]{${patron.name}},
opposition @UUID[${villain.uuid}]{${villain.name}}${scene ? `, staged at @UUID[${scene.uuid}]{${scene.name}}` : ""}.
${twist}
`
});
await forgeHoard({ size: cfg.hoard });
return { journal, patron, villain, scene };
}
function forgeAdventureDialog() {
new Dialog({
title: "The Commission — a one-shot adventure",
content: ``,
buttons: {
forge: {
icon: '', label: "Draft it",
callback: html => {
const f = html[0].querySelector("form");
forgeAdventure({
name: f.elements.name.value.trim(),
danger: f.elements.danger.value,
ground: f.elements.ground.value,
raiseStage: f.elements.raise.checked
});
}
}
},
default: "forge"
}).render(true);
}
/* -------------------------------------------- */
/* THE VAIN CROWN — the expanded adventure */
/* village → village fight → forest → the barrow */
/* -------------------------------------------- */
const CROWN_NPCS = [
{ name: "Reeve Aldous Pennywhistle", img: "icons/skills/social/diplomacy-handshake-yellow.webp",
role: "the reeve of Nettlebrook, and your patron",
line: "Wrings a velvet cap he cannot afford. Wants the barrow sealed before the dead king wakes wearing the crown — and wants it done quietly, for the village's good name.",
trick: "Offers 40 gp, half now. Secretly hopes you'll bring the crown to HIM." },
{ name: "Goodwife Marrow", img: "icons/skills/social/diplomacy-handshake-blue.webp",
role: "the innkeeper of the Drowned Duck",
line: "Knows every rumour in three parishes and will trade them for a listened ear. Saw the grave-robbers ride through at dusk, 'gentry-looking, the pale one especially.'",
trick: "A free bed and a warning: 'Don't take the open water road. Something's been at the sheep.'" },
{ name: "Brother Cassian", img: "icons/magic/holy/saint-glass-portrait-halo.webp",
role: "the priest of the very small chapel",
line: "Old, kind, and quietly terrified. Will bless the party (a Holy water flask each) and beg them to rebury King Aldric properly — 'the boy was vain, not evil.'",
trick: "Knows the barrow's two-key door needs BOTH a scholar and a thief. Says so, sadly." },
{ name: "The Pale Stranger", img: "icons/magic/death/grave-tombstone-glow-teal.webp",
role: "Vesper Kound's agent, posing as a pilgrim",
line: "Too clean, too calm, asks too many questions about the barrow road. This is one of Vesper's people — sent to see who else is hunting the crown.",
trick: "Reaction roll at −1 die. If rumbled, bolts for the square and blows a whistle — begins Act Two." }
];
async function makeAdventureNpc(def, folderId) {
let a = game.actors.getName(def.name);
if (a) return a;
return Actor.create({
name: def.name, type: "npc", img: def.img, folder: folderId,
system: {
category: "mook",
attack1: { name: "Scuffle", pool: 2, note: "villager" }, attack2: { name: "", pool: 0, note: "" },
defence: { pool: 2, note: "dodge" }, grit: { value: 2, max: 2 }, resolve: { value: 0, max: 0 }, nerve: 2,
trick: def.trick, notes: `${def.role}.\n${def.line}`
}
});
}
async function bestiaryActor(name, folderId, disposition = -1) {
const existing = game.actors.getName(name);
if (existing) { await existing.update({ "system.grit.value": existing.system.grit.max }); return existing; }
const pack = await game.packs.get("vanity.bestiary").getDocuments();
const src = pack.find(d => d.name === name);
if (!src) return null;
const data = src.toObject(); data.folder = folderId;
return Actor.create(data);
}
async function stageVainCrown() {
if (!game.user.isGM) return ui.notifications.warn("Only the GM may stage the adventure.");
ui.notifications.info("The Vain Crown: building the whole road — this takes a minute…");
const folder = await Folder.create({ name: "The Vain Crown — cast", type: "Actor" }).catch(() => null)
?? game.folders.find(f => f.name === "The Vain Crown — cast" && f.type === "Actor");
const fid = folder?.id ?? null;
// ---- cast ----
const npcs = [];
for (const def of CROWN_NPCS) npcs.push(await makeAdventureNpc(def, fid));
const mk = (n, disp) => bestiaryActor(n, fid, disp);
const bandits = []; for (let i = 0; i < 4; i++) bandits.push(await mk("Bandit"));
const wolf1 = await mk("Dire Wolf");
const forestWolves = []; for (let i = 0; i < 3; i++) forestWolves.push(await mk("Dire Wolf"));
const batSwarm = await mk("Bat Swarm");
const rats = []; for (let i = 0; i < 3; i++) rats.push(await mk("Giant Rat"));
const skeletons = []; for (let i = 0; i < 6; i++) skeletons.push(await mk("Skeleton"));
const ghouls = []; for (let i = 0; i < 2; i++) ghouls.push(await mk("Ghoul"));
const vesper = await mk("Vesper Kound");
const aldric = await mk("King Aldric, the Vain Corpse");
const dispo = arr => arr.filter(Boolean).map(a => ({ actorId: a.id, name: a.name, img: a.img, disposition: -1 }));
const neutral = arr => arr.filter(Boolean).map(a => ({ actorId: a.id, name: a.name, img: a.img, disposition: 0 }));
// ---- treasure ----
const gearPack = await game.packs.get("vanity.gear").getDocuments();
const crown = {
name: "The Peacock Crown", type: "gear",
img: "icons/commodities/treasure/crown-gold-satin-gems-red.webp",
system: { quantity: 1, slots: 1, consumable: false, cost: "priceless",
description: "
A gaudy golden circlet, each point tipped with a peacock-eye gem that seems to watch. Whoever wears it gains a permanent +1 die to Charm — and an unrefusable Vice: Pride, and a standing Bane the GM holds against them. Keeping it, destroying it, or reburying it is the party's final, character-defining choice.
" }
};
// ---- Act I: the village (daylight, NPCs, no fight) ----
const village = await forgeStage({ type: "village", size: "large", name: "Nettlebrook", activate: false,
cast: neutral(npcs) });
// ---- Act II: the fight in the square (grave-robbers + a wolf) ----
const square = await forgeStage({ type: "village", size: "medium", name: "Nettlebrook — the Green", activate: false,
cast: dispo([...bandits, wolf1]) });
// ---- Act III: the forest road (beasts) ----
const forest = await forgeStage({ type: "forest", size: "large", name: "The Whispering Wood", activate: false,
cast: dispo([...forestWolves, batSwarm, ...rats]) });
// ---- Act IV: the barrow (crypt → throne) ----
const barrow = await forgeStage({ type: "barrow", size: "large", name: "The Barrow of King Aldric", activate: false,
cast: dispo([...skeletons, ...ghouls, vesper, aldric]) });
// hoard for the finale
await forgeHoard({ size: "vault" });
// ---- the journal ----
const scenes = { village, square, forest, barrow };
const npcLink = a => `@UUID[${a.uuid}]{${a.name}}`;
const sceneLink = s => `@UUID[${s.uuid}]{${s.name}}`;
const box = t => `
` }, sort });
const pages = [
page("The Vain Crown — the road ahead", `
An expanded one-to-two-session VANITY adventure. The vain boy-king Aldric III was buried a year ago with the Peacock Crown. Now the crown stirs, the dead king will not stay dead, and a grave-robbing sorcerer — Vesper Kound — is already on the road to claim it. It begins, as these things do, in a small village that would very much like the problem to go away.
The four acts
Act I — Nettlebrook (${sceneLink(village)}): meet the patron, gather rumours, spot Vesper's agent.
Act II — The Green (${sceneLink(square)}): the whistle blows; grave-robbers and a wolf turn on the village.
Act III — The Whispering Wood (${sceneLink(forest)}): the forest road to the barrow, and what hunts it.
Act IV — The Barrow of King Aldric (${sceneLink(barrow)}): the crypt, the throne, and the crown that admires you back.
Every map is a live Foundry scene with walls, doors and daylight/fog set; every name is a token already placed. Nudge the tokens where you want them and press play.
`, 0),
page("Act I · Nettlebrook", `
${box("Nettlebrook is three streets, a green, a well, and a chapel the size of a shed. The reeve is waiting on the green in a velvet cap he plainly cannot afford, and he is very glad to see anyone at all carrying a weapon.")}
Scene: ${sceneLink(village)} — daylight, no fog, freely walkable. Four faces to play:
Rolls that matter: a Reaction (§24) for the Pale Stranger at −1 die; Charm/Command to open the reeve's purse wider; Notice/Lore to realise the 'pilgrim' is nothing of the kind. The moment the party corners the Stranger, they whistle and run — go to Act II.
`, 100),
page("Act II · The Green", `
${box("A whistle, then screaming. Vesper left a handful of hard men in the village to keep it frightened — and something worse on a leash. They break cover from the alleys and the well-house, and a villager is already down in the mud.")}
Scene: ${sceneLink(square)}. Foes: ${bandits.map(npcLink).join(" · ")} and ${npcLink(wolf1)}.
Bandits (attack 3d, leather, Grit 2): flee when half are down or the wolf falls.
Dire Wolf (attack 4d, dodge 4d, Grit 5): Knock Down free on a hit. Vesper's sentry-beast.
The green is cover country — the well, carts, and building corners. Reward Environment maneuvers and grandstanding (Vice bait, +1 Vanity).
Take one bandit alive and they'll talk: Vesper rode for the barrow at dawn, by the forest road, 'and good luck to you on it.' Go to Act III.
`, 200),
page("Act III · The Whispering Wood", `
${box("The barrow sits deep in the old wood, and the wood knows it. The path is there for those who can read it; off the path, the trees close ranks and things move that a year of the dead king's dreaming has made bold.")}
Scene: ${sceneLink(forest)} — daylight, thickets block sight and step, a path winds east to a clearing with the barrow mouth.
Wildcraft / Survive holds the path (a Ranger auto-succeeds; others need 2 Successes or lose an hour and draw the swarm).
Beasts: ${forestWolves.map(npcLink).join(" · ")} — a hunting pack; ${npcLink(batSwarm)} in the deep shade; ${rats.map(npcLink).join(" · ")} boiling from a rotten log.
Vanity bait: the flashy open-water shortcut back on the fen is faster and watched. Staying on the forest road is slower and safer — let pride choose.
The path ends at the clearing and the dark mouth of the barrow. Go to Act IV.
`, 300),
page("Act IV · The Barrow of King Aldric", `
${box("Cold air breathes out of the hill. Somewhere below, a sorcerer is working, and a dead boy-king is beginning to remember that he was beautiful.")}
Scene: ${sceneLink(barrow)} — rooms, doors and torchlight; token vision and fog on.
The Sealed Door
The mouth is a slab bound by a glowing arcane sigil (only Arcana, Threshold 2 — failure locks the spell and jolts everyone 2 Grit) and a mundane trapped lock beneath (only Larceny + thieves' tools — a failed roll springs a dart, a Wound). Two keys, two classes.
The Crypt of Servants
${skeletons.map(npcLink).join(" · ")} and ${ghouls.map(npcLink).join(" · ")} rise as the party crosses. The Cleric's Turn Undead sweeps the skeletons; the Ghouls' paralysing claws are the real danger, and the Warrior must hold the choke while the soft classes work.
The Peacock Throne
${npcLink(vesper)} has beaten the party here and just woken ${npcLink(aldric)} by mistake. Two-sided fight:
Vesper Kound (Necromancer, Grit 6): Firebolt, Sleep, raises fallen Skeletons; parleys or flees if losing — a live villain for later.
King Aldric, the Vain Corpse (Nemesis, Grit 14, Resolve 3): spectral blade ignores armour; Aura of Admiration — look at the crown, resist with Poise or waste your action gazing; Phase 2 at Grit 7 — the crown blazes and he attacks twice. Cap single hits at 4.
The spoils
Vesper's grave-loot (the Vault hoard rolled to chat), and — if they dare — The Peacock Crown: a permanent +1 die to Charm, an unrefusable Vice: Pride, and a standing Bane. Keeping it, destroying it, or reburying it beside the boy-king is the note the whole adventure has been playing toward.
Rewards: 2–3 Glory each. And whatever the crown costs.
`, 400)
];
const journal = await JournalEntry.create({
name: "The Vain Crown — Expanded", img: "icons/commodities/treasure/crown-gold-satin-gems-red.webp",
pages
});
// drop the crown into the barrow scene as a note/loot reminder isn't needed; announce
await village.activate();
await ChatMessage.create({
speaker: { alias: "The Vain Crown" }, whisper: ChatMessage.getWhisperRecipients("GM"),
content: `
👑 The Vain Crown is staged4 scenes · ${npcs.length + bandits.length + 1 + forestWolves.length + 1 + rats.length + skeletons.length + ghouls.length + 2} tokens placed · full journal