agent-office-studio 0.7.0 → 0.8.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/CHANGELOG.md +13 -1
- package/README.md +1 -1
- package/bin/agentoffice.js +6 -1
- package/dist/assets/index-BAvX7Imu.css +1 -0
- package/dist/assets/index-C01GpSoa.js +4999 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
- package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
- package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
- package/dist/index.html +11 -2
- package/docs/phone.md +51 -3
- package/docs/security.md +8 -3
- package/package.json +4 -2
- package/server/cloudflared.js +84 -0
- package/server/economy.js +455 -0
- package/server/index.js +361 -16
- package/server/journeys.js +101 -0
- package/server/office-mcp.js +19 -0
- package/server/phone.js +70 -0
- package/server/settings.js +3 -0
- package/server/talk.js +51 -0
- package/server/tunnel.js +136 -0
- package/shared/shop.js +127 -0
- package/dist/assets/index-BtHSEs3B.js +0 -4475
- package/dist/assets/index-DVuFVmWF.css +0 -1
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// The office economy: pay, the office pot, the shop and achievements.
|
|
2
|
+
//
|
|
3
|
+
// Agents are paid when a job finishes, by the active minute and scaled by the
|
|
4
|
+
// model that did the work (see shared/shop.js). Of every naira earned, the
|
|
5
|
+
// pot takes its share for the boss to spend on the office; the agent keeps
|
|
6
|
+
// the rest and can spend it on their own desk. The boss can also buy an
|
|
7
|
+
// agent something, out of the pot.
|
|
8
|
+
//
|
|
9
|
+
// Everything lives in one plain object, kept in state.json with the rest of
|
|
10
|
+
// the office, and every change to a balance goes through credit() or charge()
|
|
11
|
+
// and leaves a line in the ledger, so the books always add up.
|
|
12
|
+
|
|
13
|
+
import { PAY, ITEMS, ACHIEVEMENTS, DEFAULTS, SINGLE, itemById, levelOf, streakBoost, TIP, modelPay, modelTier, split, naira } from '../shared/shop.js';
|
|
14
|
+
|
|
15
|
+
const LEDGER_MAX = 400;
|
|
16
|
+
// Weeks start on Monday 00:00 UTC (1970-01-01 was a Thursday).
|
|
17
|
+
const weekStart = (ms) => { const d = Math.floor(ms / 86400000); return (d - ((d + 3) % 7)) * 86400000; };
|
|
18
|
+
// Bets are small and slow on purpose. A stake is locked out of the wallet the
|
|
19
|
+
// moment it is offered, so a wallet can never go below zero.
|
|
20
|
+
export const BET = { minStake: 100, maxStake: 5000, maxOpen: 3, maxMinutes: 60, openMs: 6 * 3600000, liveMs: 24 * 3600000, keep: 40 };
|
|
21
|
+
export const GOAL = { minTarget: 1000, maxTarget: 1e9, maxName: 40 };
|
|
22
|
+
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
23
|
+
|
|
24
|
+
export function emptyBooks() {
|
|
25
|
+
return { wallets: {}, pot: 0, stats: {}, desks: {}, office: [], using: { ...DEFAULTS }, achievements: {}, kudos: [], ledger: [], weekly: {}, totals: { earned: 0, spent: 0 },
|
|
26
|
+
// The team goal ({} when none), the goals already reached, agent-to-agent
|
|
27
|
+
// bets, and the stakes held on them (wallets + pot + escrow = earned - spent).
|
|
28
|
+
goal: {}, goalsDone: [], bets: [], escrow: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
32
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
33
|
+
|
|
34
|
+
export class Economy {
|
|
35
|
+
// `books` is the stored object, filled in where older state lacks a part.
|
|
36
|
+
// `onChange` gets each ledger entry as it happens, to save and broadcast.
|
|
37
|
+
constructor(books, { now = () => Date.now(), onChange = () => {} } = {}) {
|
|
38
|
+
const b = isObj(books) ? books : {};
|
|
39
|
+
const fresh = emptyBooks();
|
|
40
|
+
// typeof says 'object' for null and arrays and 'number' for NaN, so check
|
|
41
|
+
// the real shape of each part, and start that part over if it is damaged.
|
|
42
|
+
for (const k of Object.keys(fresh)) {
|
|
43
|
+
const ok = Array.isArray(fresh[k]) ? Array.isArray(b[k]) : isObj(fresh[k]) ? isObj(b[k]) : isNum(b[k]);
|
|
44
|
+
if (!ok) b[k] = fresh[k];
|
|
45
|
+
}
|
|
46
|
+
for (const id of Object.keys(b.wallets)) if (!isNum(b.wallets[id])) b.wallets[id] = 0;
|
|
47
|
+
// Books from before the running totals: what is held now counts as earned.
|
|
48
|
+
if (!isNum(b.totals.earned) || !isNum(b.totals.spent)) {
|
|
49
|
+
b.totals = { earned: b.pot + Object.values(b.wallets).reduce((a, x) => a + x, 0), spent: 0 };
|
|
50
|
+
}
|
|
51
|
+
b.using = { ...DEFAULTS, ...b.using };
|
|
52
|
+
this.b = b;
|
|
53
|
+
this.now = now;
|
|
54
|
+
this.onChange = onChange;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get books() { return this.b; }
|
|
58
|
+
wallet(id) { return this.b.wallets[id] || 0; }
|
|
59
|
+
statsOf(id) {
|
|
60
|
+
return (this.b.stats[id] ||= { jobs: 0, earned: 0, byTier: {}, kudos: 0, bought: 0, streak: 0, lastDay: '' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
record(entry) {
|
|
64
|
+
const e = { at: this.now(), ...entry };
|
|
65
|
+
this.b.ledger.push(e);
|
|
66
|
+
if (this.b.ledger.length > LEDGER_MAX) this.b.ledger.splice(0, this.b.ledger.length - LEDGER_MAX);
|
|
67
|
+
this.onChange(e);
|
|
68
|
+
return e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Money in: split between the agent and the pot.
|
|
72
|
+
credit(agentId, gross, entry) {
|
|
73
|
+
const s = split(gross);
|
|
74
|
+
if (!s.gross) return null;
|
|
75
|
+
this.b.wallets[agentId] = this.wallet(agentId) + s.agent;
|
|
76
|
+
this.b.pot += s.pot;
|
|
77
|
+
this.b.totals.earned += s.gross;
|
|
78
|
+
this.statsOf(agentId).earned += s.gross;
|
|
79
|
+
const e = this.record({ ...entry, agentId, gross: s.gross, agent: s.agent, pot: s.pot });
|
|
80
|
+
this.checkGoal();
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A turn of work, as it ends: `ms` of model time on `model`. Kept on the
|
|
85
|
+
// job until it finishes, since only a finished job pays.
|
|
86
|
+
accrue(job, ms, model) {
|
|
87
|
+
const min = Math.max(0, Number(ms) || 0) / 60000;
|
|
88
|
+
if (!min) return;
|
|
89
|
+
job.payMinutes = (job.payMinutes || 0) + min;
|
|
90
|
+
job.payWeighted = (job.payWeighted || 0) + min * modelPay(model);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What a finished job earns, without paying it.
|
|
94
|
+
quote(job) {
|
|
95
|
+
const minutes = job.payMinutes || 0;
|
|
96
|
+
const capped = minutes > PAY.maxMinutes ? (job.payWeighted || 0) * (PAY.maxMinutes / minutes) : job.payWeighted || 0;
|
|
97
|
+
const mult = modelPay(job.model);
|
|
98
|
+
return { minutes, tier: modelTier(job.model), mult, wage: capped * PAY.perMinute, bonus: PAY.finishBonus * mult };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A job finished. Pays it, counts it, and checks for milestones.
|
|
102
|
+
pay(job) {
|
|
103
|
+
if (!job?.agentId) return null;
|
|
104
|
+
const q = this.quote(job);
|
|
105
|
+
job.payMinutes = 0;
|
|
106
|
+
job.payWeighted = 0;
|
|
107
|
+
// A reply can reopen a finished job. The new minutes are paid, but the
|
|
108
|
+
// finish bonus, the job count and the milestones are for the first finish.
|
|
109
|
+
const again = !!job.payCounted;
|
|
110
|
+
job.payCounted = true;
|
|
111
|
+
const s = this.statsOf(job.agentId);
|
|
112
|
+
let boost = 0;
|
|
113
|
+
if (!again) {
|
|
114
|
+
// The day's first finish keeps or restarts the streak, and the
|
|
115
|
+
// streak lifts this job's wage.
|
|
116
|
+
const today = day(this.now());
|
|
117
|
+
if (s.lastDay !== today) {
|
|
118
|
+
const yesterday = day(this.now() - 86400000);
|
|
119
|
+
s.streak = s.lastDay === yesterday ? s.streak + 1 : 1;
|
|
120
|
+
s.lastDay = today;
|
|
121
|
+
}
|
|
122
|
+
boost = streakBoost(s.streak);
|
|
123
|
+
}
|
|
124
|
+
const e = this.credit(job.agentId, q.wage * (1 + boost) + (again ? 0 : q.bonus), { kind: 'pay', jobId: job.id, title: job.title, minutes: Math.round(q.minutes * 10) / 10, tier: q.tier, mult: q.mult, ...(boost ? { streak: s.streak, boost } : {}) });
|
|
125
|
+
if (e) job.earned = (job.earned || 0) + e.gross;
|
|
126
|
+
if (again) return { pay: e, achievements: this.milestones(job.agentId) };
|
|
127
|
+
// A bet that cannot be settled cleanly must never hold up the pay.
|
|
128
|
+
try { this.settleBets(job, q.minutes); } catch (err) { this.record({ kind: 'bet', phase: 'error', jobId: job.id, why: err.message }); }
|
|
129
|
+
s.jobs++;
|
|
130
|
+
if (q.tier) s.byTier[q.tier] = (s.byTier[q.tier] || 0) + 1;
|
|
131
|
+
const won = this.milestones(job.agentId, q);
|
|
132
|
+
return { pay: e, achievements: won };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Unlocked milestones pay their bonus once each.
|
|
136
|
+
milestones(agentId, job) {
|
|
137
|
+
const have = (this.b.achievements[agentId] ||= []);
|
|
138
|
+
const won = [];
|
|
139
|
+
// Twice round, since a bonus can push `earned` past the next milestone.
|
|
140
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
141
|
+
for (const a of ACHIEVEMENTS) {
|
|
142
|
+
if (have.includes(a.id) || !a.test(this.statsOf(agentId), job)) continue;
|
|
143
|
+
have.push(a.id);
|
|
144
|
+
won.push(a);
|
|
145
|
+
this.credit(agentId, a.bonus, { kind: 'achievement', achievement: a.id, title: a.name });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return won;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The boss liked a finished job: its finish bonus again, once.
|
|
152
|
+
kudos(job) {
|
|
153
|
+
if (!job || job.status !== 'done' || job.awaitingBoss) throw new Error('Only a finished job can get kudos.');
|
|
154
|
+
if (job.guest) throw new Error('That job belongs to another office.');
|
|
155
|
+
if (job.kudosPaid || this.b.kudos.includes(job.id)) throw new Error('That job already got kudos.');
|
|
156
|
+
job.kudosPaid = true;
|
|
157
|
+
this.b.kudos.push(job.id);
|
|
158
|
+
if (this.b.kudos.length > 2000) this.b.kudos.splice(0, 1000);
|
|
159
|
+
this.statsOf(job.agentId).kudos++;
|
|
160
|
+
const e = this.credit(job.agentId, PAY.finishBonus * modelPay(job.model), { kind: 'kudos', jobId: job.id, title: job.title });
|
|
161
|
+
return { pay: e, achievements: this.milestones(job.agentId) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
owns(item, agentId) {
|
|
165
|
+
if (item.price === 0 && SINGLE.has(item.scope)) return true;
|
|
166
|
+
if (item.scope === 'desk') return (this.b.desks[agentId] || []).includes(item.id);
|
|
167
|
+
return this.b.office.includes(item.id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// `by` is 'boss' or an agent's id; `forAgent` is whose desk a desk item
|
|
171
|
+
// goes on. Agents pay from their own wallet and only for their own desk;
|
|
172
|
+
// the boss pays from the pot.
|
|
173
|
+
buy(itemId, { by, forAgent } = {}) {
|
|
174
|
+
const item = itemById(itemId);
|
|
175
|
+
if (!item) throw new Error(`Nothing in the shop is called "${itemId}".`);
|
|
176
|
+
const boss = by === 'boss';
|
|
177
|
+
if (!boss && item.scope !== 'desk') throw new Error(`${item.name} is for the office, and only the boss can buy those, out of the pot.`);
|
|
178
|
+
const owner = item.scope === 'desk' ? (boss ? forAgent : by) : null;
|
|
179
|
+
if (item.scope === 'desk' && !owner) throw new Error('Say whose desk it is for.');
|
|
180
|
+
if (this.owns(item, owner)) throw new Error(owner ? `${item.name} is already on that desk.` : `The office already has ${item.name}.`);
|
|
181
|
+
const balance = boss ? this.b.pot : this.wallet(by);
|
|
182
|
+
if (balance < item.price) throw new Error(`${item.name} costs ${naira(item.price)} and ${boss ? 'the pot has' : 'you have'} ${naira(balance)}.`);
|
|
183
|
+
if (boss) this.b.pot -= item.price;
|
|
184
|
+
else this.b.wallets[by] = balance - item.price;
|
|
185
|
+
this.b.totals.spent += item.price;
|
|
186
|
+
if (owner) (this.b.desks[owner] ||= []).push(item.id);
|
|
187
|
+
else this.b.office.push(item.id);
|
|
188
|
+
// Buying a new look puts it straight into use.
|
|
189
|
+
if (SINGLE.has(item.scope)) this.b.using[item.scope] = item.id;
|
|
190
|
+
const e = this.record({ kind: 'buy', item: item.id, title: item.name, price: item.price, by, agentId: owner || undefined, from: boss ? 'pot' : 'wallet' });
|
|
191
|
+
let won = [];
|
|
192
|
+
if (owner && !boss) {
|
|
193
|
+
this.statsOf(owner).bought++;
|
|
194
|
+
won = this.milestones(owner);
|
|
195
|
+
}
|
|
196
|
+
return { buy: e, achievements: won };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The boss tips an agent out of the pot. A plain transfer: it is not
|
|
200
|
+
// earned, so it neither splits nor counts toward totals.
|
|
201
|
+
tip(agentId, amount) {
|
|
202
|
+
const n = Math.floor(Number(amount));
|
|
203
|
+
if (!agentId) throw new Error('Say who the tip is for.');
|
|
204
|
+
if (!(n >= TIP.min && n <= TIP.max)) throw new Error(`A tip is between ${naira(TIP.min)} and ${naira(TIP.max)}.`);
|
|
205
|
+
if (this.b.pot < n) throw new Error(`The pot has ${naira(this.b.pot)}.`);
|
|
206
|
+
this.b.pot -= n;
|
|
207
|
+
this.b.wallets[agentId] = this.wallet(agentId) + n;
|
|
208
|
+
return this.record({ kind: 'tip', agentId, amount: n, by: 'boss', title: 'Tip from the boss' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Title and streak, per agent. A streak only counts while it is alive:
|
|
212
|
+
// finished a job today or yesterday.
|
|
213
|
+
perks(id) {
|
|
214
|
+
const st = this.b.stats[id] || {};
|
|
215
|
+
const live = st.lastDay === day(this.now()) || st.lastDay === day(this.now() - 86400000);
|
|
216
|
+
const streak = live ? st.streak || 0 : 0;
|
|
217
|
+
return { ...levelOf(st.earned || 0), streak, boost: streakBoost(streak) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Switch to a floor, wall or layout the office owns.
|
|
221
|
+
use(itemId) {
|
|
222
|
+
const item = itemById(itemId);
|
|
223
|
+
if (!item || !SINGLE.has(item.scope)) throw new Error('Only a floor, wall or layout can be put into use.');
|
|
224
|
+
if (!this.owns(item)) throw new Error(`The office doesn't own ${item.name} yet.`);
|
|
225
|
+
this.b.using[item.scope] = item.id;
|
|
226
|
+
this.record({ kind: 'use', item: item.id, title: item.name, by: 'boss' });
|
|
227
|
+
return this.b.using;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Desk items an agent could buy right now.
|
|
231
|
+
affordable(agentId) {
|
|
232
|
+
return ITEMS.filter((i) => i.scope === 'desk' && i.price <= this.wallet(agentId) && !this.owns(i, agentId));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The rank each agent held when the week began, kept in the books so a
|
|
236
|
+
// restart doesn't lose it. Older state has none: the first look takes one,
|
|
237
|
+
// and from then on it rolls over every Monday.
|
|
238
|
+
rollWeek() {
|
|
239
|
+
const w = this.b.weekly;
|
|
240
|
+
const start = weekStart(this.now());
|
|
241
|
+
const ok = isNum(w.start) && isObj(w.ranks);
|
|
242
|
+
if (ok && w.start >= start) return w;
|
|
243
|
+
const ranks = {};
|
|
244
|
+
this.richList().forEach((r, i) => { ranks[r.id] = i + 1; });
|
|
245
|
+
// The very first snapshot has nothing to compare against, so it is
|
|
246
|
+
// marked `first` and shows no movement until a week has passed.
|
|
247
|
+
this.b.weekly = { start, ranks, first: !ok };
|
|
248
|
+
return this.b.weekly;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Net worth is what an agent holds plus what they have bought for their
|
|
252
|
+
// desk, at shop price. Ties go to whoever has earned more over their time.
|
|
253
|
+
richList() {
|
|
254
|
+
const b = this.b;
|
|
255
|
+
const ids = new Set([...Object.keys(b.wallets), ...Object.keys(b.desks), ...Object.keys(b.stats)]);
|
|
256
|
+
return [...ids].map((id) => {
|
|
257
|
+
const wallet = this.wallet(id);
|
|
258
|
+
const desk = (b.desks[id] || []).reduce((a, x) => a + (itemById(x)?.price || 0), 0);
|
|
259
|
+
const st = b.stats[id] || {};
|
|
260
|
+
const locked = this.staked(id);
|
|
261
|
+
return { id, worth: wallet + desk + locked, wallet, desk, earned: st.earned || 0, jobs: st.jobs || 0 };
|
|
262
|
+
}).sort((a, c) => c.worth - a.worth || c.earned - a.earned || a.id.localeCompare(c.id));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The rich list with the week's movement: `moved` is places climbed since
|
|
266
|
+
// the week began (negative for places lost), `rival` the agents just above
|
|
267
|
+
// and below with the gap in naira, and `comeback` marks whoever climbed
|
|
268
|
+
// most (ties go to the higher rank; nobody if nobody climbed). Agents who
|
|
269
|
+
// joined the list this week, and the whole list in the first week, have
|
|
270
|
+
// `moved: null`.
|
|
271
|
+
weekly() {
|
|
272
|
+
const w = this.rollWeek();
|
|
273
|
+
const list = this.richList();
|
|
274
|
+
const rows = list.map((r, i) => {
|
|
275
|
+
const was = !w.first && isNum(w.ranks[r.id]) ? w.ranks[r.id] : null;
|
|
276
|
+
const above = list[i - 1], below = list[i + 1];
|
|
277
|
+
return {
|
|
278
|
+
...r, rank: i + 1, was, moved: was === null ? null : was - (i + 1),
|
|
279
|
+
rival: { above: above ? { id: above.id, gap: above.worth - r.worth } : null, below: below ? { id: below.id, gap: r.worth - below.worth } : null },
|
|
280
|
+
comeback: false,
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
const best = rows.reduce((m, r) => (r.moved > (m?.moved || 0) ? r : m), null);
|
|
284
|
+
if (best) best.comeback = true;
|
|
285
|
+
return { since: w.start, first: !!w.first, rows };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------- team goal ----------
|
|
289
|
+
|
|
290
|
+
// The boss names a target for the pot ("pool party", ₦5,000,000). Reaching
|
|
291
|
+
// it is celebrated once; the goal then stays as reached until replaced.
|
|
292
|
+
setGoal({ name, target } = {}) {
|
|
293
|
+
name = String(name || '').replace(/\s+/g, ' ').trim().slice(0, GOAL.maxName);
|
|
294
|
+
target = Math.round(Number(target));
|
|
295
|
+
if (!name) throw new Error('Give the goal a name.');
|
|
296
|
+
if (!Number.isFinite(target) || target < GOAL.minTarget) throw new Error(`The target must be at least ${naira(GOAL.minTarget)}.`);
|
|
297
|
+
if (target > GOAL.maxTarget) throw new Error(`The target can be at most ${naira(GOAL.maxTarget)}.`);
|
|
298
|
+
if (target <= this.b.pot) throw new Error(`The pot already has ${naira(this.b.pot)}. Aim higher than that.`);
|
|
299
|
+
this.b.goal = { name, target, setAt: this.now(), reachedAt: null };
|
|
300
|
+
this.record({ kind: 'goal', phase: 'set', title: name, target, by: 'boss' });
|
|
301
|
+
return this.goalStatus();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
clearGoal() {
|
|
305
|
+
if (!this.b.goal.target) return this.goalStatus();
|
|
306
|
+
const was = this.b.goal;
|
|
307
|
+
this.b.goal = {};
|
|
308
|
+
this.record({ kind: 'goal', phase: 'cleared', title: was.name, target: was.target, by: 'boss' });
|
|
309
|
+
return this.goalStatus();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Called whenever the pot grows. Fires once per goal: reachedAt is the latch,
|
|
313
|
+
// and it survives the pot being spent down afterwards.
|
|
314
|
+
checkGoal() {
|
|
315
|
+
const g = this.b.goal;
|
|
316
|
+
if (!g.target || g.reachedAt || this.b.pot < g.target) return null;
|
|
317
|
+
g.reachedAt = this.now();
|
|
318
|
+
this.b.goalsDone.push({ name: g.name, target: g.target, reachedAt: g.reachedAt });
|
|
319
|
+
if (this.b.goalsDone.length > 20) this.b.goalsDone.splice(0, this.b.goalsDone.length - 20);
|
|
320
|
+
return this.record({ kind: 'goal', phase: 'reached', title: g.name, target: g.target, pot: this.b.pot });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
goalStatus() {
|
|
324
|
+
const g = this.b.goal;
|
|
325
|
+
if (!g.target) return null;
|
|
326
|
+
const pot = this.b.pot;
|
|
327
|
+
return { name: g.name, target: g.target, setAt: g.setAt, reachedAt: g.reachedAt || null, pot, progress: g.reachedAt ? 1 : Math.min(1, pot / g.target), done: this.b.goalsDone.length };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------- bets ----------
|
|
331
|
+
//
|
|
332
|
+
// One agent (the backer) says another agent's next job will finish in under
|
|
333
|
+
// N active minutes and stakes naira on it; a second agent (the doubter) takes
|
|
334
|
+
// the other side with a matching stake. Both stakes are locked in escrow, and
|
|
335
|
+
// when the job first finishes the winner gets both. Nobody can be the subject
|
|
336
|
+
// of their own bet, so nobody gains by dragging a job out or rushing it for a
|
|
337
|
+
// bet. Money only ever moves wallet -> escrow -> wallet, never through the
|
|
338
|
+
// pot, so earned and spent do not change.
|
|
339
|
+
|
|
340
|
+
staked(agentId) {
|
|
341
|
+
return this.b.bets.reduce((a, x) => a + (x.state === 'open' && x.by === agentId ? x.stake : x.state === 'live' && (x.by === agentId || x.against === agentId) ? x.stake : 0), 0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Wallet -> escrow. Never lets a wallet go below zero.
|
|
345
|
+
charge(agentId, amount, entry) {
|
|
346
|
+
if (!(amount > 0) || this.wallet(agentId) < amount) throw new Error(`${naira(amount)} is more than ${agentId} has (${naira(this.wallet(agentId))}).`);
|
|
347
|
+
this.b.wallets[agentId] = this.wallet(agentId) - amount;
|
|
348
|
+
this.b.escrow += amount;
|
|
349
|
+
return this.record({ ...entry, agentId, amount: -amount });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Escrow -> wallet.
|
|
353
|
+
release(agentId, amount, entry) {
|
|
354
|
+
if (!(amount > 0)) return null;
|
|
355
|
+
if (this.b.escrow < amount) throw new Error('The escrow is short; refusing to pay out more than is held.');
|
|
356
|
+
this.b.escrow -= amount;
|
|
357
|
+
this.b.wallets[agentId] = this.wallet(agentId) + amount;
|
|
358
|
+
return this.record({ ...entry, agentId, amount });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
offerBet({ by, subject, minutes, stake } = {}) {
|
|
362
|
+
this.sweep();
|
|
363
|
+
minutes = Math.round(Number(minutes));
|
|
364
|
+
stake = Math.round(Number(stake));
|
|
365
|
+
if (!by || !subject) throw new Error('Say who is betting and on whom.');
|
|
366
|
+
if (by === subject) throw new Error('You cannot bet on your own job.');
|
|
367
|
+
if (!Number.isFinite(minutes) || minutes < 1 || minutes > BET.maxMinutes) throw new Error(`Pick a time between 1 and ${BET.maxMinutes} minutes.`);
|
|
368
|
+
if (!Number.isFinite(stake) || stake < BET.minStake || stake > BET.maxStake) throw new Error(`A stake is between ${naira(BET.minStake)} and ${naira(BET.maxStake)}.`);
|
|
369
|
+
const mine = this.b.bets.filter((x) => (x.state === 'open' || x.state === 'live') && (x.by === by || x.against === by)).length;
|
|
370
|
+
if (mine >= BET.maxOpen) throw new Error(`You already have ${BET.maxOpen} bets going. Wait for one to settle.`);
|
|
371
|
+
const bet = { id: 'b' + this.now().toString(36) + Math.random().toString(36).slice(2, 6), by, subject, minutes, stake, against: null, state: 'open', createdAt: this.now() };
|
|
372
|
+
this.charge(by, stake, { kind: 'bet', phase: 'stake', betId: bet.id, subject, minutes });
|
|
373
|
+
this.b.bets.push(bet);
|
|
374
|
+
return bet;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
takeBet(id, { by } = {}) {
|
|
378
|
+
this.sweep();
|
|
379
|
+
const bet = this.b.bets.find((x) => x.id === id);
|
|
380
|
+
if (!bet || bet.state !== 'open') throw new Error('That bet is not open.');
|
|
381
|
+
if (by === bet.by) throw new Error('That is your own bet.');
|
|
382
|
+
if (by === bet.subject) throw new Error('You cannot bet against your own job.');
|
|
383
|
+
const mine = this.b.bets.filter((x) => (x.state === 'open' || x.state === 'live') && (x.by === by || x.against === by)).length;
|
|
384
|
+
if (mine >= BET.maxOpen) throw new Error(`You already have ${BET.maxOpen} bets going. Wait for one to settle.`);
|
|
385
|
+
this.charge(by, bet.stake, { kind: 'bet', phase: 'stake', betId: bet.id, subject: bet.subject, minutes: bet.minutes });
|
|
386
|
+
Object.assign(bet, { against: by, state: 'live', acceptedAt: this.now() });
|
|
387
|
+
return bet;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The backer (or the boss) takes back an offer nobody has taken.
|
|
391
|
+
cancelBet(id, { by } = {}) {
|
|
392
|
+
const bet = this.b.bets.find((x) => x.id === id);
|
|
393
|
+
if (!bet || bet.state !== 'open') throw new Error('That bet is not open.');
|
|
394
|
+
if (by !== 'boss' && by !== bet.by) throw new Error('Only whoever offered it can take it back.');
|
|
395
|
+
return this.close(bet, 'void', 'taken back');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Ends a bet and moves the money. Winner takes both stakes; a void gives
|
|
399
|
+
// each their own back. Idempotent: a bet that is closed stays closed.
|
|
400
|
+
close(bet, state, why, winner) {
|
|
401
|
+
if (bet.state !== 'open' && bet.state !== 'live') return bet;
|
|
402
|
+
const parties = [bet.by, bet.against].filter(Boolean);
|
|
403
|
+
if (this.b.escrow < bet.stake * (state === 'void' ? parties.length : 2)) throw new Error('The escrow is short; refusing to move any of it.');
|
|
404
|
+
const entry = { kind: 'bet', phase: state, betId: bet.id, subject: bet.subject, minutes: bet.minutes, stake: bet.stake, why };
|
|
405
|
+
if (state === 'void') for (const id of parties) this.release(id, bet.stake, { ...entry });
|
|
406
|
+
else this.release(winner, bet.stake * 2, { ...entry, winner, loser: parties.find((x) => x !== winner) });
|
|
407
|
+
Object.assign(bet, { state, why, winner: winner || null, settledAt: this.now() });
|
|
408
|
+
const old = this.b.bets.filter((x) => x.state !== 'open' && x.state !== 'live');
|
|
409
|
+
if (old.length > BET.keep) for (const x of old.slice(0, old.length - BET.keep)) this.b.bets.splice(this.b.bets.indexOf(x), 1);
|
|
410
|
+
return bet;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A bet belongs to the subject's first job that started after it was taken.
|
|
414
|
+
boundTo(bet, job) {
|
|
415
|
+
return bet.state === 'live' && bet.subject === job.agentId && Number.isFinite(job.startedAt) && bet.acceptedAt <= job.startedAt;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The job finished for the first time, in `minutes` of active work.
|
|
419
|
+
settleBets(job, minutes) {
|
|
420
|
+
this.sweep();
|
|
421
|
+
for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) {
|
|
422
|
+
if (!(minutes > 0)) { this.close(bet, 'void', 'no working time was recorded'); continue; }
|
|
423
|
+
const under = minutes < bet.minutes;
|
|
424
|
+
this.close(bet, under ? 'won' : 'lost', `${Math.round(minutes * 10) / 10} min on "${job.title}"`, under ? bet.by : bet.against);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The job did not finish (failed, stopped, cancelled): stakes come back.
|
|
429
|
+
voidBets(job, why = 'the job did not finish') {
|
|
430
|
+
for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) this.close(bet, 'void', why);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Bets nobody took, or that never settled, run out and refund.
|
|
434
|
+
sweep() {
|
|
435
|
+
const t = this.now();
|
|
436
|
+
for (const bet of this.b.bets.slice()) {
|
|
437
|
+
if (bet.state === 'open' && t - bet.createdAt > BET.openMs) this.close(bet, 'void', 'nobody took it');
|
|
438
|
+
else if (bet.state === 'live' && t - bet.acceptedAt > BET.liveMs) this.close(bet, 'void', 'the job never finished');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
betsStatus() {
|
|
443
|
+
return this.b.bets.slice(-BET.keep - 12);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
status() {
|
|
447
|
+
const wk = this.weekly();
|
|
448
|
+
const b = this.b;
|
|
449
|
+
return {
|
|
450
|
+
rich: wk.rows.map((r) => ({ ...r, ...this.perks(r.id) })), goal: this.goalStatus(), bets: this.betsStatus(), escrow: b.escrow, week: { since: wk.since, first: wk.first }, pay: { perMinute: PAY.perMinute, finishBonus: PAY.finishBonus },
|
|
451
|
+
pot: b.pot, wallets: b.wallets, stats: b.stats, desks: b.desks, office: b.office, using: b.using,
|
|
452
|
+
achievements: b.achievements, kudos: b.kudos.slice(-200), ledger: b.ledger.slice(-60),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|