@adam0white/human-framework 2.19.0 → 2.21.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.
package/dist/sim/sim.js CHANGED
@@ -50,7 +50,7 @@
50
50
  */
51
51
  import { appraise } from '../affect/index.js';
52
52
  import { matchesCommitment, spanMeetsWindow } from '../agenda/index.js';
53
- import { BODY_DEFAULTS, contagionRoll, die, readBody, sicken } from '../body/index.js';
53
+ import { BODY_DEFAULTS, contagionRoll, die, nextDozeCheck, readBody, sicken } from '../body/index.js';
54
54
  import { chronicleBetween } from '../chronicle/index.js';
55
55
  import { recordDeed } from '../conscience/index.js';
56
56
  import { converse } from '../conversation/index.js';
@@ -274,7 +274,7 @@ function awaitingJoint(c, id) {
274
274
  return pendingProposals(c).some((x) => x.proposerId === id || x.accepted.includes(id));
275
275
  }
276
276
  /** Next minute at which this person needs attention. */
277
- export function nextEventAt(c, p) {
277
+ function ordinaryEventAt(c, p) {
278
278
  if (!p.body.alive)
279
279
  return Number.POSITIVE_INFINITY;
280
280
  if (awaitingJoint(c, p.id))
@@ -284,6 +284,12 @@ export function nextEventAt(c, p) {
284
284
  return Math.max(p.now, Math.min(act.endsAt, act.reviewAt));
285
285
  return Math.max(p.now, c.idleUntil[p.id] ?? p.now);
286
286
  }
287
+ /** Pure scheduling query, including passive bodily checkpoints during joint waiting. */
288
+ export function nextEventAt(c, p) {
289
+ if (p.body.doze?.since !== undefined && awaitingJoint(c, p.id))
290
+ return p.now;
291
+ return Math.min(ordinaryEventAt(c, p), nextDozeCheck(p));
292
+ }
287
293
  /**
288
294
  * How this person would answer `suggestion` now, without changing state (UI telegraph; wraps `predict`). Pass the
289
295
  * world's `scarcityFor(p)` as `scarcity`, as the step does, or the telegraph can promise what the step refuses.
@@ -415,7 +421,6 @@ export function acceptJoint(c, world, proposal, partner, partnerRecord, writeLog
415
421
  proposal.partnerRecords[partner.id] = partnerRecord;
416
422
  if (proposal.accepted.length < proposal.partnerIds.length)
417
423
  return [];
418
- settle(c, proposal, 'accepted');
419
424
  const proposer = personById(c, proposal.proposerId);
420
425
  const participants = [];
421
426
  if (proposer)
@@ -427,6 +432,19 @@ export function acceptJoint(c, world, proposal, partner, partnerRecord, writeLog
427
432
  participants.push({ person: q, offer: mirrorFor(world, q, proposal), record: rec });
428
433
  }
429
434
  const at = Math.max(...participants.map((x) => x.person.now));
435
+ // Opted-in catch-up can physically stop a participant. Validate everyone before any begin.
436
+ if (participants.some(({ person }) => person.body.doze !== undefined)) {
437
+ for (const { person } of participants)
438
+ if (person.now < at)
439
+ tick(person, at);
440
+ if (participants.some(({ person }) => person.body.doze?.since !== undefined)) {
441
+ declineJoint(c, proposal);
442
+ for (const { person } of participants)
443
+ interruptPerson(c, person, at, person.activity?.interrupt?.reason ?? `declined:${proposal.id}`);
444
+ return [];
445
+ }
446
+ }
447
+ settle(c, proposal, 'accepted');
430
448
  const begun = [];
431
449
  for (const { person, offer, record } of participants) {
432
450
  if (person.now < at)
@@ -770,6 +788,26 @@ export function stepCommunity(c, world, until, opts = {}) {
770
788
  c.queued ??= {};
771
789
  c.dayDone ??= {};
772
790
  synchronizeCommands(c, opts, log);
791
+ // Cancellation releases clocks AND review deadlines. Keep pending causes, including simultaneous doze.
792
+ const cancelDozeWaiting = (p, t) => {
793
+ for (const x of pendingProposals(c)) {
794
+ if (x.proposerId !== p.id && !x.partnerIds.includes(p.id))
795
+ continue;
796
+ const waiters = [x.proposerId, ...x.accepted]
797
+ .map((id) => personById(c, id))
798
+ .filter((q) => q !== undefined);
799
+ for (const q of waiters)
800
+ if (q.now < t)
801
+ tick(q, t);
802
+ const reasons = new Map(waiters.map((q) => [q.id, q.activity?.interrupt?.reason ?? c.interrupts[q.id]?.reason]));
803
+ if (x.proposerId === p.id)
804
+ settle(c, x, 'declined');
805
+ else
806
+ declineJoint(c, x);
807
+ for (const q of waiters)
808
+ interruptPerson(c, q, t, reasons.get(q.id) ?? `declined:${x.id}`);
809
+ }
810
+ };
773
811
  // Pairs whose shared activity already ran its conversation and contagion at `pairMinute` (both partners
774
812
  // finishing the same joint activity at the same minute would otherwise run them twice).
775
813
  let pairMinute = Number.NEGATIVE_INFINITY;
@@ -787,7 +825,32 @@ export function stepCommunity(c, world, until, opts = {}) {
787
825
  if (!next || t > until)
788
826
  break;
789
827
  const p = next;
828
+ const ordinaryDue = ordinaryEventAt(c, p) <= t;
790
829
  tick(p, t);
830
+ const dozed = p.activity?.dozedAt !== undefined || p.body.doze?.since !== undefined;
831
+ if (dozed) {
832
+ cancelDozeWaiting(p, t);
833
+ const act = p.activity;
834
+ if (act?.dozedAt !== undefined) {
835
+ if (act.interrupt)
836
+ c.interrupts[p.id] ??= { ...act.interrupt };
837
+ const outcome = world.resolve(p, act, 'interrupted');
838
+ finish(p, outcome, finishOpts(world));
839
+ log({
840
+ at: t,
841
+ personId: p.id,
842
+ kind: 'finish',
843
+ detail: `${act.action} interrupted by doze`,
844
+ action: act.action,
845
+ status: outcome.status,
846
+ });
847
+ }
848
+ c.idleUntil[p.id] = t + quantum;
849
+ if (!ordinaryDue)
850
+ continue;
851
+ }
852
+ if (!ordinaryDue && p.body.alive)
853
+ continue;
791
854
  bookkeepDay(p, world, t, c.dayDone, opts);
792
855
  if (!p.body.alive) {
793
856
  log({ at: t, personId: p.id, kind: 'died', detail: `${p.name} died` });
@@ -821,6 +884,11 @@ export function stepCommunity(c, world, until, opts = {}) {
821
884
  }
822
885
  }
823
886
  }
887
+ if (p.body.doze?.since !== undefined) {
888
+ cancelDozeWaiting(p, t);
889
+ c.idleUntil[p.id] = t + quantum;
890
+ continue;
891
+ }
824
892
  let act = p.activity;
825
893
  // An interrupt on an activity that ends at this event still reaches the next decision.
826
894
  perceptReason ??= act?.interrupt?.reason;
@@ -471,6 +471,16 @@ export const REPUTATION_DEFAULTS = {
471
471
  /** First-hand familiarity damps hearsay: scale = 1 - familiarityDamping × familiarity. */
472
472
  familiarityDamping: 0.5,
473
473
  };
474
+ /** Only authored, finite effects count; prototype properties are not character traits. */
475
+ function reputationEffect(trait, table) {
476
+ const effect = Object.hasOwn(table, trait) ? table[trait] : undefined;
477
+ return effect &&
478
+ Number.isFinite(effect.affection) &&
479
+ Number.isFinite(effect.trust) &&
480
+ Number.isFinite(effect.respect)
481
+ ? effect
482
+ : undefined;
483
+ }
474
484
  /** Splits a character proposition '<personId>:<trait>[:...]' into its subject and trait, if it is one. */
475
485
  export function parseTraitProp(prop, table = REPUTATION_TRAITS) {
476
486
  const parts = prop.split(':');
@@ -478,7 +488,7 @@ export function parseTraitProp(prop, table = REPUTATION_TRAITS) {
478
488
  return undefined;
479
489
  const personId = parts[0];
480
490
  const trait = parts[1];
481
- if (personId === '' || table[trait] === undefined)
491
+ if (personId === '' || reputationEffect(trait, table) === undefined)
482
492
  return undefined;
483
493
  return { personId, trait };
484
494
  }
@@ -509,7 +519,7 @@ export function applyReputationBelief(p, prop, before, after, now, table = REPUT
509
519
  }
510
520
  /** Whether a character claim about a person is unfavourable to them (for backbiting tags). */
511
521
  export function isUnfavourableTrait(trait, value, table = REPUTATION_TRAITS) {
512
- const e = table[trait];
522
+ const e = reputationEffect(trait, table);
513
523
  if (!e)
514
524
  return false;
515
525
  const sum = e.affection + e.trust + e.respect;
package/dist/types.d.ts CHANGED
@@ -65,7 +65,22 @@ export interface Illness {
65
65
  /** Exposure kinds (keys of `BodyState.exposures`) whose recent load raises this chronic condition. */
66
66
  aggravatedBy?: string[];
67
67
  }
68
+ export interface DozeContext {
69
+ posture: 'upright' | 'seated' | 'reclined';
70
+ /** Host-declared alerting demand, not volume. */
71
+ stimulation: Unit;
72
+ }
73
+ export interface DozeState {
74
+ context: DozeContext;
75
+ since?: Minute;
76
+ }
77
+ export interface BladderState {
78
+ fill: Unit;
79
+ pending: number;
80
+ }
68
81
  export interface BodyState {
82
+ doze?: DozeState;
83
+ bladder?: BladderState;
69
84
  /** 1 = fully fed, 0 = starving. */
70
85
  satiety: Unit;
71
86
  /** 1 = fully hydrated. */
@@ -848,6 +863,8 @@ export interface Activity {
848
863
  at: Minute;
849
864
  reason: string;
850
865
  };
866
+ /** Involuntary doze stopped this activity here. Work and practice stay stopped until host resolution. */
867
+ dozedAt?: Minute;
851
868
  /**
852
869
  * Set by `begin` when the chosen act was possible only because necessity / the capacity bound lifted a
853
870
  * conscience veto (eating forbidden food, or drinking during the fast, in extremity). `finish` then records no
@@ -898,7 +915,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
898
915
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
899
916
  * `dutyReviewAt`). Person shape unchanged.
900
917
  */
901
- export declare const ENGINE_VERSION = "2.17.0";
918
+ export declare const ENGINE_VERSION = "2.21.0";
902
919
  /** A pregnancy carried by this person (host-driven; see `family/`). */
903
920
  export interface Pregnancy {
904
921
  fatherId: PersonId;
@@ -1181,6 +1198,8 @@ export interface Affordance {
1181
1198
  };
1182
1199
  /** Advertised need deltas over the whole activity (positive = satisfies). */
1183
1200
  advertises: Partial<Record<NeedId, number>>;
1201
+ /** Intended urination: actual voiding, not completion alone, defines expectation success. */
1202
+ voidsBladder?: boolean;
1184
1203
  /** Free tags: 'work', 'leisure', 'social', 'worship', 'risky', 'outdoors', ... */
1185
1204
  tags?: string[];
1186
1205
  norms?: NormTag[];
@@ -1335,6 +1354,8 @@ export interface Outcome {
1335
1354
  at: Minute;
1336
1355
  /** Realized need deltas from world effects (e.g. food eaten). Body exertion is computed by the framework. */
1337
1356
  needs?: Partial<Record<NeedId, number>>;
1357
+ /** Host-confirmed urination; only a completed activity clears stored bladder fill. */
1358
+ voidedBladder?: boolean;
1338
1359
  material?: number;
1339
1360
  /** Harm suffered. */
1340
1361
  injury?: Omit<Injury, 'id' | 'since'>;
@@ -1509,6 +1530,10 @@ export interface PersonSpec {
1509
1530
  values?: Partial<Values>;
1510
1531
  norms?: HeldNorm[];
1511
1532
  skills?: Record<string, Unit>;
1533
+ /** Authored habits, copied in order. Invalid or over-bound setup throws RangeError. */
1534
+ habits?: Habit[];
1535
+ /** Authored expectations, copied in order. Invalid or over-bound setup throws RangeError. */
1536
+ expectations?: ActionExpectation[];
1512
1537
  body?: Partial<Omit<BodyState, 'injuries' | 'illnesses' | 'nextId'>>;
1513
1538
  needs?: Partial<NeedReservoirs>;
1514
1539
  relationships?: (Partial<Relationship> & {
@@ -1534,6 +1559,8 @@ export interface PersonSpec {
1534
1559
  * `enableCharacterChange` and `enableSkillRetention` right after `createPerson`. Each is off when absent.
1535
1560
  */
1536
1561
  enable?: {
1562
+ doze?: DozeContext;
1563
+ bladder?: boolean;
1537
1564
  /** Read-only suggestion score explanations, as with `enableSuggestionReasons(p)`. */
1538
1565
  suggestionReasons?: boolean;
1539
1566
  gists?: boolean;
package/dist/types.js CHANGED
@@ -120,4 +120,4 @@ export const PERSON_SCHEMA = 'human/person@1';
120
120
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
121
121
  * `dutyReviewAt`). Person shape unchanged.
122
122
  */
123
- export const ENGINE_VERSION = '2.17.0';
123
+ export const ENGINE_VERSION = '2.21.0';
@@ -233,6 +233,18 @@ export declare function noteVoicePromise(p: Person, voiceId: EntityId, event: {
233
233
  at?: Minute;
234
234
  action?: string;
235
235
  }): VoiceRelation;
236
+ /**
237
+ * SCOPE (host trust gesture): add a host-supplied signed amount to an existing advising voice's trust,
238
+ * clamped to 0..1, and record the realized change in the usual bounded history. Magnitudes and reason
239
+ * are host premises, not calibrated claims. Missing voices return undefined without creating a voice.
240
+ * Non-finite amounts are ignored. No pressure, credit, counters, memory, time or RNG changes occur.
241
+ * Empty/non-string reasons and non-string actions reject before mutation with RangeError.
242
+ */
243
+ export declare function gestureVoiceTrust(p: Person, voiceId: EntityId, amount: number, event: {
244
+ reason: string;
245
+ at?: Minute;
246
+ action?: string;
247
+ }): VoiceRelation | undefined;
236
248
  /**
237
249
  * The next minute after `now` at which a pending commitment reaches `wakeCommitmentPressure`, the level at which a
238
250
  * sleeper may wake to serve it (the asleep veto lifts). The composite caps a sleeper's review there so a duty whose
package/dist/will/will.js CHANGED
@@ -293,6 +293,27 @@ export function noteVoicePromise(p, voiceId, event) {
293
293
  noteTrust(v, v.trust - before, event.kept ? 'promise-kept' : 'promise-broken', at, event.action);
294
294
  return v;
295
295
  }
296
+ /**
297
+ * SCOPE (host trust gesture): add a host-supplied signed amount to an existing advising voice's trust,
298
+ * clamped to 0..1, and record the realized change in the usual bounded history. Magnitudes and reason
299
+ * are host premises, not calibrated claims. Missing voices return undefined without creating a voice.
300
+ * Non-finite amounts are ignored. No pressure, credit, counters, memory, time or RNG changes occur.
301
+ * Empty/non-string reasons and non-string actions reject before mutation with RangeError.
302
+ */
303
+ export function gestureVoiceTrust(p, voiceId, amount, event) {
304
+ if (!event || typeof event.reason !== 'string' || event.reason.length === 0)
305
+ throw new RangeError('A trust gesture needs a reason');
306
+ if (event.action !== undefined && typeof event.action !== 'string')
307
+ throw new RangeError('Invalid trust gesture action');
308
+ const v = voiceOf(p, voiceId);
309
+ if (!v || !Number.isFinite(amount))
310
+ return v;
311
+ const before = v.trust;
312
+ v.trust = clamp01(before + amount);
313
+ const at = event.at !== undefined && Number.isFinite(event.at) ? event.at : p.now;
314
+ noteTrust(v, v.trust - before, event.reason, at, event.action);
315
+ return v;
316
+ }
296
317
  const finite = (x) => x === undefined || Number.isFinite(x);
297
318
  /** Host numbers the framework computes with must be finite; a NaN offer is vetoed, never chosen. */
298
319
  function wellFormed(aff) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.19.0",
3
+ "version": "2.21.0",
4
4
  "description": "Deterministic, explainable simulated people: body, needs, affect, memory, beliefs, skills, habits, relationships, values and a will that can refuse a suggestion",
5
5
  "keywords": [
6
6
  "simulation",