@maka/maka-cli 5.392.0 → 5.393.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +1 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +2 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/time.js +8 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +11 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/factories/authored-run.js +12 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +1 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +15 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/game.js +3 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +3 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +47 -4
  13. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +17 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +17 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +14 -4
  16. package/bundle/typescript/src/commands/game/sideQuest/utilities/door-watch.js +2 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/environment.js +194 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +6 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/perception.js +3 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +2 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/wards.js +15 -3
  22. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.392.0",
3
+ "version": "5.393.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -68,7 +68,7 @@ export class AssenseCommand extends Command {
68
68
  return `${target.name} has no aura -- on the astral it is a featureless grey shadow of its frame, neither living nor magical (p.313). Nothing to read.`;
69
69
  }
70
70
  const skill = actor.skillRating('assensing');
71
- const pool = Math.max(1, actor.intuition + (skill > 0 ? skill : -1) + actor.woundModifier - actor.sustainingPenalty);
71
+ const pool = Math.max(1, actor.intuition + (skill > 0 ? skill : -1) + actor.woundModifier - actor.sustainingPenalty - actor.backgroundCountPenalty);
72
72
  const roll = rollPool(pool);
73
73
  const lines = [`You read ${target.name}'s aura -- Assensing: ${formatRoll(roll)}`];
