What was on git could not be installed. foundry-module/ referenced core/ rather than containing it, so an installed copy had no generator at all — it only ever worked here because the live module directory had core/ copied in by hand. core/ is now vendored into foundry-module/module/core/, minus cli.mjs and test.mjs which are desk tools. module.json points manifest and download at release assets, the way mapwright distributes, so a remote server can install by URL without reaching into the repo tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.2 KiB
JavaScript
129 lines
5.2 KiB
JavaScript
/**
|
|
* The director: decides, never generates. Draft 5 §4.
|
|
*
|
|
* Emits semantic instructions — "area 3 is a complication, heat=fight, hoard=cache, and it owes
|
|
* the tab a Bane" — which the Foundry adapter executes against the Forge. The core never sees an
|
|
* Actor, a UUID or a die.
|
|
*
|
|
* Two playtest findings are baked in:
|
|
* - Deadliness must not also change how *often* fights appear (draft 4 double-dipped). Severity
|
|
* and density are separate dials.
|
|
* - Every area needs a fallback route, not just a gate. Playtest 1 died because the good ending
|
|
* was gated on a threshold the table could fail without knowing.
|
|
*/
|
|
|
|
export const HEATS = ['skirmish', 'fight', 'battle', 'nightmare'];
|
|
export const HOARDS = ['pocket', 'cache', 'chest', 'vault', 'kingly'];
|
|
|
|
/** The Forge's own ladder (vanity.mjs HEAT_HOARD). */
|
|
const HEAT_TO_HOARD = { skirmish: 'pocket', fight: 'cache', battle: 'chest', nightmare: 'vault' };
|
|
|
|
const DEADLINESS_SHIFT = { forgiving: -1, standard: 0, cruel: +1 };
|
|
const GREED_SHIFT = { lean: -1, standard: 0, glutted: +1 };
|
|
|
|
/**
|
|
* Fraction of areas carrying a planned encounter.
|
|
* Standard ≈ 2 fights in 6 areas — playtest 2 ran that and it worked; playtest 1's ~60% was far
|
|
* too many and the session overran with the finale unresolved.
|
|
*/
|
|
const DENSITY = { sparse: 0.20, standard: 0.34, infested: 0.55 };
|
|
|
|
const clamp = (i, arr) => Math.max(0, Math.min(arr.length - 1, i));
|
|
|
|
/**
|
|
* The arc. Areas are assigned roles by position so a delve has a shape rather than a flat run of
|
|
* rooms. Proportional, so it works at 3 areas or 12.
|
|
*/
|
|
export function arcRole(index, total) {
|
|
const t = total <= 1 ? 1 : (index - 1) / (total - 1); // 0..1
|
|
if (t < 0.20) return 'approach';
|
|
if (t < 0.45) return 'complication';
|
|
if (t < 0.65) return 'turn';
|
|
if (t < 0.85) return 'descent';
|
|
return 'threshold';
|
|
}
|
|
|
|
/** Roles that should stay quiet. An empty area is what makes an occupied one land. */
|
|
const QUIET_ROLES = new Set(['approach']);
|
|
|
|
export function baseHeatIndex({ depth = 2, deadliness = 'standard', party = 4 }) {
|
|
let i = Math.round((depth - 1) * (HEATS.length - 1) / 4); // depth 1..5 → 0..3
|
|
i += DEADLINESS_SHIFT[deadliness] ?? 0;
|
|
if (party <= 2) i -= 1;
|
|
if (party >= 7) i += 1;
|
|
return clamp(i, HEATS);
|
|
}
|
|
|
|
/**
|
|
* Plan the whole delve up front — the skeleton is cheap and must be coherent. Areas are
|
|
* *materialised* lazily elsewhere; this is only the decision layer.
|
|
*/
|
|
export function planDelve(params = {}) {
|
|
const {
|
|
areas = 6, depth = 2, party = 4,
|
|
deadliness = 'standard', density = 'standard', greed = 'standard',
|
|
ending = 'authored', rng,
|
|
} = params;
|
|
|
|
if (!rng) throw new Error('planDelve requires an Rng (seeded)');
|
|
|
|
const heatIdx = baseHeatIndex({ depth, deadliness, party });
|
|
const wantFights = Math.max(1, Math.round(areas * (DENSITY[density] ?? DENSITY.standard)));
|
|
|
|
// Choose which areas carry fights. Never the first area; prefer complication/turn/descent.
|
|
const plan = [];
|
|
const candidates = [];
|
|
for (let i = 1; i <= areas; i++) {
|
|
const role = arcRole(i, areas);
|
|
plan.push({ index: i, role, heat: null, hoard: null, quiet: QUIET_ROLES.has(role) });
|
|
if (i > 1 && !QUIET_ROLES.has(role)) candidates.push(i);
|
|
}
|
|
|
|
const chosen = new Set(rng.derive('density').pickN(candidates, Math.min(wantFights, candidates.length)));
|
|
// The area just before the ending always carries something — the threshold should have teeth.
|
|
if (areas >= 3) chosen.add(areas);
|
|
|
|
for (const a of plan) {
|
|
if (!chosen.has(a.index)) continue;
|
|
// Escalate along the arc: later areas run hotter, within the band the dials allow.
|
|
const lift = a.role === 'threshold' ? 1 : a.role === 'descent' ? 1 : 0;
|
|
a.heat = HEATS[clamp(heatIdx + lift, HEATS)];
|
|
const hoardBase = HOARDS.indexOf(HEAT_TO_HOARD[a.heat] ?? 'cache');
|
|
a.hoard = HOARDS[clamp(hoardBase + (GREED_SHIFT[greed] ?? 0), HOARDS)];
|
|
}
|
|
|
|
// Treasure is not only behind fights: lean delves still need something to want.
|
|
const treasureless = plan.filter(a => !a.hoard);
|
|
if (greed !== 'lean' && treasureless.length) {
|
|
const bonus = rng.derive('greed').pick(treasureless.filter(a => a.role !== 'approach')) ?? treasureless[0];
|
|
if (bonus) bonus.hoard = HOARDS[clamp(HOARDS.indexOf('pocket') + (GREED_SHIFT[greed] ?? 0), HOARDS)];
|
|
}
|
|
|
|
return {
|
|
areas: plan,
|
|
ending: {
|
|
index: areas + 1,
|
|
role: 'ending',
|
|
mode: ending,
|
|
// Draft 5 §2: authored is a default, not a law. Generated endings get a real heat.
|
|
heat: ending === 'generated' ? HEATS[clamp(heatIdx + 1, HEATS)] : null,
|
|
hoard: ending === 'generated' ? HOARDS[clamp(HOARDS.indexOf('vault') + (GREED_SHIFT[greed] ?? 0), HOARDS)] : null,
|
|
},
|
|
budget: { plannedFights: chosen.size, heatBase: HEATS[heatIdx], wantFights },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Which Bane-banking beat an area should offer, given how the tab is tracking.
|
|
* Playtest 1: the tab reached 1 of 6 because nothing emitted Banes deliberately.
|
|
*/
|
|
export function baneBeatFor(area, pressure, rng) {
|
|
const pace = pressure.onPace(area.index);
|
|
const pool = area.quiet
|
|
? ['noticing', 'announcing']
|
|
: ['flattery', 'noticing', 'relicUse', 'refusal'];
|
|
// Behind pace → always offer one. On pace → sometimes, so it does not feel mechanical.
|
|
if (!pace.behind && !rng.chance(0.5)) return null;
|
|
return rng.pick(pool);
|
|
}
|