@jgengine/core 0.26.0 → 0.28.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 (143) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/ai/policeResponse.d.ts +253 -0
  3. package/dist/ai/policeResponse.js +421 -0
  4. package/dist/anim/actionLayer.d.ts +47 -0
  5. package/dist/anim/actionLayer.js +63 -0
  6. package/dist/anim/ikSolver.d.ts +14 -0
  7. package/dist/anim/ikSolver.js +37 -0
  8. package/dist/anim/locomotionGraph.d.ts +58 -3
  9. package/dist/anim/locomotionGraph.js +142 -15
  10. package/dist/anim/ragdollRig.d.ts +53 -0
  11. package/dist/anim/ragdollRig.js +132 -0
  12. package/dist/anim/skeletonStandard.d.ts +140 -0
  13. package/dist/anim/skeletonStandard.js +326 -0
  14. package/dist/combat/damageRuntime.d.ts +14 -1
  15. package/dist/combat/damageRuntime.js +62 -4
  16. package/dist/combat/death.js +7 -3
  17. package/dist/combat/deathReason.d.ts +7 -1
  18. package/dist/combat/effects.d.ts +25 -0
  19. package/dist/combat/effects.js +16 -0
  20. package/dist/combat/statusRuntime.d.ts +2 -0
  21. package/dist/combat/statusRuntime.js +1 -0
  22. package/dist/economy/loanBook.d.ts +182 -0
  23. package/dist/economy/loanBook.js +303 -0
  24. package/dist/economy/market.d.ts +161 -0
  25. package/dist/economy/market.js +248 -0
  26. package/dist/economy/transactionLedger.d.ts +162 -0
  27. package/dist/economy/transactionLedger.js +301 -0
  28. package/dist/editor/creatorStorage.js +3 -1
  29. package/dist/editor/simulation.d.ts +5 -2
  30. package/dist/editor/simulation.js +29 -19
  31. package/dist/game/cameraConfig.d.ts +14 -0
  32. package/dist/game/clipRoles.d.ts +5 -4
  33. package/dist/game/clipRoles.js +34 -4
  34. package/dist/game/events.d.ts +27 -0
  35. package/dist/game/gameSave.d.ts +142 -0
  36. package/dist/game/gameSave.js +174 -0
  37. package/dist/game/playableGame.d.ts +74 -6
  38. package/dist/game/saveSlotIndex.d.ts +54 -0
  39. package/dist/game/saveSlotIndex.js +71 -0
  40. package/dist/game/saveSlots.d.ts +2 -0
  41. package/dist/game/saveSlots.js +1 -1
  42. package/dist/game/saveStore.d.ts +8 -0
  43. package/dist/game/saveStore.js +16 -0
  44. package/dist/gameplay.d.ts +17 -2
  45. package/dist/gameplay.js +15 -0
  46. package/dist/inventory/carryLoad.d.ts +48 -0
  47. package/dist/inventory/carryLoad.js +44 -0
  48. package/dist/inventory/equipment.d.ts +95 -0
  49. package/dist/inventory/equipment.js +169 -0
  50. package/dist/inventory/inventoryModel.d.ts +15 -6
  51. package/dist/inventory/inventoryModel.js +54 -5
  52. package/dist/inventory/shapedGrid.d.ts +26 -0
  53. package/dist/inventory/shapedGrid.js +47 -0
  54. package/dist/loot/lootGen.d.ts +186 -0
  55. package/dist/loot/lootGen.js +437 -0
  56. package/dist/loot/lootWeapon.d.ts +35 -0
  57. package/dist/loot/lootWeapon.js +70 -0
  58. package/dist/meta/changelog.d.ts +1 -1
  59. package/dist/meta/changelog.js +156 -1
  60. package/dist/mission/compositions.d.ts +85 -0
  61. package/dist/mission/compositions.js +130 -0
  62. package/dist/mission/mission.d.ts +435 -0
  63. package/dist/mission/mission.js +770 -0
  64. package/dist/mission/triggers.d.ts +61 -0
  65. package/dist/mission/triggers.js +108 -0
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/traffic.d.ts +222 -0
  69. package/dist/nav/traffic.js +918 -0
  70. package/dist/physics/kinematicVehicle.d.ts +36 -0
  71. package/dist/physics/kinematicVehicle.js +15 -1
  72. package/dist/physics/vehicleDamage.d.ts +136 -0
  73. package/dist/physics/vehicleDamage.js +214 -0
  74. package/dist/physics/vehicleParts.d.ts +62 -0
  75. package/dist/physics/vehicleParts.js +65 -0
  76. package/dist/progression/gearScore.d.ts +56 -0
  77. package/dist/progression/gearScore.js +59 -0
  78. package/dist/progression/skillTree.d.ts +129 -0
  79. package/dist/progression/skillTree.js +265 -0
  80. package/dist/progression/xpCurve.d.ts +60 -0
  81. package/dist/progression/xpCurve.js +141 -0
  82. package/dist/runtime/context/combat.js +11 -0
  83. package/dist/scene/builtinSceneKinds.js +2 -0
  84. package/dist/settings/accessibilitySettings.d.ts +81 -0
  85. package/dist/settings/accessibilitySettings.js +152 -0
  86. package/dist/settings/graphicsProfile.d.ts +3 -0
  87. package/dist/settings/graphicsProfile.js +4 -0
  88. package/dist/settings/settingsModel.d.ts +1 -1
  89. package/dist/settings/settingsModel.js +1 -0
  90. package/dist/sim/nearAgents.d.ts +8 -1
  91. package/dist/sim/nearAgents.js +34 -17
  92. package/dist/sim/offScreen.d.ts +2 -1
  93. package/dist/sim/offScreen.js +14 -3
  94. package/dist/sim/population.d.ts +147 -0
  95. package/dist/sim/population.js +182 -0
  96. package/dist/sim/queue.d.ts +90 -0
  97. package/dist/sim/queue.js +171 -0
  98. package/dist/sim/rides.d.ts +287 -0
  99. package/dist/sim/rides.js +471 -0
  100. package/dist/sim/routine.js +19 -11
  101. package/dist/ui/loadingTips.d.ts +41 -0
  102. package/dist/ui/loadingTips.js +64 -0
  103. package/dist/ui/menuNav.d.ts +79 -0
  104. package/dist/ui/menuNav.js +147 -0
  105. package/dist/ui/menuTransition.d.ts +50 -0
  106. package/dist/ui/menuTransition.js +67 -0
  107. package/dist/ui.d.ts +2 -0
  108. package/dist/ui.js +2 -0
  109. package/dist/vfx/effectLibrary.d.ts +98 -0
  110. package/dist/vfx/effectLibrary.js +181 -0
  111. package/dist/vfx/particleBudget.d.ts +40 -0
  112. package/dist/vfx/particleBudget.js +39 -0
  113. package/dist/vfx/particleDirector.d.ts +16 -0
  114. package/dist/vfx/particleDirector.js +18 -0
  115. package/dist/world/authoredSimulation.js +7 -1
  116. package/dist/world/captureCamera.d.ts +30 -0
  117. package/dist/world/captureCamera.js +19 -0
  118. package/dist/world/decalKind.d.ts +48 -0
  119. package/dist/world/decalKind.js +86 -0
  120. package/dist/world/features.d.ts +18 -5
  121. package/dist/world/features.js +1 -0
  122. package/dist/world/grassKind.d.ts +5 -0
  123. package/dist/world/grassKind.js +3 -0
  124. package/dist/world/terraform.d.ts +8 -0
  125. package/dist/world/terrain.d.ts +1 -0
  126. package/dist/world/terrain.js +2 -0
  127. package/dist/world.d.ts +7 -2
  128. package/dist/world.js +5 -0
  129. package/package.json +1 -1
  130. package/skills/jgengine/api.md +2 -2
  131. package/skills/jgengine-combat/SKILL.md +9 -1
  132. package/skills/jgengine-combat/api.md +5 -4
  133. package/skills/jgengine-combat/reference.md +4 -0
  134. package/skills/jgengine-gameplay/SKILL.md +86 -0
  135. package/skills/jgengine-gameplay/api.md +485 -16
  136. package/skills/jgengine-gameplay/capabilities.md +131 -4
  137. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  138. package/skills/jgengine-world/SKILL.md +8 -0
  139. package/skills/jgengine-world/api.md +243 -11
  140. package/skills/jgengine-world/capabilities.md +61 -1
  141. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  142. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  143. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,182 @@