74
74
  if (roll.hits === 0) {
@@ -41,7 +41,7 @@ export class DispelCommand extends CastCommand {
41
41
  static description = 'Unweave a SUSTAINED SPELL -- Magic + counterspelling, opposed by its caster\'s grip: "dispel <name>", "dispel invisibility on <name>", "dispel barrier". No backlash; retry freely. A ward is not dispelled -- press through it or attack it from the astral.';
42
42
  dispelPool() {
43
43
  const skill = this.actor.skillRating('counterspelling');
44
- return Math.max(1, this.actor.magic + (skill > 0 ? skill : -1) + this.actor.woundModifier - this.actor.sustainingPenalty);
44
+ return Math.max(1, this.actor.magic + (skill > 0 ? skill : -1) + this.actor.woundModifier - this.actor.sustainingPenalty - this.actor.backgroundCountPenalty);
45
45
  }
46
46
  async execute(args = []) {
47
47
  // The counter-weave is still an incantation -- it carries (sneak ends),
@@ -57,7 +57,8 @@ export class SummonCommand extends Command {
57
57
  return bill;
58
58
  // The opposed test (p.300): Magic + conjuring vs the spirit's Force.
59
59
  const conjuring = actor.skillRating('conjuring');
60
- const mine = rollPool(Math.max(1, actor.magic + (conjuring > 0 ? conjuring : -1) + actor.woundModifier - actor.sustainingPenalty));
60
+ // The background count bites every magic-linked test (SG p.32).
61
+ const mine = rollPool(Math.max(1, actor.magic + (conjuring > 0 ? conjuring : -1) + actor.woundModifier - actor.sustainingPenalty - actor.backgroundCountPenalty));
61
62
  const spirit = rollPool(force);
62
63
  const services = Math.max(0, mine.hits - spirit.hits);
63
64
  // Drain: 2x the SPIRIT's hits, min 2 (p.300), resisted with
@@ -19,7 +19,10 @@ export class TimeCommand extends Command {
19
19
  static verb = 'time';
20
20
  static description = 'Check the clock: the time of day, and whether you are in the small hours yet. In a shared run everyone reads the host\'s clock, so "after midnight" means the same moment for the whole team.';
21
21
  async execute(_args = []) {
22
- const now = new Date();
22
+ // A RUN WITH A DISTRICT START TIME keeps its own hour (environment.ts,
23
+ // Scene.worldTime): 23:30 when it started, plus the game clock since.
24
+ const now = this.scene.worldTime?.() ?? new Date();
25
+ const gameTime = this.scene.district?.startTime !== undefined;
23
26
  // Asked ENGINE-SIDE, which is the only place that can answer it: in
24
27
  // a shared run this command executes on the server, where
25
28
  // `remoteSession` (the CLIENT's link to that server) is undefined.
@@ -55,6 +58,10 @@ export class TimeCommand extends Command {
55
58
  const wait = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
56
59
  lines.push(` Not the small hours yet -- midnight is ${wait} out.`);
57
60
  }
61
+ if (gameTime) {
62
+ lines.push(` {white-fg}This job keeps its own hour -- it started at ${this.scene.district.startTime}, and the clock runs as the night does.{/white-fg}`);
63
+ return lines.filter(l => l.length > 0).join('\n');
64
+ }
58
65
  lines.push(shared
59
66
  // Both lines name the zone, because the whole point is that this
60
67
  // is NOT your machine's clock. It used to say "runs on this
@@ -3415,5 +3415,15 @@
3415
3415
  // press through on Magic + Charisma [Astral] v. Force x 2, "attack ward"
3416
3416
  // from the astral; the ward device kind is gone and legacy ones migrate
3417
3417
  // onto their room.
3418
- export const ENGINE_VERSION = '1.204.0';
3418
+ // 1.205.0 (2026-10-07): THE DISTRICT. A seed's `district` sets the map's
3419
+ // conditions and a room's `conditions` override them (utilities/
3420
+ // environment.ts): a start time the game clock runs from (crowds and
3421
+ // `time` read it); weather, wind and daylight outdoors as p.173-176
3422
+ // environmental modifiers -- worst wins, ties bump, two at -6 make -10,
3423
+ // range now combines instead of stacking, vision compensates -- on
3424
+ // attacks and sight-based perception; background count (SG p.30-32) on
3425
+ // every magic pool, wards and sustained spells reduced by it; spam and
3426
+ // static zones (p.231) as noise on every Matrix action. An authored
3427
+ // localGrid survives a hub ride-out.
3428
+ export const ENGINE_VERSION = '1.205.0';
3419
3429
  //# sourceMappingURL=engine-version.js.map
@@ -1,3 +1,4 @@
1
+ import { districtFaults } from '../utilities/environment.js';
1
2
  import { wallFixtureKindOf } from '../utilities/room-grid.js';
2
3
  import { roomGridWire } from '../utilities/ways-out.js';
3
4
  import { icTypeNamed, IC_TYPES, resolveICOrder } from '../utilities/ic.js';
@@ -486,6 +487,15 @@ export function authoredSpiderFaults(seed, built) {
486
487
  }
487
488
  return faults;
488
489
  }
490
+ /**
491
+ * THE DISTRICT'S CONDITIONS (utilities/environment.ts, 2026-10-07): the
492
+ * start time, weather, wind, light, background count and Matrix zone, and
493
+ * any room's own -- each a value canon names, nothing else.
494
+ */
495
+ export function authoredDistrictFaults(seed) {
496
+ return districtFaults(seed.district, (seed.rooms ?? []).map(r => ({ name: r.name, conditions: r.conditions })))
497
+ .map(message => ({ check: 'district', message, rooms: (seed.rooms ?? []).filter(r => message.includes(`"${r.name}"`)).map(r => r.name) }));
498
+ }
489
499
  /**
490
500
  * WARDS ARE A ROOM'S (user 2026-10-06; SR5 p.297-298, p.315-316): a
491
501
  * warded room carries the ward's Force -- what pressing through and
@@ -558,7 +568,7 @@ export function authoredGroundFaults(seed) {
558
568
  }
559
569
  export async function validateAuthoredRun(seed) {
560
570
  const { faults, built } = await SceneSeedGenerator.auditSeed(prepareAuthoredSeed(seed).seed);
561
- return [...authoredGroundFaults(seed), ...authoredItemFaults(seed), ...authoredSpotFaults(seed), ...authoredNpcFaults(seed), ...authoredSecurityFaults(seed), ...authoredDoorwayFaults(seed, built), ...authoredDoorstepFaults(seed, built), ...authoredSpiderFaults(seed, built), ...authoredWardFaults(seed, built), ...faults.map(f => ({ ...f, rooms: roomsNamed(f.message, seed) }))];
571
+ return [...authoredGroundFaults(seed), ...authoredItemFaults(seed), ...authoredSpotFaults(seed), ...authoredNpcFaults(seed), ...authoredSecurityFaults(seed), ...authoredDoorwayFaults(seed, built), ...authoredDoorstepFaults(seed, built), ...authoredSpiderFaults(seed, built), ...authoredWardFaults(seed, built), ...authoredDistrictFaults(seed), ...faults.map(f => ({ ...f, rooms: roomsNamed(f.message, seed) }))];
562
572
  }
563
573
  export function matrixWire(built) {
564
574
  const rooms = built.getRooms();
@@ -610,7 +620,7 @@ export async function previewAuthoredRun(seed, heads = 1) {
610
620
  rooms[room.name] = wire;
611
621
  }
612
622
  return {
613
- faults: [...authoredGroundFaults(seed), ...authoredItemFaults(seed), ...authoredSpotFaults(seed), ...authoredNpcFaults(seed), ...authoredSecurityFaults(seed), ...authoredDoorwayFaults(seed, built), ...authoredDoorstepFaults(seed, built), ...authoredSpiderFaults(seed, built), ...authoredWardFaults(seed, built), ...faults.map(f => ({ ...f, rooms: roomsNamed(f.message, seed) }))],
623
+ faults: [...authoredGroundFaults(seed), ...authoredItemFaults(seed), ...authoredSpotFaults(seed), ...authoredNpcFaults(seed), ...authoredSecurityFaults(seed), ...authoredDoorwayFaults(seed, built), ...authoredDoorstepFaults(seed, built), ...authoredSpiderFaults(seed, built), ...authoredWardFaults(seed, built), ...authoredDistrictFaults(seed), ...faults.map(f => ({ ...f, rooms: roomsNamed(f.message, seed) }))],
614
624
  rooms,
615
625
  ...(built ? { matrix: matrixWire(built) } : {}),
616
626
  payout: prepared.seed.winCondition?.payout,
@@ -50,6 +50,7 @@ export class RoomFactory {
50
50
  camerasSlavedTo: roomJson.camerasSlavedTo,
51
51
  // The crowd (utilities/crowd.ts) -- named here or this whitelist eats it.
52
52
  occupancy: roomJson.occupancy,
53
+ conditions: roomJson.conditions,
53
54
  // "At" spots ride through -- this whitelist SILENTLY DROPS any
54
55
  // field not named here (it already ate `owner` once; see the
55
56
  // scene-factory patch that re-applies it).
@@ -1,3 +1,5 @@
1
+ import { startSecondsOf } from '../utilities/environment.js';
2
+ import { partOfDay } from '../utilities/world-clock.js';
1
3
  import { Scene } from '../models/scene.js';
2
4
  import { localGrid } from '../utilities/grids.js';
3
5
  import { ensureJackPoint } from '../utilities/jackpoint.js';
@@ -172,6 +174,12 @@ export class SceneSynthesizer {
172
174
  scene.neighborhood = json.neighborhood;
173
175
  if (typeof json.lawContract === 'string' && json.lawContract)
174
176
  scene.lawContract = json.lawContract;
177
+ // THE DISTRICT (environment.ts): the map's conditions, and the hour the
178
+ // run starts at. Rooms built so far took their crowd off the wall clock;
179
+ // with a start time they are re-derived for the run's own hour below,
180
+ // once every room exists.
181
+ if (json.district && typeof json.district === 'object')
182
+ scene.district = json.district;
175
183
  // Shared context handed to every ensureAI*() call below, so room
176
184
  // descriptions, NPC appearances, and puzzle riddles all stay
177
185
  // consistent with the same scene tone.
@@ -195,6 +203,13 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
195
203
  scene.addRoom(room);
196
204
  builtRooms.push(room);
197
205
  }
206
+ // THE RUN'S OWN HOUR (environment.ts): a district start time sets the
207
+ // crowds for that part of the day, not the wall clock's.
208
+ if (startSecondsOf(scene.district) !== undefined) {
209
+ const when = partOfDay(scene.worldTime());
210
+ for (const room of builtRooms)
211
+ room.rederiveCrowd(when);
212
+ }
198
213
  beat(`rooms:${builtRooms.length}`);
199
214
  if (useAi) {
200
215
  await enrichConcurrently(builtRooms, r => r.ensureAIDescription(sceneContext), (done, total) => beat(`rooms-dressed:${done}/${total}`));
@@ -8072,8 +8072,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
8072
8072
  // forward), and no blocking AI-redressing pass either -- the chunked
8073
8073
  // rooms pass already wrote this scene's prose.
8074
8074
  this.scene = await SceneSynthesizer.synthesizeFromJson(seed, this.player, false, false);
8075
- // A run happens in the same metroplex: it rides the hub's local grid.
8076
- if (this._hubScene)
8075
+ // A run happens in the same metroplex: it rides the hub's local grid --
8076
+ // unless its seed names its own (a player-built run's District tab).
8077
+ if (this._hubScene && !seed.localGrid)
8077
8078
  this.scene.localGrid = this._hubScene.localGrid;
8078
8079
  this.scene.initialize(this);
8079
8080
  this.resolveHostGrids();
@@ -1,3 +1,4 @@
1
+ import { sightModifier } from '../utilities/environment.js';
1
2
  import { registerNotice, registerProvocation, PROVOCATION } from '../utilities/hostile-contact.js';
2
3
  import { hint } from '../utilities/hints.js';
3
4
  import { npcSilenceNote } from '../utilities/draft-failure-note.js';
@@ -1539,7 +1540,8 @@ description text.`;
1539
1540
  // pool.
1540
1541
  const sneak = rollPool(Math.max(1, actor.agility + (theirSkill > 0 ? theirSkill : -1) + actor.bonus('sneak') + actor.woundModifier));
1541
1542
  const mySkill = this.skillRating('perception');
1542
- const spot = rollPool(Math.max(1, this.intuition + (mySkill > 0 ? mySkill : -1) + this.woundModifier));
1543
+ // The watcher's side carries Visibility and Light (p.136, p.135).
1544
+ const spot = rollPool(Math.max(1, this.intuition + (mySkill > 0 ? mySkill : -1) + sightModifier(this, this.currentLocation, actor.currentLocation) + this.woundModifier));
1543
1545
  this.logger.write(`[SNEAK] ${actor.name} ${sneak.hits} vs ${this.name} ${spot.hits}.`);
1544
1546
  // The dice are PLAYER-VISIBLE either way, like every other contest
1545
1547
  // in the game -- a real session's sneaks read as "nothing
@@ -1,3 +1,4 @@
1
+ import { backgroundPenalty, zoneNoise } from '../utilities/environment.js';
1
2
  import { carryMode, carryPhrase } from '../utilities/carry.js';
2
3
  import { getSoft, getProgram } from '../utilities/programs.js';
3
4
  import { GEAR_LANES, CHROME_LANES, QUALITY_LANES, ART_LANES, ADEPT_LANES, scaleOf } from '../utilities/effects.js';
@@ -1144,7 +1145,19 @@ export class Player extends AbstractPlayer {
1144
1145
  * nothing but some dropped calls.
1145
1146
  */
1146
1147
  get matrixActionPenalty() {
1147
- return this.runningSilent ? -2 : 0;
1148
+ return (this.runningSilent ? -2 : 0) - this.matrixZoneNoise;
1149
+ }
1150
+ /**
1151
+ * A SPAM OR STATIC ZONE where this persona is (p.230-231; the district's
1152
+ * and room's conditions, utilities/environment.ts): its rating as noise
1153
+ * on every Matrix action. Inside a host there is no zone -- p.235 gives a
1154
+ * host no physical place for one to be.
1155
+ */
1156
+ get matrixZoneNoise() {
1157
+ if (this.insideHost)
1158
+ return 0;
1159
+ const room = this.bodyRoom ?? this.currentLocation;
1160
+ return room ? zoneNoise(room.scene, room) : 0;
1148
1161
  }
1149
1162
  /**
1150
1163
  * THIS PERSONA'S MATRIX ATTRIBUTE (p.226 for a deck's array, p.251
@@ -3096,6 +3109,7 @@ export class Player extends AbstractPlayer {
3096
3109
  this.leaveRoom();
3097
3110
  this.enterRoom(room);
3098
3111
  this._currentLocation = room;
3112
+ this.fizzleUnderBackground();
3099
3113
  // The live sheet's "where" (2026-09-01): a room change reports
3100
3114
  // through the same hook the condition mutators fire -- position
3101
3115
  // rides every report, and this is the choke point every movement
@@ -4326,7 +4340,7 @@ export class Player extends AbstractPlayer {
4326
4340
  // astral with unarmed. That is a migration question, not a rules
4327
4341
  // one, and it is not this item's to answer.
4328
4342
  const astralSkill = Math.max(this.skillRating('unarmed'), this.skillRating('astral-combat'));
4329
- return Math.max(0, this.willpower + astralSkill + this.woundModifier - this.sustainingPenalty);
4343
+ return Math.max(0, this.willpower + astralSkill + this.woundModifier - this.sustainingPenalty - this.backgroundCountPenalty);
4330
4344
  }
4331
4345
  // An adept's magic lives in their hands. Compressed model: half their
4332
4346
  // Power Points ride every strike. Chosen powers: Improved Ability
@@ -4417,7 +4431,8 @@ export class Player extends AbstractPlayer {
4417
4431
  return Math.max(0, this.intuition + this.matrixAttribute('firewall') + hot + this.bonus('matrix') + this.formDefenseBonus + this.woundModifier - this.sustainingPenalty);
4418
4432
  }
4419
4433
  if (this.plane === 'astral') {
4420
- return Math.max(0, this.intuition + Math.floor(this.magic / 2) + this.woundModifier - this.sustainingPenalty);
4434
+ // Astral combat is magic-linked: the background count bites (SG p.32).
4435
+ return Math.max(0, this.intuition + Math.floor(this.magic / 2) + this.woundModifier - this.sustainingPenalty - this.backgroundCountPenalty);
4421
4436
  }
4422
4437
  // Combat sense: the adept's magic reads the fight before it happens.
4423
4438
  // Wired reflexes read it faster still. Locked in someone's grapple
@@ -5430,6 +5445,34 @@ export class Player extends AbstractPlayer {
5430
5445
  .filter(i => i.category === Category.Focus)
5431
5446
  .sort((a, b) => (b.ratingValue ?? 0) - (a.ratingValue ?? 0))[0];
5432
5447
  }
5448
+ /**
5449
+ * BACKGROUND COUNT where this actor stands (Street Grimoire p.30-32;
5450
+ * utilities/environment.ts): its absolute value, a dice pool penalty on
5451
+ * every test linked to magic -- spellcasting, conjuring, counterspelling,
5452
+ * assensing, astral combat. 0 anywhere the district and the room say
5453
+ * nothing, which is every scene without a district.
5454
+ */
5455
+ get backgroundCountPenalty() {
5456
+ const room = this.currentLocation;
5457
+ return room ? backgroundPenalty(room.scene, room) : 0;
5458
+ }
5459
+ /**
5460
+ * SPELLS UNDER A BACKGROUND COUNT (Street Grimoire p.32): sustained
5461
+ * spells "are reduced by the background count"; at 0 or less they
5462
+ * fizzle. Checked on arrival, for the spells worn on this character --
5463
+ * a barrier stands where it was raised and is not carried in.
5464
+ */
5465
+ fizzleUnderBackground() {
5466
+ const bc = this.backgroundCountPenalty;
5467
+ if (bc <= 0)
5468
+ return;
5469
+ for (const w of this.wornWorkings()) {
5470
+ if (w.kind !== 'spell' || w.key === 'barrier' || w.key === 'manabarrier')
5471
+ continue;
5472
+ if (w.force !== undefined && w.force - bc <= 0)
5473
+ this.dropSustainedWorking(w.key);
5474
+ }
5475
+ }
5433
5476
  getMagicPool() {
5434
5477
  // A focus adds its Force to the tests it is bonded for (p.318): the
5435
5478
  // row's effects, scaled by Force (catalog v13).
@@ -5442,7 +5485,7 @@ export class Player extends AbstractPlayer {
5442
5485
  const base = this.isAwakened()
5443
5486
  ? (spellSkill > 0 ? this.magic + spellSkill : Math.max(Player.BASE_MAGIC_POOL, this.magic + 2))
5444
5487
  : Player.BASE_MAGIC_POOL + spellSkill;
5445
- return Math.max(0, base + focusBonus + this.woundModifier - this.sustainingPenalty);
5488
+ return Math.max(0, base + focusBonus + this.woundModifier - this.sustainingPenalty - this.backgroundCountPenalty);
5446
5489
  }
5447
5490
  /**
5448
5491
  * A magician/mystic adept knows the spells in their CHOSEN spellbook
@@ -1125,10 +1125,27 @@ export class Room extends AbstractRoom {
1125
1125
  this.tags = config.tags;
1126
1126
  // THE CROWD (utilities/crowd.ts): the seed's headcount when it names
1127
1127
  // one, otherwise what this kind of place holds at this time of day.
1128
+ this.conditions = config.conditions;
1129
+ this.authoredOccupancy = config.occupancy !== undefined;
1128
1130
  this.crowd = freshCrowd(config.occupancy !== undefined
1129
1131
  ? clampHeadcount(config.occupancy)
1130
1132
  : deriveOccupancy({ name: this.name, roomType: this.roomType, tags: this.tags, outdoors: this.outdoors, size: this.size, dwelling: this.dwelling, residenceTier: this.residenceTier }));
1131
1133
  }
1134
+ /** This room's own conditions (utilities/environment.ts conditionsAt). */
1135
+ conditions;
1136
+ /** Whether the seed named this room's headcount (then time never moves it). */
1137
+ authoredOccupancy = false;
1138
+ /**
1139
+ * THE CROWD AT THE SCENE'S OWN HOUR (a district start time,
1140
+ * environment.ts): a room built before the scene knew its clock derived
1141
+ * its headcount from the wall clock; this re-derives it for the run's
1142
+ * part of day. An authored headcount is the author's and stays.
1143
+ */
1144
+ rederiveCrowd(when) {
1145
+ if (this.authoredOccupancy)
1146
+ return;
1147
+ this.crowd = freshCrowd(deriveOccupancy({ name: this.name, roomType: this.roomType, tags: this.tags, outdoors: this.outdoors, size: this.size, dwelling: this.dwelling, residenceTier: this.residenceTier }, when));
1148
+ }
1132
1149
  /** The unnamed people in this room and how they are taking it (crowd.ts). */
1133
1150
  crowd;
1134
1151
  /**
@@ -1,3 +1,5 @@
1
+ import { startSecondsOf } from '../utilities/environment.js';
2
+ import { worldNow } from '../utilities/world-clock.js';
1
3
  import { Room, Item, NPC } from './_index.js';
2
4
  import { capitalCase } from 'change-case';
3
5
  import { localGrid as makeLocalGrid } from '../utilities/grids.js';
@@ -157,6 +159,21 @@ export class Scene extends AbstractScene {
157
159
  neighborhood;
158
160
  /** Who answers a call here (ISceneSeedJson.lawContract; law-response.ts). */
159
161
  lawContract;
162
+ /** The district's conditions (ISceneSeedJson.district; environment.ts). */
163
+ district;
164
+ /**
165
+ * THE WORLD'S TIME AT THIS TABLE. A district with a start time runs on
166
+ * game time -- that hour, today (UTC), plus the seconds the game clock
167
+ * has counted -- so a run set at 23:30 is night for everyone in it,
168
+ * whenever it is played. Without one, the world clock (world-clock.ts).
169
+ */
170
+ worldTime(now = new Date()) {
171
+ const start = startSecondsOf(this.district);
172
+ if (start === undefined)
173
+ return worldNow(now);
174
+ const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
175
+ return new Date(day + (start + this.gameClock.seconds) * 1000);
176
+ }
160
177
  /** The law response called to this site, if any (law-response.ts). */
161
178
  lawResponse;
162
179
  /**
@@ -1,3 +1,4 @@
1
+ import { environmentalModifier, rangeRowOf } from './environment.js';
1
2
  import { allBreached, damageFace, penetrationBoxes, pierces, BARRIER_FOG } from './barrier-geometry.js';
2
3
  import { recoilCompensation, recoilPenalty } from './recoil.js';
3
4
  import { Category } from '../types/shared/item-enum.js';
@@ -662,7 +663,14 @@ export class CombatExchange {
662
663
  if (a && d && this.scene.barrierOnLine?.(a, d, 'physical'))
663
664
  fog = BARRIER_FOG;
664
665
  }
665
- const rangeMod = range === 'out-of-range' ? -6 : range;
666
+ const rangeOnly = range === 'out-of-range' ? -6 : range;
667
+ // THE ENVIRONMENT (p.173-176; utilities/environment.ts): the district's
668
+ // weather, wind and daylight outdoors, a room's own light, the barrier's
669
+ // Light Fog, AND the range -- range is an environmental modifier too,
670
+ // so they combine (worst wins, ties bump a category) instead of
671
+ // stacking. A spell reads Light and Visibility only, as melee does.
672
+ const env = environmentalModifier(this.scene, [attacker.currentLocation, defender.currentLocation].filter(Boolean), attacker, { melee: isMelee || !!strike.isSpell, rangeRow: rangeRowOf(rangeOnly), extraVisRow: fog ? 1 : 0 });
673
+ const rangeMod = env.modifier;
666
674
  // p.190 grants the bonus to a defender who "uses a Take Cover
667
675
  // action" -- so the dice follow the ACTION, not the address.
668
676
  const coverDice = isInCover(defender)
@@ -706,7 +714,7 @@ export class CombatExchange {
706
714
  // charging, a running defender, surprise, Full Defense and the
707
715
  // dodge/block/parry interrupts -- see turnModifiers below.
708
716
  const turn = this.turnModifiers(attacker, defender, isMelee, strike, meta);
709
- const attack = rollPool(strike.pool + directed + rangeMod + attackerFloor + targetDown + turn.attack + recoil + fog, attackLimit);
717
+ const attack = rollPool(strike.pool + directed + rangeMod + attackerFloor + targetDown + turn.attack + recoil, attackLimit);
710
718
  // A DIRECT SPELL IS NOT DODGED (p.283): the target opposes the
711
719
  // casting test with Body or Willpower, and only counterspelling (and
712
720
  // a teammate's inspiration) helps -- not cover, posture, surprise or
@@ -717,12 +725,14 @@ export class CombatExchange {
717
725
  ? rollPool(0)
718
726
  : rollPool(defender.getDefensePool() + inspired + counterspell + coverDice + proneDefence + turn.defense);
719
727
  const rangeNote = weaponClass && spatial.meters > 0
720
- ? ` (${rangeMod !== 0 ? `${rangeMod} range` : `${rangeBracketName(spatial.meters, weaponClass)} range`} -- ~${Math.round(spatial.meters)} m)`
728
+ ? ` (${rangeBracketName(spatial.meters, weaponClass)} range -- ~${Math.round(spatial.meters)} m)`
721
729
  : '';
730
+ // The one environmental modifier that counted, and what made it.
731
+ const envNote = env.modifier !== 0 ? ` (${env.modifier} ${env.conditions.join(' + ')}${env.conditions.length > 1 ? ', tied' : ''}, p.175)` : '';
722
732
  const recoilNote = recoil < 0 && strike.weaponItem
723
733
  ? ` (${recoil} recoil -- round ${attacker.recoilRoundsFired} against RC ${recoilCompensation(attacker, strike.weaponItem)})`
724
734
  : '';
725
- meta.push(` Attack: ${formatRoll(attack)}${directed > 0 ? ` (+${directed} directed)` : ''}${rangeNote}${recoilNote}${fog ? ` (${fog} through a force barrier, Light Fog p.175)` : ''}`);
735
+ meta.push(` Attack: ${formatRoll(attack)}${directed > 0 ? ` (+${directed} directed)` : ''}${rangeNote}${envNote}${recoilNote}${fog ? ' (through a force barrier: Light Fog)' : ''}`);
726
736
  const coverNote = coverDice > 0 ? ` (+${coverDice} cover -- the ${spotOf(defender)})` : '';
727
737
  meta.push(` Defense: ${formatRoll(defense)}${inspired > 0 ? ` (+${inspired} inspired)` : ''}${counterspell > 0 ? ` (+${counterspell} counterspell${counterspellSource ? ` -- ${counterspellSource}'s cover` : ''})` : ''}${coverNote}`);
728
738
  const dryWarning = strike.weaponItem?.isFirearm() && strike.weaponItem.ammo === 0
@@ -1,3 +1,4 @@
1
+ import { sightModifier } from './environment.js';
1
2
  import { NPC } from '../models/npc.js';
2
3
  import { rollPool, formatRoll } from './dice.js';
3
4
  import { wouldNotice, doorwayBlocker } from './spots.js';
@@ -45,7 +46,7 @@ export function caughtAtDoor(room, direction, mover, isPlayer) {
45
46
  const theirSkill = mover.skillRating('sneaking');
46
47
  const sneak = rollPool(Math.max(1, mover.agility + (theirSkill > 0 ? theirSkill : -1) + mover.bonus('sneak') + mover.woundModifier));
47
48
  const mySkill = npc.skillRating('perception');
48
- const spot = rollPool(Math.max(1, npc.intuition + (mySkill > 0 ? mySkill : -1) + npc.woundModifier));
49
+ const spot = rollPool(Math.max(1, npc.intuition + (mySkill > 0 ? mySkill : -1) + sightModifier(npc, room) + npc.woundModifier));
49
50
  if (isPlayer)
50
51
  lines.push(`Slip past ${npc.name}: ${formatRoll(sneak)} vs ${formatRoll(spot)} -- ${sneak.hits > spot.hits ? 'unseen.' : 'SPOTTED.'}`);
51
52
  if (sneak.hits > spot.hits)
@@ -0,0 +1,194 @@
1
+ /** The p.175 rows, as the dice pool modifier each sits on. */
2
+ const ROWS = [0, -1, -3, -6];
3
+ const LIGHT_ROW = { full: 0, partial: 1, dim: 2, dark: 3 };
4
+ /** clear 0, light -1, heavy -6: the rows canon names for rain/fog/smoke. */
5
+ const VIS_ROW = { clear: 0, light: 1, heavy: 3 };
6
+ const WIND_ROW = { calm: 0, light: 1, moderate: 2, strong: 3 };
7
+ const LIGHTS = new Set(['full', 'partial', 'dim', 'dark']);
8
+ const VISES = new Set(['clear', 'light', 'heavy']);
9
+ const WINDS = new Set(['calm', 'light', 'moderate', 'strong']);
10
+ export function isLight(v) { return typeof v === 'string' && LIGHTS.has(v); }
11
+ export function isVisibility(v) { return typeof v === 'string' && VISES.has(v); }
12
+ export function isWind(v) { return typeof v === 'string' && WINDS.has(v); }
13
+ export function isMatrixZone(v) {
14
+ const z = v;
15
+ return !!z && (z.kind === 'spam' || z.kind === 'static') && (z.rating === 2 || z.rating === 4 || z.rating === 6);
16
+ }
17
+ export function isBackgroundCount(v) {
18
+ return typeof v === 'number' && Number.isInteger(v) && v >= -24 && v <= 24;
19
+ }
20
+ /** "HH:MM", 00:00-23:59. */
21
+ export function isStartTime(v) {
22
+ return typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v);
23
+ }
24
+ /** The district a scene was built with, if any (an authored run). */
25
+ export function districtOf(scene) {
26
+ return scene?.district;
27
+ }
28
+ /**
29
+ * WHAT IS TRUE IN THIS ROOM. Outdoors takes the district's weather, wind
30
+ * and daylight; indoors is full light, clear air, no wind. Background
31
+ * count and the Matrix zone are the district's everywhere. A room's own
32
+ * `conditions` win over all of it.
33
+ */
34
+ export function conditionsAt(scene, room) {
35
+ const d = districtOf(scene) ?? {};
36
+ const own = room?.conditions ?? {};
37
+ const outside = room?.outdoors === true;
38
+ return {
39
+ light: own.light ?? (outside ? d.outdoorLight ?? 'full' : 'full'),
40
+ visibility: own.visibility ?? (outside ? d.weather ?? 'clear' : 'clear'),
41
+ wind: outside ? d.wind ?? 'calm' : 'calm',
42
+ backgroundCount: own.backgroundCount ?? d.backgroundCount ?? 0,
43
+ matrixZone: own.matrixZone ?? d.matrixZone,
44
+ };
45
+ }
46
+ export function visionOf(viewer) {
47
+ const can = (k) => typeof viewer?.can === 'function' ? viewer.can(k) : false;
48
+ return { lowLight: can('low-light'), thermographic: can('thermographic') };
49
+ }
50
+ /** One condition's row after the viewer's vision compensates (p.175). */
51
+ function lightRow(light, vision) {
52
+ let row = LIGHT_ROW[light];
53
+ if (vision.lowLight && (light === 'partial' || light === 'dim'))
54
+ row = 0;
55
+ if (vision.thermographic)
56
+ row = Math.max(0, row - 1);
57
+ return row;
58
+ }
59
+ function visRow(vis, vision) {
60
+ const row = VIS_ROW[vis];
61
+ return vision.thermographic ? Math.max(0, row - 1) : row;
62
+ }
63
+ /**
64
+ * COMBINE CONDITIONS THE p.173-175 WAY: the most severe row only; two or
65
+ * more tied at it bump one category (-1 -> -3 -> -6), and two or more at
66
+ * the -6 row make -10. `rows` are row indexes, 0 (none) to 3 (-6).
67
+ */
68
+ export function combineRows(rows) {
69
+ const worst = Math.max(0, ...rows);
70
+ if (worst === 0)
71
+ return 0;
72
+ const tied = rows.filter(r => r === worst).length;
73
+ if (tied < 2)
74
+ return ROWS[worst];
75
+ return worst >= 3 ? -10 : ROWS[worst + 1];
76
+ }
77
+ /**
78
+ * THE ENVIRONMENTAL MODIFIER for a sight-based test in `room` (p.173-176).
79
+ * `rangeRow` is the range category the caller already worked out (0 none,
80
+ * 1 medium, 2 long, 3 extreme) -- range is an environmental modifier and
81
+ * combines with the rest rather than stacking. Melee reads Light and
82
+ * Visibility only.
83
+ */
84
+ export function environmentalModifier(scene, room, viewer, opts = {}) {
85
+ // BETWEEN TWO PLACES (a shot from a doorway into the rain): each column
86
+ // at its worst along the way -- the shooter's room and the target's.
87
+ const all = (Array.isArray(room) ? room : [room]).map(r => conditionsAt(scene, r));
88
+ const vision = visionOf(viewer);
89
+ const worstOf = (rowOf, pick) => {
90
+ let best = { row: -1, value: pick(all[0]) };
91
+ for (const c of all) {
92
+ const row = rowOf(c);
93
+ if (row > best.row)
94
+ best = { row, value: pick(c) };
95
+ }
96
+ return best;
97
+ };
98
+ const parts = [];
99
+ const lw = worstOf(c => lightRow(c.light, vision), c => c.light);
100
+ if (lw.row > 0)
101
+ parts.push({ row: lw.row, label: lw.value === 'dark' ? 'darkness' : `${lw.value} light` });
102
+ const vw = worstOf(c => visRow(c.visibility, vision), c => c.visibility);
103
+ const v = Math.max(vw.row, opts.extraVisRow ?? 0);
104
+ if (v > 0) {
105
+ const kind = districtOf(scene)?.weatherKind ?? 'rain';
106
+ parts.push({ row: v, label: vw.row >= (opts.extraVisRow ?? 0) && vw.value !== 'clear' ? `${vw.value} ${kind}` : 'light fog' });
107
+ }
108
+ if (!opts.melee) {
109
+ const ww = worstOf(c => WIND_ROW[c.wind], c => c.wind);
110
+ if (ww.row > 0)
111
+ parts.push({ row: ww.row, label: `${ww.value} wind` });
112
+ if ((opts.rangeRow ?? 0) > 0)
113
+ parts.push({ row: opts.rangeRow, label: 'range' });
114
+ }
115
+ const modifier = combineRows(parts.map(p => p.row));
116
+ const worst = Math.max(0, ...parts.map(p => p.row));
117
+ return { modifier, conditions: parts.filter(p => p.row === worst && worst > 0).map(p => p.label) };
118
+ }
119
+ /** A range modifier as the engine computes it (rangePenalty) -> its p.175 row. */
120
+ export function rangeRowOf(rangeModifier) {
121
+ if (rangeModifier <= -6)
122
+ return 3;
123
+ if (rangeModifier <= -3)
124
+ return 2;
125
+ if (rangeModifier <= -1)
126
+ return 1;
127
+ return 0;
128
+ }
129
+ /** BACKGROUND COUNT here, as the penalty canon applies (SG p.32): its
130
+ * absolute value, on every magic-linked test. */
131
+ export function backgroundPenalty(scene, room) {
132
+ return Math.abs(conditionsAt(scene, room).backgroundCount);
133
+ }
134
+ /** A spam or static zone's noise here (p.231), 0 when there is none. */
135
+ export function zoneNoise(scene, room) {
136
+ return conditionsAt(scene, room).matrixZone?.rating ?? 0;
137
+ }
138
+ /** The seconds past UTC midnight a district's start time names. */
139
+ export function startSecondsOf(district) {
140
+ if (!isStartTime(district?.startTime))
141
+ return undefined;
142
+ const [h, m] = district.startTime.split(':').map(Number);
143
+ return h * 3600 + m * 60;
144
+ }
145
+ /** Faults in a district or a room's conditions, for the seed audit. */
146
+ export function districtFaults(district, rooms) {
147
+ const out = [];
148
+ if (district !== undefined) {
149
+ const d = district;
150
+ if (typeof d !== 'object' || d === null)
151
+ return ['district must be an object'];
152
+ if (d.startTime !== undefined && !isStartTime(d.startTime))
153
+ out.push(`district start time "${String(d.startTime)}" must be HH:MM, 00:00-23:59`);
154
+ if (d.weather !== undefined && !isVisibility(d.weather))
155
+ out.push(`district weather must be clear, light or heavy`);
156
+ if (d.wind !== undefined && !isWind(d.wind))
157
+ out.push(`district wind must be calm, light, moderate or strong`);
158
+ if (d.outdoorLight !== undefined && !isLight(d.outdoorLight))
159
+ out.push(`district outdoor light must be full, partial, dim or dark`);
160
+ if (d.backgroundCount !== undefined && !isBackgroundCount(d.backgroundCount))
161
+ out.push(`district background count must be a whole number from -24 to 24 (Street Grimoire p.30)`);
162
+ if (d.matrixZone !== undefined && !isMatrixZone(d.matrixZone))
163
+ out.push(`district Matrix zone must be spam or static at rating 2, 4 or 6 (p.231)`);
164
+ }
165
+ for (const r of rooms) {
166
+ if (r.conditions === undefined)
167
+ continue;
168
+ const c = r.conditions;
169
+ if (c.light !== undefined && !isLight(c.light))
170
+ out.push(`"${r.name}": light must be full, partial, dim or dark`);
171
+ if (c.visibility !== undefined && !isVisibility(c.visibility))
172
+ out.push(`"${r.name}": visibility must be clear, light or heavy`);
173
+ if (c.backgroundCount !== undefined && !isBackgroundCount(c.backgroundCount))
174
+ out.push(`"${r.name}": background count must be a whole number from -24 to 24`);
175
+ if (c.matrixZone !== undefined && !isMatrixZone(c.matrixZone))
176
+ out.push(`"${r.name}": Matrix zone must be spam or static at rating 2, 4 or 6`);
177
+ }
178
+ return out;
179
+ }
180
+ /**
181
+ * SIGHT HERE (p.135: "Perception Tests involving vision ... use the same
182
+ * Visibility and Light modifiers as attacks do in combat"): the Light and
183
+ * Visibility rows only, combined the p.173 way, for whoever is looking,
184
+ * across the rooms between them and what they look at. The scene comes
185
+ * off the room (Room.scene). 0 or negative.
186
+ */
187
+ export function sightModifier(viewer, ...rooms) {
188
+ const placed = rooms.filter((r) => !!r);
189
+ if (placed.length === 0)
190
+ return 0;
191
+ const scene = placed.find(r => r.scene)?.scene;
192
+ return environmentalModifier(scene, placed, viewer, { melee: true }).modifier;
193
+ }
194
+ //# sourceMappingURL=environment.js.map
@@ -223,6 +223,12 @@ function hostNamed(room, q) {
223
223
  * purpose: the party reads the rider's body as far away, and the rider
224
224
  * reads the job as far away, off this one line.
225
225
  */
226
+ /**
227
+ * THE DISTANCE HALF OF NOISE (p.231's table). A spam or static zone's
228
+ * rating is the other half, and it applies to EVERY Matrix action taken
229
+ * there, near or far -- so it rides Player.matrixActionPenalty with
230
+ * running silent, not this per-target number.
231
+ */
226
232
  export function noiseFor(actor, room) {
227
233
  if (actor.insideHost)
228
234
  return 0;
@@ -1,3 +1,4 @@
1
+ import { sightModifier } from './environment.js';
1
2
  import { mentalLimit } from './limits.js';
2
3
  /**
3
4
  * THE PERCEPTION TEST (SR5 core p.135-136), shared by every verb that
@@ -52,6 +53,8 @@ export function perceptionTest(actor, opts = {}) {
52
53
  + (skill > 0 ? skill : -1)
53
54
  + (opts.active ? ACTIVELY_LOOKING : 0)
54
55
  + actor.bonus('perception')
56
+ // Visibility and Light (p.135, p.173): the district and the room's.
57
+ + sightModifier(actor, actor.currentLocation)
55
58
  + actor.woundModifier
56
59
  - actor.sustainingPenalty);
57
60
  return { pool, limit: mentalLimit(actor) };
@@ -1,3 +1,4 @@
1
+ import { sightModifier } from './environment.js';
1
2
  import { droneImmobile } from './drone-prose.js';
2
3
  import { Direction } from '../types/shared/direction-enum.js';
3
4
  import { isOpenGround, isVoid, roomAtCell, streetDoors, worldCell } from './district-plane.js';
@@ -2249,7 +2250,7 @@ export function wouldNotice(observer, target) {
2249
2250
  return true;
2250
2251
  const perception = observer.skillRating('perception');
2251
2252
  const pool = Math.max(1, observer.intuition + (perception > 0 ? perception : -1)
2252
- + observer.bonus('perception'));
2253
+ + observer.bonus('perception') + sightModifier(observer, room));
2253
2254
  return Math.floor(pool / 4) + perceptionRangeModifier(meters) >= threshold;
2254
2255
  }
2255
2256
  /** A conversational voice. Also what earshot.ts's SPEECH_METERS is, so
@@ -1,11 +1,22 @@
1
1
  import { rollPool, formatRoll } from './dice.js';
2
2
  import { astralLimit } from './limits.js';
3
+ import { backgroundPenalty } from './environment.js';
3
4
  import { pierces } from './barrier-geometry.js';
4
5
  /** Does this room's ward stop this form? Not its creator, and not the
5
6
  * form holding a pass it just pressed for. */
7
+ /**
8
+ * A WARD UNDER A BACKGROUND COUNT (Street Grimoire p.32): "wards and
9
+ * rituals ... are reduced by the background count" and collapse at 0 or
10
+ * less. The Force the ward actually stands at, here.
11
+ */
12
+ export function effectiveWardForce(room) {
13
+ return room.wardForce - backgroundPenalty(room.scene, room);
14
+ }
6
15
  export function wardHolds(room, actor) {
7
16
  if (!room?.warded)
8
17
  return false;
18
+ if (effectiveWardForce(room) <= 0)
19
+ return false;
9
20
  if (room.wardCreator !== undefined && room.wardCreator === actor.name)
10
21
  return false;
11
22
  return actor.wardPass !== room.name;
@@ -28,8 +39,9 @@ export function wardBetween(from, to, actor) {
28
39
  */
29
40
  export function pressThroughWard(scene, logger, actor, ward) {
30
41
  const press = rollPool(actor.magic + actor.charisma, astralLimit(actor));
31
- const hold = rollPool(ward.wardForce * 2);
32
- const meta = `Press through the ward (p.316): Magic + Charisma ${formatRoll(press)} v. Force ${ward.wardForce} x 2 ${formatRoll(hold)}`;
42
+ const force = effectiveWardForce(ward);
43
+ const hold = rollPool(force * 2);
44
+ const meta = `Press through the ward (p.316): Magic + Charisma ${formatRoll(press)} v. Force ${force} x 2 ${formatRoll(hold)}${force !== ward.wardForce ? ` (Force ${ward.wardForce}, less the background count)` : ""}`;
33
45
  if (press.hits - hold.hits <= 0) {
34
46
  actor.performAction('is stopped by a ward', ward.name);
35
47
  return { through: false, meta };
@@ -67,7 +79,7 @@ export function alertWard(scene, logger, ward, actor, how) {
67
79
  export function resolveWardStrike(scene, logger, attacker, ward) {
68
80
  const world = [];
69
81
  const meta = [];
70
- const force = ward.wardForce;
82
+ const force = Math.max(1, effectiveWardForce(ward));
71
83
  const profile = attacker.getWeaponProfile();
72
84
  const roll = rollPool(attacker.getAttackPool(), attacker.getAttackLimit(null));
73
85
  meta.push(`Attack on the ward over ${ward.name} (unopposed, p.197): ${formatRoll(roll)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.392.0",
3
+ "version": "5.393.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",