Fire modes, fitted sights and lamps, and a tab rail
Selectable fire, with the modifiers on the choice Every weapon has carried a fireMode since the packs were first built and nothing read it: a musket could rip a ten-round burst and "rapid" bought a weapon nothing. FIRE_MODE now gates the burst rules and carries its own attack modifier — slow +10% and then you work for the next one, fast a second shot at -20% to both, rapid bursts, beam +5% and it can be held on the target for damage instead of hits. The gate lives in burstAttack too, so no macro can route round it. `fast` was on five catalogue weapons and was not in the system's list of modes at all, so re-saving one of them silently blanked it; the list is derived from the rule now. burstAttack shipped complete and was reachable from nothing. It is the resolution path the selector chooses, and takes the dialog's whole stack rather than charging the player twice for the same burst. Sights and lamps that apply themselves A sight is graded by range band, so a telescopic sight is a -10% liability inside ten metres and worth +20% at extreme, and it only counts if it fits. The band is already known when the modifier is wanted, so nothing is selected and nothing is forgotten. The dark costs something now; a carried lamp buys it back to dim, never to daylight, and the card says it shows them exactly where you are. A migration fits gear that predates the fields, matched on the stamped key rather than by name. The damage card The last card still wearing Foundry's stock dice styling — it posted a bare Roll and let the core template dress it. It renders through the module's own card now, with the Roll still on the message so dice stay inspectable. A rail instead of a strip Eight tabs across an 820px sheet compressed to 11px type. They run down the side now, one line each, with counts as badges; the sheet is 152px wider to pay for it. Also fixed: any weapon with a Coherence cost could not be attacked with AT ALL — the handler re-read the weapon after an await, when currentTarget is already null, and the attack died on a TypeError with nothing shown. And the duplicate-rule guard failed an honest table because FIRE_MODE and speedBands share two key names; it wants a majority now, and prints partial overlaps rather than swallowing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d4442a55f0
commit
2e10ecced9
@@ -118,6 +118,10 @@ function gearItem(g) {
|
||||
powerDraw: 0, skillBonus: { skillFamilyId: g.bonus?.fam ?? "", value: g.bonus?.value ?? 0 },
|
||||
researchFacility: g.facility ?? "",
|
||||
coherenceCost: g.coherence ?? 0,
|
||||
// A sight applies itself off the range band, and a lamp off the dark. Both are
|
||||
// facts about what is in the pack rather than choices made at the trigger.
|
||||
optic: { kind: g.optic ?? "", fits: g.fits ?? "any" },
|
||||
lightSource: !!g.lit,
|
||||
isAnchor: /anchor/i.test(g.name)
|
||||
},
|
||||
effects: [], ...BASE(),
|
||||
|
||||
+53
-2
@@ -71,10 +71,22 @@ for (const name of constants) {
|
||||
if (keys.length < 2) continue;
|
||||
for (const rel of files) {
|
||||
const src = await readFile(new URL(rel, root), "utf8");
|
||||
// an object literal that carries two or more of the canonical keys is a copy
|
||||
// An object literal that carries the canonical keys is a copy. "Two or more"
|
||||
// alone was too blunt: FIRE_MODE has `slow` and `fast`, and so does the entirely
|
||||
// unrelated speedBands vocabulary, so an honest table was reported as a copy of
|
||||
// a rule it has nothing to do with. A real drift bug duplicates the WHOLE table
|
||||
// — every instance this guard has ever caught did — so require a majority of the
|
||||
// rule's keys as well. Partial overlaps are still printed, because a half-copy
|
||||
// is worth a human look even when it is not worth failing the build.
|
||||
for (const m of src.matchAll(/=\s*\{([^{}]*)\}/g)) {
|
||||
const hits = keys.filter(k => new RegExp(`\\b${k}\\s*:`).test(m[1]));
|
||||
if (hits.length >= 2) fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`);
|
||||
if (hits.length < 2) continue;
|
||||
if (hits.length * 2 >= keys.length) {
|
||||
fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`);
|
||||
} else {
|
||||
console.log(` note ${rel} shares ${hits.length}/${keys.length} key names with `
|
||||
+ `${name} (${hits.join(", ")}) — read as coincidence, not a copy`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +256,45 @@ const checks = [
|
||||
["burstHitsFor(success, short)", RULES.burstHitsFor("success","short"), 1],
|
||||
["burstHitsFor(failure, long)", RULES.burstHitsFor("failure","long"), 0],
|
||||
["burstRoundsSpent(long, 4 loaded)", RULES.burstRoundsSpent("long",4), 4],
|
||||
// The mode is the gate. A musket may not rip a burst however the API is called.
|
||||
["burstSizesFor(slow) is empty", RULES.burstSizesFor("slow").length, 0],
|
||||
["burstSizesFor(rapid)", RULES.burstSizesFor("rapid").join(","), "short,long,full"],
|
||||
["fireModeFor(fast) is a real mode", RULES.fireModeFor("fast").id, "fast"],
|
||||
["fireModeFor(blank) falls to single", RULES.fireModeFor("").id, "single"],
|
||||
["fireModeAttackFor(slow)", RULES.fireModeAttackFor("slow"), 10],
|
||||
["fireModeAttackFor(beam)", RULES.fireModeAttackFor("beam"), 5],
|
||||
["fireModeAttackFor(rapid)", RULES.fireModeAttackFor("rapid"), 0],
|
||||
// A second shot costs BOTH shots, and only a fast action offers one at all.
|
||||
["secondShotPenaltyFor(fast)", RULES.secondShotPenaltyFor("fast"), -20],
|
||||
["secondShotPenaltyFor(slow) is none", RULES.secondShotPenaltyFor("slow"), null],
|
||||
// A beam buys damage, not hits — the one firing choice that adds dice.
|
||||
["sustainFor(lock).mod", RULES.sustainFor("lock").mod, -25],
|
||||
["sustainDamageFor(hold)", RULES.sustainDamageFor("hold"), "1d6"],
|
||||
["sustainDamageFor(tap) is none", RULES.sustainDamageFor("tap"), ""],
|
||||
// Counted in SHOTS, so a 20-shot cell affords three locked beams — not zero.
|
||||
["sustainChargeFor(lock) in shots", RULES.sustainChargeFor("lock"), 6],
|
||||
["sustainChargeFor(hold) in shots", RULES.sustainChargeFor("hold"), 3],
|
||||
["sustainChargeFor(tap) is one shot", RULES.sustainChargeFor("tap"), 1],
|
||||
// A scope is a LIABILITY up close. That is the trade that makes sights a choice.
|
||||
["sightBonusFor(telescopic, short)", RULES.sightBonusFor("telescopic","short"), -10],
|
||||
["sightBonusFor(telescopic, extreme)", RULES.sightBonusFor("telescopic","extreme"), 20],
|
||||
["sightBonusFor(reflex, short)", RULES.sightBonusFor("reflex","short"), 10],
|
||||
["sightBonusFor(reflex, extreme)", RULES.sightBonusFor("reflex","extreme"), -10],
|
||||
["sightBonusFor(none, long)", RULES.sightBonusFor("none","long"), 0],
|
||||
["sightFor(nonsense) is none", RULES.sightFor("wat").id, "none"],
|
||||
["sightSeesInDark(thermal)", RULES.sightSeesInDark("thermal"), true],
|
||||
["sightSeesInDark(telescopic)", RULES.sightSeesInDark("telescopic"), false],
|
||||
// A lamp buys the dark back to dim, never to daylight, and it shows you.
|
||||
["lightingFor(pitch).mod", RULES.lightingFor("pitch").mod, -50],
|
||||
["lightingModFor(pitch, no lamp)", RULES.lightingModFor("pitch").mod, -50],
|
||||
["lightingModFor(pitch, lamp)", RULES.lightingModFor("pitch",{lightSource:true}).mod, -10],
|
||||
["lightingModFor(pitch, lamp) reveals", RULES.lightingModFor("pitch",{lightSource:true}).revealed, true],
|
||||
// A lamp in a lit room is not a penalty and does not reveal anything new.
|
||||
["lightingModFor(lit, lamp)", RULES.lightingModFor("lit",{lightSource:true}).mod, 0],
|
||||
["lightingModFor(lit, lamp) quiet", RULES.lightingModFor("lit",{lightSource:true}).revealed, false],
|
||||
// Thermal beats a lamp: it removes the dark AND does not announce you.
|
||||
["lightingModFor(dark, thermal)", RULES.lightingModFor("dark",{seesInDark:true}).mod, 0],
|
||||
["lightingModFor(dark, thermal) quiet", RULES.lightingModFor("dark",{seesInDark:true}).revealed, false],
|
||||
// two weapons
|
||||
["twoWeaponPenaltyFor() off hand", RULES.twoWeaponPenaltyFor().off, -40],
|
||||
["twoWeaponPenaltyFor(trained) main", RULES.twoWeaponPenaltyFor({trained:true}).main, -20],
|
||||
|
||||
+16
-1
@@ -316,7 +316,7 @@ export const GEAR = [
|
||||
{ key: "anchor", name: "Personal anchor", mass: 0.1, desc: "Something that remembers you as you were. Restores 1 Coherence per period of downtime spent with it." },
|
||||
{ key: "key", name: "Crossing key", mass: 0.4, trad: "tradition_transposed", coherence: 1, desc: "Opens a path without a scheduled window. Costs 1 Coherence each time it is used." },
|
||||
/* --- standard agency issue: what every case draws from stores --- */
|
||||
{ key: "torch", name: "Field lamp", mass: 0.5, desc: "Sealed, sixty hours. Nothing on the far side is reliably lit." },
|
||||
{ key: "torch", name: "Field lamp", mass: 0.5, lit: true, desc: "Sealed, sixty hours. Nothing on the far side is reliably lit. Buys the dark back to dim, and tells the dark where you are." },
|
||||
{ key: "rope", name: "Line and harness", mass: 3.5, bonus: { fam: "athletics", value: 20 }, desc: "+20% Athletics for any climb or descent." },
|
||||
{ key: "rations", name: "Ration pack (3 days)", mass: 2.4, desc: "Restores 1 Resource to the case when shared by the whole team." },
|
||||
{ key: "water", name: "Filter flask", mass: 0.9, desc: "Makes almost anything drinkable. Almost." },
|
||||
@@ -381,6 +381,21 @@ export const GEAR = [
|
||||
{ key: "cordon_kit", name: "Cordon tape and signs", mass: 2.2, desc: "Two hundred metres of tape and four signs citing an Act that does not exist." },
|
||||
{ key: "clipboard", name: "Clipboard", mass: 0.4, desc: "Nobody stops a person carrying a clipboard. This is not a joke and is in the manual." },
|
||||
|
||||
/* --- fitted to the weapon: these apply themselves off the range band --- *
|
||||
* A sight is not a flat bonus. It is ground for a distance, and it is a liability
|
||||
* at the others — which is why the telescopic sight costs you inside ten metres
|
||||
* and why nobody sensibly carries all four. */
|
||||
{ key: "sight_irons", name: "Aperture sights", mass: 0.2, optic: "irons", fits: "any",
|
||||
desc: "+5% short and medium. Iron, unbreakable, and no help at all past a hundred metres." },
|
||||
{ key: "sight_reflex", name: "Reflex sight", mass: 0.3, optic: "reflex", fits: "any",
|
||||
desc: "+10% short, +5% medium, nothing long, -10% extreme. Both eyes open, for rooms and corridors." },
|
||||
{ key: "sight_scope", name: "Telescopic sight", mass: 0.7, optic: "telescopic", fits: "rifle",
|
||||
desc: "-10% short, +5% medium, +15% long, +20% extreme. Rifles only. The penalty up close is the whole point of it." },
|
||||
{ key: "sight_thermal", name: "Thermal sight", mass: 1.1, optic: "thermal", fits: "rifle", era: "future",
|
||||
desc: "+5% out to long range, and the dark stops mattering — without a lamp to announce you. Rifles only." },
|
||||
{ key: "weapon_lamp", name: "Weapon lamp", mass: 0.2, lit: true,
|
||||
desc: "Clamps under the barrel. Buys the dark back to dim, and points at whatever you are pointing at." },
|
||||
|
||||
/* --- looking at things --- */
|
||||
{ key: "uv_lamp", name: "Ultraviolet lamp", mass: 0.6, desc: "Shows what has been cleaned up and what has been written on the walls in the meantime." },
|
||||
{ key: "signal_mirror", name: "Signalling mirror", mass: 0.1, desc: "For line of sight without a signal. Also shows you what is behind you, which is the more common use." },
|
||||
|
||||
@@ -111,6 +111,11 @@ export const ICON_MAP = {
|
||||
|
||||
/* ---- future stores ---- */
|
||||
wound_foam:["kit","gear",-16,"future"], cold_light:["bolt","gear",-20,"future"],
|
||||
// Fitted to the weapon. Lenses for the three optical sights, so they read as a set;
|
||||
// the thermal one is a future-era lens and the lamp is a bolt like the other lights.
|
||||
sight_irons:["bar","gear",26], sight_reflex:["lens","gear",8],
|
||||
sight_scope:["lens","gear",-8], sight_thermal:["lens","gear",44,"future"],
|
||||
weapon_lamp:["bolt","gear",12],
|
||||
field_slate:["chip","gear",24,"future"], locator_tags:["target","gear",-24,"future"],
|
||||
density_scanner:["scanner","gear",28,"future"], seal_foam:["cannister","gear",-28,"future"],
|
||||
assist_sleeve:["hand","gear",32,"future"],
|
||||
|
||||
+47
-1
@@ -22,6 +22,7 @@ import {
|
||||
DYING, dyingLimitFor, LAST_ENTRY,
|
||||
FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, BLAST, BLAST_COVER,
|
||||
BURST, burstHitsFor, TWO_WEAPON, twoWeaponPenaltyFor,
|
||||
FIRE_MODE, SECOND_SHOT, SUSTAIN, SIGHT, LIGHTING, LIGHT_FLOOR,
|
||||
STATIONS, SPACE_BANDS, SPACE_RANGE_MOD, HEAT, heatEffectsFor, spaceHitFor,
|
||||
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
|
||||
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
|
||||
@@ -329,7 +330,52 @@ ${table(["Cover", "What gets through"],
|
||||
Object.entries(BLAST_COVER).map(([k, v]) => [k, v === 0 ? "nothing" : `${Math.round(v * 100)}%`]))}
|
||||
` },
|
||||
|
||||
{ name: "2c · Automatic fire, and two weapons", content: `
|
||||
{ name: "2c · How a weapon fires, and what is bolted to it", content: `
|
||||
<h2>What a weapon can do with a round</h2>
|
||||
<p>Every weapon has a <strong>fire mode</strong>, and it is a decision rather than a
|
||||
drawback. The trade runs one way: the faster a weapon puts rounds out, the less each
|
||||
individual shot is worth, and the slower it is, the more the one shot you get is worth
|
||||
taking properly.</p>
|
||||
${table(["Mode", "To hit", "What it offers"],
|
||||
Object.values(FIRE_MODE).map(m => [L(m.label), pc(m.attack),
|
||||
m.burst.length ? "bursts"
|
||||
: m.sustain ? "the beam can be held on the target"
|
||||
: m.second ? `a second shot, at ${pc(SECOND_SHOT)} to both`
|
||||
: m.reloads ? "one shot, then work"
|
||||
: "one aimed shot a round"]))}
|
||||
<p>A weapon that cannot fire a burst <strong>cannot be made to</strong>. A muzzle-loader
|
||||
has no automatic setting however the button is pressed.</p>
|
||||
|
||||
<h2>Holding a beam</h2>
|
||||
<p>A beam does not fire discrete rounds, so counting hits is the wrong shape for it.
|
||||
What a beam can do instead is stay on the target, and that buys <strong>damage</strong>
|
||||
— the one firing choice in this game that adds dice rather than adding rolls. It is
|
||||
paid for twice, in accuracy and in charge.</p>
|
||||
${table(["", "To hit", "Damage", "Charge"],
|
||||
Object.values(SUSTAIN).map(s => [L(s.label), pc(s.mod),
|
||||
s.bonus ? `+${s.bonus}` : "—", `×${s.charge}`]))}
|
||||
|
||||
<h2>Sights</h2>
|
||||
<p>A sight is not a flat bonus, which is why most games end up with everybody carrying
|
||||
the same scope. A sight is ground for a distance and is a <strong>liability at the
|
||||
others</strong>. You never choose to apply it: the range band is already known by the
|
||||
time the modifier is wanted, so a carried sight applies itself.</p>
|
||||
${table(["Sight", "Short", "Medium", "Long", "Extreme"],
|
||||
Object.values(SIGHT).filter(s => s.id !== "none").map(s => [L(s.label),
|
||||
pc(s.bands.short), pc(s.bands.medium), pc(s.bands.long), pc(s.bands.extreme)]))}
|
||||
<p>The telescopic sight is a <strong>penalty inside ten metres</strong>. That is the
|
||||
whole point of it, and it is why nobody sensibly carries all four. A sight only counts
|
||||
if it <em>fits</em> — the scope and the thermal sight are rifle furniture and do nothing
|
||||
bolted to a service pistol.</p>
|
||||
|
||||
<h2>The dark</h2>
|
||||
<p>Most crossings do not come with the lights on.</p>
|
||||
${table(["", "To hit"], Object.values(LIGHTING).map(l => [L(l.label), pc(l.mod)]))}
|
||||
<p>A carried lamp buys the dark back to <strong>${L(LIGHTING[LIGHT_FLOOR].label)}</strong>
|
||||
and never better — and it is a position. The card will say so. A thermal sight removes
|
||||
the dark outright <em>and</em> does not announce you, which is most of what you are
|
||||
paying its weight for.</p>
|
||||
|
||||
<h2>Automatic fire</h2>
|
||||
<p>One roll, not four. The <strong>success level</strong> says how much of the burst
|
||||
arrived, and <strong>the rounds are gone either way</strong> — that is the honest part
|
||||
|
||||
Reference in New Issue
Block a user