@adam0white/human-framework 2.8.0 → 2.9.1

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
@@ -125,6 +125,7 @@ export function communityState(c) {
125
125
  /**
126
126
  * A community over `people`. With `prior` (from `communityState`, possibly through JSON), the host-side state is
127
127
  * resumed from it, so a run restored from people + community + world state continues exactly as it would have.
128
+ * `prior` is copied: after stepping, write back `communityState(c)`; the old `prior` stays stale.
128
129
  */
129
130
  export function createCommunity(people, prior) {
130
131
  const sorted = [...people].sort((a, b) => cmpStr(a.id, b.id));
@@ -181,9 +182,19 @@ export function birth(c, a, b, spec) {
181
182
  /**
182
183
  * Tell `dead`'s death at `at` to everyone alive in the community who has a tie to them (1.8.0): a told `death`
183
184
  * percept through `perceive`, so they grieve, the tie is kept and marked, and a spouse is widowed. Each listener's
184
- * clock must be at or before `at`; the drivers call it when they notice the death.
185
+ * receipt time is at least their own `now`. `late: 'now'` explicitly permits listeners past `at`, as does the
186
+ * existing default. `late: 'throw'` requires all eligible listeners to be at or before `at` and throws before
187
+ * delivering any news if one is ahead. This changes no listener's clock.
185
188
  */
186
- export function tellDeath(c, dead, at) {
189
+ export function tellDeath(c, dead, at, opts = {}) {
190
+ if (opts.late === 'throw') {
191
+ for (const q of c.people) {
192
+ if (q === dead || !q.body.alive || q.now <= at)
193
+ continue;
194
+ if (q.social.relationships.some((r) => r.otherId === dead.id))
195
+ throw new Error(`tellDeath: listener ${q.id} is past the death minute`);
196
+ }
197
+ }
187
198
  for (const q of c.people) {
188
199
  if (q === dead || !q.body.alive)
189
200
  continue;
@@ -572,24 +583,8 @@ function contagionBetween(p, q, act, t, log) {
572
583
  }
573
584
  }
574
585
  }
575
- /**
576
- * Advance the community to `until`, processing person events in (time, id) order. Returns the events.
577
- */
578
- export function stepCommunity(c, world, until, opts = {}) {
579
- const events = [];
580
- const quantum = Math.max(1, opts.idleQuantum ?? SIM_DEFAULTS.idleQuantum);
581
- const maxEvents = opts.maxEvents ?? SIM_DEFAULTS.maxEvents;
582
- const salience = opts.interruptSalience ?? SIM_DEFAULTS.interruptSalience;
583
- // Communities created before joint support (plain JSON from an older host) get the new bookkeeping.
584
- c.interrupts ??= {};
585
- c.joint ??= { nextId: 0, proposals: [], excluded: {} };
586
- const log = (e) => {
587
- if (events.length < maxEvents)
588
- events.push(e);
589
- };
590
- const contagion = opts.contagion ?? true;
591
- c.queued ??= {};
592
- c.dayDone ??= {};
586
+ /** Synchronize the host's commands before selecting the next event. */
587
+ function synchronizeCommands(c, opts, log) {
593
588
  if (opts.controlled) {
594
589
  for (const p of c.people) {
595
590
  const cmd = opts.controlled[p.id];
@@ -614,36 +609,108 @@ export function stepCommunity(c, world, until, opts = {}) {
614
609
  }
615
610
  }
616
611
  }
617
- const decideOpts = (p, reason, affordances) => {
618
- const o = {};
619
- // Voices: queued conversation advice first (fresher), then the host's standing suggestions that are heard now
620
- // (standing-advice SCOPE); one per voice. At reviews they still weigh in, but `decide` resolves them quietly
621
- // (no counters, pressure or autonomy cost), so one request is not re-counted as many verdicts.
622
- const standing = opts.suggestions?.[p.id];
623
- const merged = [];
624
- const seen = new Set();
625
- const heard = (standing ? (Array.isArray(standing) ? standing : [standing]) : []).filter((s) => standingHeard(c, p, s, affordances, p.now));
626
- for (const s of [...(c.queued?.[p.id] ?? []), ...heard]) {
627
- if (seen.has(s.voiceId))
628
- continue;
629
- seen.add(s.voiceId);
630
- merged.push(s);
612
+ }
613
+ /** Run life-course bookkeeping and the host's day hook at the first event of a new day. */
614
+ function bookkeepDay(p, world, t, dayDone, opts) {
615
+ // First event of a new day: life-course rolls for the days crossed, then the host's day hook.
616
+ const day = dayOf(t);
617
+ const lastDay = dayDone[p.id];
618
+ if (lastDay === undefined || day > lastDay) {
619
+ dayDone[p.id] = day;
620
+ const lc = opts.lifecourse;
621
+ if (lc && lastDay !== undefined && p.body.alive) {
622
+ const dt = (day - lastDay) * MINUTES_PER_DAY;
623
+ if (lc.chronicOnsets) {
624
+ const onsetOpts = {};
625
+ if (lc.conditions)
626
+ onsetOpts.conditions = lc.conditions;
627
+ const ex = lc.exposures?.(p);
628
+ if (ex)
629
+ onsetOpts.exposures = ex;
630
+ for (const ill of chronicOnsets(p, dt, p.rng, onsetOpts))
631
+ sicken(p, ill);
632
+ }
633
+ if (lc.mortality) {
634
+ const roll = mortalityEvent(p, dt, p.rng, lc.multiplier !== undefined ? { multiplier: lc.multiplier } : {});
635
+ if (roll.died)
636
+ die(p);
637
+ }
631
638
  }
632
- const [first, ...rest] = merged;
633
- if (first)
634
- o.suggestion = first;
635
- if (rest.length > 0)
636
- o.suggestions = rest;
637
- const scarcity = world.scarcityFor?.(p);
638
- if (scarcity !== undefined && scarcity > 0)
639
- o.scarcity = scarcity;
640
- if (opts.necessity !== undefined)
641
- o.necessity = opts.necessity;
642
- const forced = reason ?? c.interrupts[p.id]?.reason;
643
- if (forced !== undefined && !p.activity)
644
- o.interrupt = forced;
645
- return o;
639
+ if (p.body.alive)
640
+ world.onDay?.(p, day);
641
+ }
642
+ }
643
+ /** Merge heard advice and the host's decision options in their existing precedence order. */
644
+ function decisionOptions(c, world, p, opts, reason, affordances) {
645
+ const o = {};
646
+ // Voices: queued conversation advice first (fresher), then the host's standing suggestions that are heard now
647
+ // (standing-advice SCOPE); one per voice. At reviews they still weigh in, but `decide` resolves them quietly
648
+ // (no counters, pressure or autonomy cost), so one request is not re-counted as many verdicts.
649
+ const standing = opts.suggestions?.[p.id];
650
+ const merged = [];
651
+ const seen = new Set();
652
+ const heard = (standing ? (Array.isArray(standing) ? standing : [standing]) : []).filter((s) => standingHeard(c, p, s, affordances, p.now));
653
+ for (const s of [...(c.queued?.[p.id] ?? []), ...heard]) {
654
+ if (seen.has(s.voiceId))
655
+ continue;
656
+ seen.add(s.voiceId);
657
+ merged.push(s);
658
+ }
659
+ const [first, ...rest] = merged;
660
+ if (first)
661
+ o.suggestion = first;
662
+ if (rest.length > 0)
663
+ o.suggestions = rest;
664
+ const scarcity = world.scarcityFor?.(p);
665
+ if (scarcity !== undefined && scarcity > 0)
666
+ o.scarcity = scarcity;
667
+ if (opts.necessity !== undefined)
668
+ o.necessity = opts.necessity;
669
+ const forced = reason ?? c.interrupts[p.id]?.reason;
670
+ if (forced !== undefined && !p.activity)
671
+ o.interrupt = forced;
672
+ return o;
673
+ }
674
+ /** Add pending joint mirrors to world offers and apply this minute's declined-offer exclusions. */
675
+ function offersForDecision(c, world, p, t) {
676
+ // Offers: the world's, plus mirrors of joint proposals awaiting this person's answer, minus offers a partner
677
+ // just declined (this minute only).
678
+ const asked = pendingProposals(c).filter((x) => x.partnerIds.includes(p.id) && !x.accepted.includes(p.id));
679
+ const mirrors = new Map();
680
+ let affordances = world.affordancesFor(p);
681
+ for (const x of asked) {
682
+ const m = mirrorFor(world, p, x);
683
+ mirrors.set(m.id, x);
684
+ affordances = [...affordances.filter((a) => a.id !== m.id), m];
685
+ }
686
+ const ex = c.joint.excluded[p.id];
687
+ if (ex) {
688
+ if (ex.at === t)
689
+ affordances = affordances.filter((a) => !ex.ids.includes(a.id));
690
+ else
691
+ delete c.joint.excluded[p.id];
692
+ }
693
+ return { asked, mirrors, affordances };
694
+ }
695
+ /**
696
+ * Advance the community to `until`, processing person events in (time, id) order. Returns the events.
697
+ */
698
+ export function stepCommunity(c, world, until, opts = {}) {
699
+ const events = [];
700
+ const quantum = Math.max(1, opts.idleQuantum ?? SIM_DEFAULTS.idleQuantum);
701
+ const maxEvents = opts.maxEvents ?? SIM_DEFAULTS.maxEvents;
702
+ const salience = opts.interruptSalience ?? SIM_DEFAULTS.interruptSalience;
703
+ // Communities created before joint support (plain JSON from an older host) get the new bookkeeping.
704
+ c.interrupts ??= {};
705
+ c.joint ??= { nextId: 0, proposals: [], excluded: {} };
706
+ const log = (e) => {
707
+ if (events.length < maxEvents)
708
+ events.push(e);
646
709
  };
710
+ const contagion = opts.contagion ?? true;
711
+ c.queued ??= {};
712
+ c.dayDone ??= {};
713
+ synchronizeCommands(c, opts, log);
647
714
  // Pairs whose shared activity already ran its conversation and contagion at `pairMinute` (both partners
648
715
  // finishing the same joint activity at the same minute would otherwise run them twice).
649
716
  let pairMinute = Number.NEGATIVE_INFINITY;
@@ -662,33 +729,7 @@ export function stepCommunity(c, world, until, opts = {}) {
662
729
  break;
663
730
  const p = next;
664
731
  tick(p, t);
665
- // First event of a new day: life-course rolls for the days crossed, then the host's day hook.
666
- const day = dayOf(t);
667
- const lastDay = c.dayDone[p.id];
668
- if (lastDay === undefined || day > lastDay) {
669
- c.dayDone[p.id] = day;
670
- const lc = opts.lifecourse;
671
- if (lc && lastDay !== undefined && p.body.alive) {
672
- const dt = (day - lastDay) * MINUTES_PER_DAY;
673
- if (lc.chronicOnsets) {
674
- const onsetOpts = {};
675
- if (lc.conditions)
676
- onsetOpts.conditions = lc.conditions;
677
- const ex = lc.exposures?.(p);
678
- if (ex)
679
- onsetOpts.exposures = ex;
680
- for (const ill of chronicOnsets(p, dt, p.rng, onsetOpts))
681
- sicken(p, ill);
682
- }
683
- if (lc.mortality) {
684
- const roll = mortalityEvent(p, dt, p.rng, lc.multiplier !== undefined ? { multiplier: lc.multiplier } : {});
685
- if (roll.died)
686
- die(p);
687
- }
688
- }
689
- if (p.body.alive)
690
- world.onDay?.(p, day);
691
- }
732
+ bookkeepDay(p, world, t, c.dayDone, opts);
692
733
  if (!p.body.alive) {
693
734
  log({ at: t, personId: p.id, kind: 'died', detail: `${p.name} died` });
694
735
  if (opts.tellDeaths ?? true)
@@ -758,24 +799,8 @@ export function stepCommunity(c, world, until, opts = {}) {
758
799
  }
759
800
  act = p.activity;
760
801
  }
761
- // Offers: the world's, plus mirrors of joint proposals awaiting this person's answer, minus offers a partner
762
- // just declined (this minute only).
763
- const asked = pendingProposals(c).filter((x) => x.partnerIds.includes(p.id) && !x.accepted.includes(p.id));
764
- const mirrors = new Map();
765
- let affordances = world.affordancesFor(p);
766
- for (const x of asked) {
767
- const m = mirrorFor(world, p, x);
768
- mirrors.set(m.id, x);
769
- affordances = [...affordances.filter((a) => a.id !== m.id), m];
770
- }
771
- const ex = c.joint.excluded[p.id];
772
- if (ex) {
773
- if (ex.at === t)
774
- affordances = affordances.filter((a) => !ex.ids.includes(a.id));
775
- else
776
- delete c.joint.excluded[p.id];
777
- }
778
- const record = decide(p, affordances, decideOpts(p, perceptReason, affordances));
802
+ const { asked, mirrors, affordances } = offersForDecision(c, world, p, t);
803
+ const record = decide(p, affordances, decisionOptions(c, world, p, opts, perceptReason, affordances));
779
804
  delete c.interrupts[p.id];
780
805
  // Conversation advice is weighed until the next fresh decision, then it has been heard.
781
806
  if (!act)
package/dist/types.d.ts CHANGED
@@ -511,7 +511,10 @@ export interface Commitment {
511
511
  status: 'pending' | 'kept' | 'broken' | 'released';
512
512
  /** Optional recurrence: re-create after it closes (e.g. daily prayer windows). */
513
513
  recurEvery?: number;
514
- /** Optional display name, e.g. 'Maghrib'; used in counter-offers ("after I pray Maghrib"). */
514
+ /**
515
+ * Optional display name, e.g. 'Maghrib'; used in counter-offers ("after I pray Maghrib") and in `reviewLine`
516
+ * ("I didn't keep my call to Mum"), so write it as a noun phrase.
517
+ */
515
518
  label?: string;
516
519
  /** For kind 'abstain': action ids whose completion inside the window breaks it. */
517
520
  violatedBy?: string[];
@@ -837,7 +840,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
837
840
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
838
841
  * `dutyReviewAt`). Person shape unchanged.
839
842
  */
840
- export declare const ENGINE_VERSION = "2.8.0";
843
+ export declare const ENGINE_VERSION = "2.9.0";
841
844
  /** A pregnancy carried by this person (host-driven; see `family/`). */
842
845
  export interface Pregnancy {
843
846
  fatherId: PersonId;
@@ -1100,7 +1103,11 @@ export interface Affordance {
1100
1103
  /** Free tags: 'work', 'leisure', 'social', 'worship', 'risky', 'outdoors', ... */
1101
1104
  tags?: string[];
1102
1105
  norms?: NormTag[];
1103
- /** Commitment ids this would fulfil if completed. */
1106
+ /**
1107
+ * Commitment ids this would fulfil if completed. Listing two or more keeps every listed one that is pending and
1108
+ * in window, and no other (2.9.0), not even an earlier-ending match by action; listing one keeps the
1109
+ * earliest-ending match, explicit or by action, as before.
1110
+ */
1104
1111
  fulfills?: string[];
1105
1112
  /** Goal ids this advances. */
1106
1113
  advances?: string[];
@@ -1249,7 +1256,11 @@ export interface Outcome {
1249
1256
  * under protest and 1 otherwise. Lower quality lowers felt valence and therefore learned expectations.
1250
1257
  */
1251
1258
  quality?: Unit;
1252
- /** Commitment ids this outcome keeps (defaults to the activity's `Affordance.fulfills`). */
1259
+ /**
1260
+ * Commitment ids this outcome keeps (defaults to the activity's `Affordance.fulfills`). With two or more listed,
1261
+ * every listed one pending and in window is kept, and no other (2.9.0); with one, one completion keeps the
1262
+ * earliest-ending match.
1263
+ */
1253
1264
  fulfills?: string[];
1254
1265
  /** Goal ids this outcome advances (defaults to the activity's `Affordance.advances`). */
1255
1266
  advances?: string[];
@@ -1333,9 +1344,16 @@ export interface Term {
1333
1344
  * 'goal:g1', 'habit', 'emotion:fear:risky' (emotion id, then the tag it acts through), 'effort', 'risk',
1334
1345
  * 'suggestion:player', 'social:p2', 'expectation', 'autonomy' (reactance to a pushing voice),
1335
1346
  * 'promptings:turn' (a practised turning-away), 'forecast:body' (expected
1336
- * bodily state during `followedBy` spans), 'promptings:echo' (recurrence under the `fault` reading) */
1347
+ * bodily state during `followedBy` spans), 'promptings:echo' (recurrence under the `fault` reading),
1348
+ * 'review:resolve' (a resolve from the evening review) */
1337
1349
  source: string;
1338
1350
  value: number;
1351
+ /**
1352
+ * What a generic source refers to (2.9.0), so `source` stays stable for hosts that match it exactly. Set on
1353
+ * `review:resolve` to the resolve's key (`Resolve.key`) when exactly one resolve weighed in; absent when several
1354
+ * did. Absent on every other term.
1355
+ */
1356
+ ref?: string;
1339
1357
  }
1340
1358
  export interface Considered {
1341
1359
  affordanceId: string;
@@ -1455,6 +1473,13 @@ export interface PromptingResponse {
1455
1473
  /** Skill-like practice 0..1 as of `lastUsedAt`; it decays slowly with disuse. */
1456
1474
  practice: Unit;
1457
1475
  lastUsedAt: Minute;
1476
+ /**
1477
+ * What this response was learned for (2.9.0): an action, an offer tag, or the opposing norm or goal (`n1`,
1478
+ * `norm:n1` or `goal:g1`). A cued response serves only prompts it matches; a response without a cue serves every
1479
+ * prompt that no cued response matches. A norm or goal cue matches only the prompt's strongest opposing term.
1480
+ * Cues longer than 200 characters are ignored.
1481
+ */
1482
+ cue?: string;
1458
1483
  }
1459
1484
  export interface PromptingRecord {
1460
1485
  at: Minute;
@@ -1482,6 +1507,8 @@ export interface PromptingsState {
1482
1507
  }>;
1483
1508
  /** Echo gained per prompt under `fault`. Default 0: the recurrence term is a host-set assumption (P3 contested). */
1484
1509
  echoRate: number;
1510
+ /** Days over which disused practice halves (2.9.0, host-set); absent = `PROMPTINGS_DEFAULTS.practiceHalfLifeDays`. */
1511
+ practiceHalfLifeDays?: number;
1485
1512
  }
1486
1513
  /**
1487
1514
  * How a person reviews a day (2.7.0, a held reading): `accounting` (muhasaba: name the lapse and resolve for its
@@ -1528,12 +1555,21 @@ export interface ReviewSpec {
1528
1555
  export interface PromptingsSpec {
1529
1556
  reading?: PromptingReading;
1530
1557
  readingSource?: string;
1558
+ /** `cue` (2.9.0): what the response was learned for; see `PromptingResponse.cue`. */
1531
1559
  responses?: {
1532
1560
  id: string;
1533
1561
  kind: PromptingResponseKind;
1534
1562
  practice?: Unit;
1563
+ cue?: string;
1535
1564
  }[];
1536
1565
  echoRate?: number;
1566
+ /**
1567
+ * Days over which disused practice halves (2.9.0), for hosts whose time runs short (a one-week game). Applies to
1568
+ * every response of the person, including disuse since a response's last use. Absent = 60 days, as before.
1569
+ * Read on every `enablePromptings` call: re-enabling without it returns the person to the default. Values outside
1570
+ * (0, 36500] are ignored.
1571
+ */
1572
+ practiceHalfLifeDays?: number;
1537
1573
  }
1538
1574
  /** What the body is doing over an interval. */
1539
1575
  export interface BodyLoad {
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.8.0';
123
+ export const ENGINE_VERSION = '2.9.0';
package/dist/will/will.js CHANGED
@@ -562,10 +562,49 @@ const voiceTerm = (c, voiceId) => c?.terms.find((t) => t.source === `suggestion:
562
562
  * group-advice findings only in shape (advice is weighed by trust in each adviser, not by headcount); it does not model conformity to a majority, persuasion between the voices, or the person asking for advice.
563
563
  */
564
564
  function evaluate(p, input, ctx, given, draw) {
565
- const W = WILL_DEFAULTS;
566
565
  const cmd = ctx.command;
567
566
  // A commanding voice is not also weighed as a suggestion in the same decision.
568
567
  const suggestions = cmd ? given.filter((s) => s.voiceId !== cmd.voiceId) : given;
568
+ const prepared = prepareVetoes(p, input, ctx, suggestions);
569
+ const { considered, affById } = prepared;
570
+ const selection = selectOption(p, considered, ctx, draw);
571
+ const ev = {
572
+ chosenId: selection.winner?.affordanceId ?? null,
573
+ considered,
574
+ resolutions: [],
575
+ autonomyDelta: 0,
576
+ voices: [],
577
+ };
578
+ if (cmd && arbitrateCommand(p, ctx, cmd, suggestions, prepared, selection, ev))
579
+ return ev;
580
+ if (suggestions.length === 0)
581
+ return withCommandRefusal(ev, cmd);
582
+ if (inBreak(p)) {
583
+ // During a mental break no voice reaches them; nothing is counted for or against any of them.
584
+ ev.resolutions = suggestions.map((s) => ({
585
+ voiceId: s.voiceId,
586
+ verdict: 'refused',
587
+ kind: 'cannot',
588
+ reason: 'break',
589
+ says: '',
590
+ }));
591
+ ev.voices = suggestions.map((s) => ({ id: s.voiceId, pressure: 0, accepted: 0, refused: 0 }));
592
+ if (ev.resolutions.length === 1)
593
+ ev.suggestion = ev.resolutions[0];
594
+ return withCommandRefusal(ev, cmd);
595
+ }
596
+ const judged = judgeVoices(p, ctx, suggestions, prepared, selection);
597
+ arbitrateInsistence(p, judged, considered, affById, ev);
598
+ ev.resolutions = judged.map((j) => j.res);
599
+ ev.voices = judged.map((j) => j.side);
600
+ const chosen = considered.find((c) => c.affordanceId === ev.chosenId);
601
+ ev.suggestion =
602
+ creditedResolution(ev.resolutions, chosen) ??
603
+ (ev.resolutions.length === 1 ? ev.resolutions[0] : undefined);
604
+ return withCommandRefusal(ev, cmd);
605
+ }
606
+ /** Apply capacity, conscience, per-voice distrust and omission vetoes, in that order. */
607
+ function prepareVetoes(p, input, ctx, suggestions) {
569
608
  const affById = new Map(ctx.affordances.map((a) => [a.id, a]));
570
609
  const duties = closingDuties(p, ctx);
571
610
  const servesDuty = (aff) => duties.some((c) => keepsDuty(p, c, aff, ctx.now));
@@ -609,6 +648,11 @@ function evaluate(p, input, ctx, given, draw) {
609
648
  out.vetoed = veto;
610
649
  return out;
611
650
  });
651
+ return { affById, duties, baseVeto, distrustOf, considered };
652
+ }
653
+ /** Select once using the supplied draw; prediction passes no draw and stays deterministic. */
654
+ function selectOption(p, considered, ctx, draw) {
655
+ const W = WILL_DEFAULTS;
612
656
  // Selection: inertia for the current activity, then argmax or softmax.
613
657
  const live = considered.filter((c) => !c.vetoed);
614
658
  const current = p.activity;
@@ -636,67 +680,55 @@ function evaluate(p, input, ctx, given, draw) {
636
680
  }
637
681
  }
638
682
  }
639
- const ev = {
640
- chosenId: winner?.affordanceId ?? null,
641
- considered,
642
- resolutions: [],
643
- autonomyDelta: 0,
644
- voices: [],
645
- };
646
- if (cmd) {
647
- const co = commandOutcome(p, considered, ctx, cmd, winner, score, baseVeto, duties);
648
- ev.command = co;
649
- if (co.holds && co.targetAffordanceId !== undefined) {
650
- // Direct control: the commanded option is done; every other voice is set aside (no counters) and a
651
- // distrust veto another voice caused does not stand in the way.
652
- ev.chosenId = co.targetAffordanceId;
653
- for (const c of considered)
654
- if (c.vetoed?.reason === 'distrust')
655
- delete c.vetoed;
656
- const res = {
657
- voiceId: cmd.voiceId,
658
- verdict: 'commanded',
659
- reason: 'command',
660
- says: '',
661
- margin: co.margin,
662
- };
663
- if (co.ownAffordanceId !== null && co.ownAffordanceId !== co.targetAffordanceId)
664
- res.insteadAffordanceId = co.ownAffordanceId;
665
- const all = [
666
- res,
667
- ...suggestions.map((s) => ({
668
- voiceId: s.voiceId,
669
- verdict: 'refused',
670
- kind: 'cannot',
671
- reason: 'commanded',
672
- says: '',
673
- })),
674
- ].sort((a, b) => cmpStr(a.voiceId, b.voiceId));
675
- ev.resolutions = all;
676
- ev.voices = all.map((r) => ({ id: r.voiceId, pressure: 0, accepted: 0, refused: 0 }));
677
- ev.suggestion = res;
678
- return ev;
679
- }
680
- }
681
- if (suggestions.length === 0)
682
- return withCommandRefusal(ev, cmd);
683
- if (inBreak(p)) {
684
- // During a mental break no voice reaches them; nothing is counted for or against any of them.
685
- ev.resolutions = suggestions.map((s) => ({
686
- voiceId: s.voiceId,
687
- verdict: 'refused',
688
- kind: 'cannot',
689
- reason: 'break',
683
+ return { winner, ranked, score };
684
+ }
685
+ /** Return true when a command holds and completes the evaluation ahead of all suggestions. */
686
+ function arbitrateCommand(p, ctx, cmd, suggestions, prepared, selection, ev) {
687
+ const { considered, baseVeto, duties } = prepared;
688
+ const { winner, score } = selection;
689
+ const co = commandOutcome(p, considered, ctx, cmd, winner, score, baseVeto, duties);
690
+ ev.command = co;
691
+ if (co.holds && co.targetAffordanceId !== undefined) {
692
+ // Direct control: the commanded option is done; every other voice is set aside (no counters) and a
693
+ // distrust veto another voice caused does not stand in the way.
694
+ ev.chosenId = co.targetAffordanceId;
695
+ for (const c of considered)
696
+ if (c.vetoed?.reason === 'distrust')
697
+ delete c.vetoed;
698
+ const res = {
699
+ voiceId: cmd.voiceId,
700
+ verdict: 'commanded',
701
+ reason: 'command',
690
702
  says: '',
691
- }));
692
- ev.voices = suggestions.map((s) => ({ id: s.voiceId, pressure: 0, accepted: 0, refused: 0 }));
693
- if (ev.resolutions.length === 1)
694
- ev.suggestion = ev.resolutions[0];
695
- return withCommandRefusal(ev, cmd);
703
+ margin: co.margin,
704
+ };
705
+ if (co.ownAffordanceId !== null && co.ownAffordanceId !== co.targetAffordanceId)
706
+ res.insteadAffordanceId = co.ownAffordanceId;
707
+ const all = [
708
+ res,
709
+ ...suggestions.map((s) => ({
710
+ voiceId: s.voiceId,
711
+ verdict: 'refused',
712
+ kind: 'cannot',
713
+ reason: 'commanded',
714
+ says: '',
715
+ })),
716
+ ].sort((a, b) => cmpStr(a.voiceId, b.voiceId));
717
+ ev.resolutions = all;
718
+ ev.voices = all.map((r) => ({ id: r.voiceId, pressure: 0, accepted: 0, refused: 0 }));
719
+ ev.suggestion = res;
720
+ return true;
696
721
  }
722
+ return false;
723
+ }
724
+ /** Judge each voice against the same selected option before resolving competing insistence. */
725
+ function judgeVoices(p, ctx, suggestions, prepared, selection) {
726
+ const W = WILL_DEFAULTS;
727
+ const { considered, affById, distrustOf } = prepared;
728
+ const { winner, ranked, score } = selection;
697
729
  const winnerAff = winner ? affById.get(winner.affordanceId) : undefined;
698
730
  const reason = dominantTerm(winner);
699
- const judged = suggestions.map((s) => {
731
+ return suggestions.map((s) => {
700
732
  const suggested = considered.filter((c) => {
701
733
  const aff = affById.get(c.affordanceId);
702
734
  return aff !== undefined && suggestionTargets(s, aff);
@@ -815,6 +847,10 @@ function evaluate(p, input, ctx, given, draw) {
815
847
  : { affordanceId: bestSuggested.affordanceId, label: 'later' },
816
848
  }));
817
849
  });
850
+ }
851
+ /** Settle competing insistence in the existing voice order, preserving equal-trust/strength ties. */
852
+ function arbitrateInsistence(p, judged, considered, affById, ev) {
853
+ const W = WILL_DEFAULTS;
818
854
  // Rule (a): at most one insisted option is complied with.
819
855
  const insisting = judged.filter((j) => j.comply !== undefined);
820
856
  let forcer;
@@ -882,13 +918,6 @@ function evaluate(p, input, ctx, given, draw) {
882
918
  }
883
919
  }
884
920
  }
885
- ev.resolutions = judged.map((j) => j.res);
886
- ev.voices = judged.map((j) => j.side);
887
- const chosen = considered.find((c) => c.affordanceId === ev.chosenId);
888
- ev.suggestion =
889
- creditedResolution(ev.resolutions, chosen) ??
890
- (ev.resolutions.length === 1 ? ev.resolutions[0] : undefined);
891
- return withCommandRefusal(ev, cmd);
892
921
  }
893
922
  /** Add the commanding voice's refusal (a command that did not hold) to the resolutions, in voice-id order. */
894
923
  function withCommandRefusal(ev, cmd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.8.0",
3
+ "version": "2.9.1",
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",