1
+ import type { TransactionLedger } from "./transactionLedger.js";
2
+ /**
3
+ * Money in this module is an integer count of **minor units** — cents when the currency has two
4
+ * decimals, whole coins when it has none. Principal, interest, payments and balances are safe
5
+ * integers, so a schedule adds up exactly.
6
+ */
7
+ /** Terms of one loan. Everything a repayment schedule needs, as plain data. */
8
+ export interface LoanTerms {
9
+ /** Stable id, unique within a loan book. */
10
+ id: string;
11
+ /** Integer minor units borrowed; must be `> 0`. */
12
+ principal: number;
13
+ /** Interest per period as a fraction of the balance, e.g. `0.05` for 5%. */
14
+ ratePerPeriod: number;
15
+ /** Number of scheduled payments; integer `>= 1`. */
16
+ periods: number;
17
+ /** Seconds between scheduled payments; must be `> 0`. */
18
+ periodSeconds: number;
19
+ /** When the first payment is due, in seconds. */
20
+ startSeconds: number;
21
+ /**
22
+ * `"amortized"` (default) charges interest on the outstanding balance and levels the payment;
23
+ * `"flat"` charges interest on the original principal and splits principal evenly.
24
+ */
25
+ method?: "amortized" | "flat";
26
+ /** Currency the loan is denominated in; falls back to the book's default. */
27
+ currency?: string;
28
+ /** Account that owes and pays; falls back to the book's default. */
29
+ account?: string;
30
+ }
31
+ /** One scheduled repayment, in integer minor units. */
32
+ export interface LoanPayment {
33
+ /** 1-based. */
34
+ index: number;
35
+ dueSeconds: number;
36
+ interest: number;
37
+ principal: number;
38
+ /** `interest + principal`. */
39
+ amount: number;
40
+ /** Remaining principal after this payment. */
41
+ balanceAfter: number;
42
+ }
43
+ /** A full repayment plan: every payment, its interest/principal split and the running balance. */
44
+ export interface LoanSchedule {
45
+ id: string;
46
+ principal: number;
47
+ method: "amortized" | "flat";
48
+ payments: readonly LoanPayment[];
49
+ /** Sum of every payment's interest. */
50
+ totalInterest: number;
51
+ /** Sum of every payment. */
52
+ totalPaid: number;
53
+ }
54
+ /** One live loan: its plan plus how far it has been paid. */
55
+ export interface LoanRecord {
56
+ id: string;
57
+ /** The terms it was drawn on, kept whole so a save stores them verbatim. */
58
+ terms: LoanTerms;
59
+ principal: number;
60
+ ratePerPeriod: number;
61
+ periods: number;
62
+ currency: string;
63
+ account: string;
64
+ schedule: LoanSchedule;
65
+ /** Payments settled so far. */
66
+ paid: number;
67
+ /** Total settled, in minor units, including interest. */
68
+ paidAmount: number;
69
+ /** Interest settled so far, in minor units. */
70
+ interestPaid: number;
71
+ /** Scheduled payments that came due with no funds, counted once per advance. */
72
+ missed: number;
73
+ /** True once `missed` reached the book's limit; a defaulted loan stops being advanced. */
74
+ defaulted: boolean;
75
+ /** True once the principal is fully repaid, early or on schedule. */
76
+ settled: boolean;
77
+ /** Remaining principal, in minor units. */
78
+ outstanding: number;
79
+ }
80
+ /** Saved progress of one loan; the schedule is rebuilt from its terms on restore. */
81
+ export interface LoanState {
82
+ terms: LoanTerms;
83
+ paid: number;
84
+ paidAmount: number;
85
+ interestPaid: number;
86
+ missed: number;
87
+ defaulted: boolean;
88
+ settled: boolean;
89
+ outstanding: number;
90
+ }
91
+ /** Serializable loan-book state. */
92
+ export interface LoanBookState {
93
+ loans: readonly LoanState[];
94
+ }
95
+ /** What one {@link LoanBook.advance} settled, and what it could not. */
96
+ export interface LoanAdvanceResult {
97
+ paid: readonly LoanPayment[];
98
+ /** Payments that came due with no funds; each counts once per advance. */
99
+ missed: readonly {
100
+ id: string;
101
+ index: number;
102
+ amount: number;
103
+ }[];
104
+ /** Loans that reached the miss limit during this advance. */
105
+ defaulted: readonly string[];
106
+ }
107
+ /** Counters of a loan book, for a probe or a summary line. */
108
+ export interface LoanBookStats {
109
+ loans: number;
110
+ active: number;
111
+ defaulted: number;
112
+ settled: number;
113
+ /** Remaining principal across every loan, in minor units. */
114
+ outstanding: number;
115
+ paidAmount: number;
116
+ interestPaid: number;
117
+ missed: number;
118
+ }
119
+ /** Outcome of an early repayment. */
120
+ export interface LoanRepayment {
121
+ ok: boolean;
122
+ /** Minor units actually applied to principal. */
123
+ applied: number;
124
+ outstanding: number;
125
+ settled: boolean;
126
+ }
127
+ /** Options for {@link createLoanBook}. */
128
+ export interface LoanBookOptions {
129
+ /** The books every draw, payment and repayment is posted through. */
130
+ ledger: TransactionLedger;
131
+ /** Default currency for loans that do not name one. */
132
+ currency?: string;
133
+ /** Default owing account for loans that do not name one. */
134
+ account?: string;
135
+ /** Due payments that may go unsettled before a loan is defaulted (default `3`). */
136
+ maxMissed?: number;
137
+ /** Register a devtools probe under this name reporting {@link LoanBookStats}. */
138
+ devtoolsName?: string;
139
+ }
140
+ /** The loans a business owes: schedules, interest, arrears and default, posted to a ledger. */
141
+ export interface LoanBook {
142
+ /** Draw a loan: posts the principal to the borrower's account and schedules the repayments. `null` for unusable terms or an unknown account. */
143
+ originate(terms: LoanTerms): LoanRecord | null;
144
+ /** Settle every payment due at or before `nowSeconds`, in loan-id order. */
145
+ advance(nowSeconds: number): LoanAdvanceResult;
146
+ /** Pay principal early. Interest already scheduled is unaffected. */
147
+ repay(loanId: string, amount: number, atSeconds: number): LoanRepayment | null;
148
+ outstanding(loanId: string): number;
149
+ /** The next payment due, or `null` when the loan is settled or paid off. */
150
+ nextPayment(loanId: string): LoanPayment | null;
151
+ scheduleOf(loanId: string): LoanSchedule | null;
152
+ loan(loanId: string): LoanRecord | null;
153
+ loans(): readonly LoanRecord[];
154
+ snapshot(): LoanBookState;
155
+ /** Replace state; returns `false` without mutation when the state is malformed. */
156
+ restore(next: LoanBookState): boolean;
157
+ stats(): LoanBookStats;
158
+ reset(): void;
159
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
160
+ dispose(): void;
161
+ }
162
+ /**
163
+ * Build a loan's repayment schedule: one entry per period with its due time, interest/principal
164
+ * split and the balance left, in integer minor units. An amortized loan levels the payment over
165
+ * the outstanding balance (`P·i / (1 − (1+i)^−n)`); a flat loan charges interest on the original
166
+ * principal. Either way the final payment absorbs the rounding, so the principal parts sum to the
167
+ * principal exactly and the balance ends at zero. Deterministic and pure.
168
+ *
169
+ * @capability loan-schedule build a loan's interest and principal repayment schedule in integer minor units
170
+ */
171
+ export declare function buildLoanSchedule(terms: LoanTerms): LoanSchedule;
172
+ /**
173
+ * The loans a business owes, over a {@link TransactionLedger}: each draw posts the principal to the
174
+ * borrower's account, each settled payment posts its interest (`"loan-interest"`) and principal
175
+ * (`"loan-principal"`) as separate reason-tagged entries, and an early repayment posts
176
+ * `"loan-repayment"`. A payment that comes due with no funds is left in arrears and counted once
177
+ * per advance; after `maxMissed` the loan is defaulted and stops being advanced. State is plain
178
+ * data — `snapshot()` stores terms and progress, and the schedule is rebuilt from the terms.
179
+ *
180
+ * @capability loans draw, schedule, service and default a loan with interest, posted to a ledger
181
+ */
182
+ export declare function createLoanBook(options: LoanBookOptions): LoanBook;
@@ -0,0 +1,303 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ function check(condition, message) {
3
+ if (!condition)
4
+ throw new RangeError(message);
5
+ }
6
+ function assertTerms(terms) {
7
+ check(terms.id.trim() !== "", 'Loan field "id" must not be empty');
8
+ check(Number.isSafeInteger(terms.principal) && terms.principal > 0, `Loan "${terms.id}" principal must be a positive integer in minor units`);
9
+ check(Number.isFinite(terms.ratePerPeriod) && terms.ratePerPeriod >= 0, `Loan "${terms.id}" ratePerPeriod must be a non-negative finite number`);
10
+ check(Number.isInteger(terms.periods) && terms.periods >= 1, `Loan "${terms.id}" periods must be an integer >= 1`);
11
+ check(Number.isFinite(terms.periodSeconds) && terms.periodSeconds > 0, `Loan "${terms.id}" periodSeconds must be positive`);
12
+ check(Number.isFinite(terms.startSeconds) && terms.startSeconds >= 0, `Loan "${terms.id}" startSeconds must be a non-negative finite number`);
13
+ const method = terms.method ?? "amortized";
14
+ check(method === "amortized" || method === "flat", `Loan "${terms.id}" method must be "amortized" or "flat"`);
15
+ }
16
+ /**
17
+ * Build a loan's repayment schedule: one entry per period with its due time, interest/principal
18
+ * split and the balance left, in integer minor units. An amortized loan levels the payment over
19
+ * the outstanding balance (`P·i / (1 − (1+i)^−n)`); a flat loan charges interest on the original
20
+ * principal. Either way the final payment absorbs the rounding, so the principal parts sum to the
21
+ * principal exactly and the balance ends at zero. Deterministic and pure.
22
+ *
23
+ * @capability loan-schedule build a loan's interest and principal repayment schedule in integer minor units
24
+ */
25
+ export function buildLoanSchedule(terms) {
26
+ assertTerms(terms);
27
+ const method = terms.method ?? "amortized";
28
+ const periods = terms.periods;
29
+ const rate = terms.ratePerPeriod;
30
+ const level = method === "amortized"
31
+ ? rate === 0
32
+ ? terms.principal / periods
33
+ : (terms.principal * rate) / (1 - (1 + rate) ** -periods)
34
+ : terms.principal / periods + terms.principal * rate;
35
+ const payment = Math.max(1, Math.round(level));
36
+ const payments = [];
37
+ let balance = terms.principal;
38
+ let totalInterest = 0;
39
+ let totalPaid = 0;
40
+ for (let index = 1; index <= periods; index += 1) {
41
+ const interest = method === "flat" ? Math.round(terms.principal * rate) : Math.round(balance * rate);
42
+ let principalPart = method === "flat" ? Math.floor(terms.principal / periods) : payment - interest;
43
+ if (principalPart <= 0)
44
+ principalPart = 1;
45
+ if (index === periods || principalPart > balance)
46
+ principalPart = balance;
47
+ balance -= principalPart;
48
+ const amount = interest + principalPart;
49
+ totalInterest += interest;
50
+ totalPaid += amount;
51
+ payments.push({
52
+ index,
53
+ dueSeconds: terms.startSeconds + (index - 1) * terms.periodSeconds,
54
+ interest,
55
+ principal: principalPart,
56
+ amount,
57
+ balanceAfter: balance,
58
+ });
59
+ }
60
+ return { id: terms.id, principal: terms.principal, method, payments, totalInterest, totalPaid };
61
+ }
62
+ /**
63
+ * The loans a business owes, over a {@link TransactionLedger}: each draw posts the principal to the
64
+ * borrower's account, each settled payment posts its interest (`"loan-interest"`) and principal
65
+ * (`"loan-principal"`) as separate reason-tagged entries, and an early repayment posts
66
+ * `"loan-repayment"`. A payment that comes due with no funds is left in arrears and counted once
67
+ * per advance; after `maxMissed` the loan is defaulted and stops being advanced. State is plain
68
+ * data — `snapshot()` stores terms and progress, and the schedule is rebuilt from the terms.
69
+ *
70
+ * @capability loans draw, schedule, service and default a loan with interest, posted to a ledger
71
+ */
72
+ export function createLoanBook(options) {
73
+ const ledger = options.ledger;
74
+ const defaultCurrency = options.currency ?? "credits";
75
+ const defaultAccount = options.account ?? "business";
76
+ const maxMissed = Math.max(1, Math.floor(options.maxMissed ?? 3));
77
+ const records = new Map();
78
+ const probe = options.devtoolsName === undefined
79
+ ? null
80
+ : devtools.probes.register(options.devtoolsName, () => readStats());
81
+ function readStats() {
82
+ let active = 0;
83
+ let defaulted = 0;
84
+ let settled = 0;
85
+ let outstanding = 0;
86
+ let paidAmount = 0;
87
+ let interestPaid = 0;
88
+ let missed = 0;
89
+ for (const loan of records.values()) {
90
+ if (loan.defaulted)
91
+ defaulted += 1;
92
+ else if (loan.settled)
93
+ settled += 1;
94
+ else
95
+ active += 1;
96
+ outstanding += loan.outstanding;
97
+ paidAmount += loan.paidAmount;
98
+ interestPaid += loan.interestPaid;
99
+ missed += loan.missed;
100
+ }
101
+ return { loans: records.size, active, defaulted, settled, outstanding, paidAmount, interestPaid, missed };
102
+ }
103
+ function settle(loan, payment, at) {
104
+ const principalPart = Math.min(payment.principal, loan.outstanding);
105
+ const total = payment.interest + principalPart;
106
+ if (total <= 0)
107
+ return true;
108
+ if (ledger.balanceOf(loan.account, loan.currency) < total)
109
+ return false;
110
+ if (payment.interest > 0) {
111
+ ledger.post({ at, account: loan.account, currency: loan.currency, amount: -payment.interest, reason: "loan-interest", ref: loan.id });
112
+ }
113
+ if (principalPart > 0) {
114
+ ledger.post({ at, account: loan.account, currency: loan.currency, amount: -principalPart, reason: "loan-principal", ref: loan.id });
115
+ }
116
+ loan.outstanding -= principalPart;
117
+ loan.paidAmount += total;
118
+ loan.interestPaid += payment.interest;
119
+ loan.paid += 1;
120
+ if (loan.outstanding <= 0) {
121
+ loan.outstanding = 0;
122
+ loan.settled = true;
123
+ }
124
+ return true;
125
+ }
126
+ return {
127
+ originate(terms) {
128
+ try {
129
+ assertTerms(terms);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ if (records.has(terms.id))
135
+ return null;
136
+ const currency = terms.currency ?? defaultCurrency;
137
+ const account = terms.account ?? defaultAccount;
138
+ const draw = ledger.post({ at: terms.startSeconds, account, currency, amount: terms.principal, reason: "loan-draw", ref: terms.id });
139
+ if (!draw.ok)
140
+ return null;
141
+ const schedule = buildLoanSchedule(terms);
142
+ const loan = {
143
+ id: terms.id,
144
+ terms: { ...terms, currency, account },
145
+ principal: terms.principal,
146
+ ratePerPeriod: terms.ratePerPeriod,
147
+ periods: terms.periods,
148
+ currency,
149
+ account,
150
+ schedule,
151
+ paid: 0,
152
+ paidAmount: 0,
153
+ interestPaid: 0,
154
+ missed: 0,
155
+ defaulted: false,
156
+ settled: false,
157
+ outstanding: terms.principal,
158
+ };
159
+ records.set(loan.id, loan);
160
+ return { ...loan };
161
+ },
162
+ advance(nowSeconds) {
163
+ const paid = [];
164
+ const missed = [];
165
+ const defaulted = [];
166
+ for (const id of [...records.keys()].sort()) {
167
+ const loan = records.get(id);
168
+ if (loan.settled || loan.defaulted)
169
+ continue;
170
+ for (;;) {
171
+ const payment = loan.schedule.payments[loan.paid];
172
+ if (payment === undefined || payment.dueSeconds > nowSeconds)
173
+ break;
174
+ if (settle(loan, payment, payment.dueSeconds)) {
175
+ paid.push(payment);
176
+ if (loan.settled)
177
+ break;
178
+ continue;
179
+ }
180
+ loan.missed += 1;
181
+ missed.push({ id: loan.id, index: payment.index, amount: payment.amount });
182
+ if (loan.missed >= maxMissed) {
183
+ loan.defaulted = true;
184
+ defaulted.push(loan.id);
185
+ }
186
+ break;
187
+ }
188
+ }
189
+ return { paid, missed, defaulted };
190
+ },
191
+ repay(loanId, amount, atSeconds) {
192
+ const loan = records.get(loanId);
193
+ if (loan === undefined || loan.settled || loan.defaulted)
194
+ return null;
195
+ if (!Number.isSafeInteger(amount) || amount <= 0)
196
+ return null;
197
+ const applied = Math.min(amount, loan.outstanding);
198
+ const result = ledger.post({ at: atSeconds, account: loan.account, currency: loan.currency, amount: -applied, reason: "loan-repayment", ref: loan.id });
199
+ if (!result.ok)
200
+ return { ok: false, applied: 0, outstanding: loan.outstanding, settled: loan.settled };
201
+ loan.outstanding -= applied;
202
+ loan.paidAmount += applied;
203
+ if (loan.outstanding <= 0) {
204
+ loan.outstanding = 0;
205
+ loan.settled = true;
206
+ }
207
+ return { ok: true, applied, outstanding: loan.outstanding, settled: loan.settled };
208
+ },
209
+ outstanding(loanId) {
210
+ return records.get(loanId)?.outstanding ?? 0;
211
+ },
212
+ nextPayment(loanId) {
213
+ const loan = records.get(loanId);
214
+ if (loan === undefined || loan.settled)
215
+ return null;
216
+ return loan.schedule.payments[loan.paid] ?? null;
217
+ },
218
+ scheduleOf(loanId) {
219
+ return records.get(loanId)?.schedule ?? null;
220
+ },
221
+ loan(loanId) {
222
+ const loan = records.get(loanId);
223
+ return loan === undefined ? null : { ...loan };
224
+ },
225
+ loans() {
226
+ return Array.from(records.values(), (loan) => ({ ...loan }));
227
+ },
228
+ snapshot() {
229
+ return {
230
+ loans: Array.from(records.values(), (loan) => ({
231
+ terms: { ...loan.terms },
232
+ paid: loan.paid,
233
+ paidAmount: loan.paidAmount,
234
+ interestPaid: loan.interestPaid,
235
+ missed: loan.missed,
236
+ defaulted: loan.defaulted,
237
+ settled: loan.settled,
238
+ outstanding: loan.outstanding,
239
+ })),
240
+ };
241
+ },
242
+ restore(next) {
243
+ if (!Array.isArray(next.loans))
244
+ return false;
245
+ const restored = new Map();
246
+ for (const state of next.loans) {
247
+ if (!Number.isInteger(state.paid) || state.paid < 0)
248
+ return false;
249
+ if (!Number.isInteger(state.paidAmount) || state.paidAmount < 0)
250
+ return false;
251
+ if (!Number.isInteger(state.interestPaid) || state.interestPaid < 0)
252
+ return false;
253
+ if (!Number.isInteger(state.missed) || state.missed < 0)
254
+ return false;
255
+ if (typeof state.defaulted !== "boolean" || typeof state.settled !== "boolean")
256
+ return false;
257
+ if (!Number.isSafeInteger(state.outstanding) || state.outstanding < 0)
258
+ return false;
259
+ let schedule;
260
+ try {
261
+ schedule = buildLoanSchedule(state.terms);
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ if (state.paid > schedule.payments.length)
267
+ return false;
268
+ if (state.outstanding > state.terms.principal)
269
+ return false;
270
+ if (restored.has(state.terms.id))
271
+ return false;
272
+ restored.set(state.terms.id, {
273
+ id: state.terms.id,
274
+ terms: { ...state.terms },
275
+ principal: state.terms.principal,
276
+ ratePerPeriod: state.terms.ratePerPeriod,
277
+ periods: state.terms.periods,
278
+ currency: state.terms.currency ?? defaultCurrency,
279
+ account: state.terms.account ?? defaultAccount,
280
+ schedule,
281
+ paid: state.paid,
282
+ paidAmount: state.paidAmount,
283
+ interestPaid: state.interestPaid,
284
+ missed: state.missed,
285
+ defaulted: state.defaulted,
286
+ settled: state.settled,
287
+ outstanding: state.outstanding,
288
+ });
289
+ }
290
+ records.clear();
291
+ for (const [id, loan] of restored)
292
+ records.set(id, loan);
293
+ return true;
294
+ },
295
+ stats: readStats,
296
+ reset() {
297
+ records.clear();
298
+ },
299
+ dispose() {
300
+ probe?.();
301
+ },
302
+ };
303
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Money in this module is an integer count of **minor units** — cents when the currency has two
3
+ * decimals, whole coins when it has none. Every price, cost and total is a safe integer, so a
4
+ * simulation never accumulates float drift; the caller converts at the display boundary.
5
+ */
6
+ /** One traded good: its reference price, finite stock, demand and price-curve policy. */
7
+ export interface MarketGoodDef {
8
+ /** Stable id, unique within a market. */
9
+ id: string;
10
+ /** Price per unit at the reference stock, in integer minor units. */
11
+ basePrice: number;
12
+ /** Units on hand; a non-negative integer. Finite by construction. */
13
+ stock: number;
14
+ /** Units buyers want per period at `basePrice`; a non-negative finite number. */
15
+ demand: number;
16
+ /** Periods of demand the market wants to hold in stock (default `4`). */
17
+ coverPeriods?: number;
18
+ /** Curve exponent: how hard price reacts to a stock/demand gap (default `1`). */
19
+ elasticity?: number;
20
+ /** Floor on the price as a fraction of `basePrice` (default `0.25`). */
21
+ minFactor?: number;
22
+ /** Ceiling on the price as a fraction of `basePrice` (default `4`). */
23
+ maxFactor?: number;
24
+ /** What one unit costs the caller from the market's supplier, in minor units (default `basePrice`). */
25
+ restockCost?: number;
26
+ }
27
+ /** The mutable half of a good: what a save stores, with the definition kept as authored data. */
28
+ export interface MarketGoodState {
29
+ id: string;
30
+ stock: number;
31
+ demand: number;
32
+ }
33
+ /** Counters and stocks of a market, for a probe or a summary line. */
34
+ export interface MarketStats {
35
+ goods: number;
36
+ unitsInStock: number;
37
+ /** Sum of `stock × price` over every good, in minor units. */
38
+ stockValue: number;
39
+ sales: number;
40
+ unitsSold: number;
41
+ /** Gross takings, in minor units. */
42
+ revenue: number;
43
+ /** Units buyers asked for that stock could not serve. */
44
+ unmetUnits: number;
45
+ }
46
+ /** Everything a price curve reads: the good's resolved policy plus its live stock and demand. */
47
+ export interface MarketPriceInput {
48
+ basePrice: number;
49
+ stock: number;
50
+ demand: number;
51
+ /** `coverPeriods × demand` — the stock at which price is `basePrice`. */
52
+ targetStock: number;
53
+ elasticity: number;
54
+ minFactor: number;
55
+ maxFactor: number;
56
+ }
57
+ /** Serializable market state: the mutable half of every good plus the running counters. */
58
+ export interface MarketSnapshot {
59
+ goods: readonly MarketGoodState[];
60
+ counters: {
61
+ sales: number;
62
+ unitsSold: number;
63
+ revenue: number;
64
+ unmetUnits: number;
65
+ };
66
+ }
67
+ /** Options for {@link createMarket}: the goods and the price/demand policy they share. */
68
+ export interface MarketConfig {
69
+ goods?: readonly MarketGoodDef[];
70
+ /** Periods of demand held in stock when a good does not name its own; default `4`. */
71
+ coverPeriods?: number;
72
+ /** Curve exponent when a good does not name its own; default `1`. */
73
+ elasticity?: number;
74
+ minFactor?: number;
75
+ maxFactor?: number;
76
+ /** Fraction of the gap between current and observed demand adopted per {@link Market.recordDemand} (default `0.5`). */
77
+ demandSmoothing?: number;
78
+ /** Weight added to demand per unit buyers asked for and did not get (default `0.5`). */
79
+ unmetDemandFactor?: number;
80
+ /** Replace the default price curve; receives the good's resolved inputs. */
81
+ priceCurve?: (input: MarketPriceInput) => number;
82
+ /** Register a devtools probe under this name reporting {@link MarketStats}. */
83
+ devtoolsName?: string;
84
+ }
85
+ /** A served sale: what the buyer paid per unit and in total, in minor units. */
86
+ export interface MarketSale {
87
+ ok: true;
88
+ goodId: string;
89
+ qty: number;
90
+ unitPrice: number;
91
+ total: number;
92
+ }
93
+ /** Why a sale could not be served. */
94
+ export interface MarketSaleRejection {
95
+ ok: false;
96
+ reason: "unknown-good" | "invalid-qty" | "out-of-stock";
97
+ /** Units the market could not cover; only present for `"out-of-stock"`. */
98
+ shortfall?: number;
99
+ }
100
+ /** A sale outcome: the price the buyers paid, or why it could not be served. */
101
+ export type MarketSaleResult = MarketSale | MarketSaleRejection;
102
+ /** A non-mutating read of what a sale would cost and how it would move the price. */
103
+ export interface MarketQuote {
104
+ goodId: string;
105
+ qty: number;
106
+ unitPrice: number;
107
+ total: number;
108
+ /** Units that could not be covered from stock. */
109
+ shortfall: number;
110
+ /** Unit price after the sale, from the curve — the supply term moving. */
111
+ nextUnitPrice: number;
112
+ }
113
+ /** A live market: prices from supply and demand over finite stock, with serializable state. */
114
+ export interface Market {
115
+ /** Unit price in integer minor units. */
116
+ priceOf(goodId: string): number;
117
+ /** Price after `qty` more units left stock (a negative `qty` is a delivery), without mutating. */
118
+ priceAfter(goodId: string, qty: number): number;
119
+ /** `coverPeriods × demand` — the stock the market wants on hand. */
120
+ targetStockOf(goodId: string): number;
121
+ stockOf(goodId: string): number;
122
+ demandOf(goodId: string): number;
123
+ /** What one unit costs from the supplier, in minor units. */
124
+ restockCostOf(goodId: string): number;
125
+ /** What a sale of `qty` would cost and do to the price; `null` for an unknown good. */
126
+ quote(goodId: string, qty: number): MarketQuote | null;
127
+ /** Serve `qty` units to buyers: decrements finite stock and returns the price they paid. */
128
+ sell(goodId: string, qty: number): MarketSaleResult;
129
+ /** Take delivery of `qty` units from the supplier, lowering the price. `false` for an unknown good or bad count. */
130
+ restock(goodId: string, qty: number): boolean;
131
+ /**
132
+ * Fold one period of observed buying into demand: smooths it toward what sold and adds
133
+ * `unmetDemandFactor × unitsUnmet`. This is the demand term of the price curve moving.
134
+ */
135
+ recordDemand(goodId: string, unitsSold: number, unitsUnmet?: number): void;
136
+ /** Set demand directly, e.g. from an authored scenario. */
137
+ setDemand(goodId: string, demand: number): void;
138
+ /** Change a good's price policy in play. `false` for an unknown good or an unusable patch. */
139
+ retune(goodId: string, patch: Partial<Omit<MarketGoodDef, "id" | "stock" | "demand">>): boolean;
140
+ /** Every good's mutable state, in insertion order. */
141
+ list(): MarketGoodState[];
142
+ snapshot(): MarketSnapshot;
143
+ /** Replace state; returns `false` without mutation when the snapshot is malformed or names an unknown good. */
144
+ restore(next: MarketSnapshot): boolean;
145
+ stats(): MarketStats;
146
+ reset(): void;
147
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
148
+ dispose(): void;
149
+ }
150
+ /**
151
+ * A market whose price comes from supply and demand over finite stock, as plain serializable state.
152
+ *
153
+ * The stated curve is `price = clamp(basePrice × (coverPeriods × demand / max(stock, 1))^elasticity)`
154
+ * between `basePrice × minFactor` and `basePrice × maxFactor`. Both terms are live: selling thins
155
+ * stock and raises the price, restocking lowers it, and `recordDemand` moves demand from what
156
+ * buyers actually took and could not take. `priceCurve` replaces the curve, and `retune` changes a
157
+ * good's policy in play. Money is integer minor units throughout.
158
+ *
159
+ * @capability supply-demand-pricing price goods from finite stock and live demand on a stated curve
160
+ */
161
+ export declare function createMarket(config?: MarketConfig): Market;