agent-office-studio 0.7.0 → 0.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/CHANGELOG.md +61 -1
- package/README.md +13 -1
- package/bin/agentoffice.js +6 -1
- package/dist/assets/code-Dtdckl8K.js +32 -0
- package/dist/assets/index-DxWppRAd.css +1 -0
- package/dist/assets/index-Y1a-xw5C.js +5040 -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/assets/pdf-BtCWNl9F.js +55 -0
- package/dist/assets/pdf-a7kwa8Fi.js +1 -0
- package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
- package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
- package/dist/index.html +32 -26
- package/dist/theme-boot.js +13 -0
- package/docs/desktop.md +52 -0
- package/docs/phone.md +51 -3
- package/docs/security.md +8 -3
- package/package.json +20 -2
- package/server/changes.js +13 -2
- package/server/chief.js +17 -0
- package/server/cloudflared.js +84 -0
- package/server/economy.js +478 -0
- package/server/files.js +104 -0
- package/server/guests.js +59 -21
- package/server/index.js +743 -59
- package/server/journeys.js +101 -0
- package/server/links.js +11 -4
- package/server/machine.js +164 -0
- package/server/office-mcp.js +29 -0
- package/server/phone.js +70 -0
- package/server/procstat.js +12 -6
- package/server/settings.js +5 -0
- package/server/talk.js +51 -0
- package/server/tunnel.js +136 -0
- package/server/visits.js +14 -4
- package/shared/shop.js +134 -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
package/server/tunnel.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// A public address for the office, so a phone can reach it from anywhere: a
|
|
2
|
+
// Cloudflare quick tunnel. `cloudflared` dials out from this machine and gives
|
|
3
|
+
// back a random https://….trycloudflare.com name that forwards to the office,
|
|
4
|
+
// so nothing listens on the network and no router is touched. No account.
|
|
5
|
+
//
|
|
6
|
+
// It is only ever started by an explicit click, never at boot, and it dies
|
|
7
|
+
// with the office. The address is new every time. Whoever has the link with
|
|
8
|
+
// the key can run Claude here, so the key stays the one thing that matters.
|
|
9
|
+
//
|
|
10
|
+
// It is only called on once the address works from outside: a phone that
|
|
11
|
+
// looks a name up too early caches "no such name" for minutes, and a tunnel
|
|
12
|
+
// can connect yet route nowhere (see emptyConfig). Either way the phone would
|
|
13
|
+
// show a blank page, so the QR waits.
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import https from 'node:https';
|
|
20
|
+
import { Resolver } from 'node:dns/promises';
|
|
21
|
+
|
|
22
|
+
// cloudflared reads ~/.cloudflared/config.yml even for a quick tunnel, and a
|
|
23
|
+
// named tunnel's ingress there (ending, as they do, in http_status:404) then
|
|
24
|
+
// answers every request to our address with an empty 404: a blank page on the
|
|
25
|
+
// phone. So it gets a config of its own, with nothing in it.
|
|
26
|
+
function emptyConfig() {
|
|
27
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'office-tunnel-')), 'config.yml');
|
|
28
|
+
fs.writeFileSync(file, '');
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Does https://host/ reach this office from the internet? Looked up on public
|
|
33
|
+
// resolvers, not this machine's, which may have cached the name as missing.
|
|
34
|
+
// The office answers a keyless visit with 401, which Cloudflare never sends.
|
|
35
|
+
const resolver = new Resolver({ timeout: 3000, tries: 1 });
|
|
36
|
+
resolver.setServers(['1.1.1.1', '8.8.8.8']);
|
|
37
|
+
export async function reachable(host) {
|
|
38
|
+
let ip;
|
|
39
|
+
try { [ip] = await resolver.resolve4(host); } catch { return false; }
|
|
40
|
+
if (!ip) return false;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const req = https.get({ host: ip, servername: host, headers: { host }, path: '/', timeout: 8000 }, (res) => { res.resume(); resolve(res.statusCode === 401); });
|
|
43
|
+
req.on('timeout', () => req.destroy());
|
|
44
|
+
req.on('error', () => resolve(false));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const URL_RE = /https:\/\/([a-z0-9-]+\.trycloudflare\.com)/i;
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
|
|
51
|
+
export class Tunnel {
|
|
52
|
+
// findBin() gives the cloudflared to run (it may download it, calling
|
|
53
|
+
// onStep('download')); allow(host) lets the office accept that Host and
|
|
54
|
+
// forget(host) takes it back; check(host) is the outside check.
|
|
55
|
+
constructor({ port, allow, forget, findBin, check = reachable, onChange = () => {} }) {
|
|
56
|
+
Object.assign(this, { port, allow, forget, findBin, check, onChange });
|
|
57
|
+
this.state = 'off'; // off | starting | on | error
|
|
58
|
+
this.step = null; // while starting: download | connect | check
|
|
59
|
+
this.host = null;
|
|
60
|
+
this.error = null;
|
|
61
|
+
this.child = null;
|
|
62
|
+
this.run = 0; // bumped by every start and stop, so a stale run stands down
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
status() {
|
|
66
|
+
return { state: this.state, step: this.state === 'starting' ? this.step : null, url: this.state === 'on' ? `https://${this.host}` : null, error: this.error };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set(state, extra = {}) {
|
|
70
|
+
Object.assign(this, { state, error: null, ...extra });
|
|
71
|
+
this.onChange(this.status());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
start() {
|
|
75
|
+
if (this.state === 'starting' || this.state === 'on') return this.status();
|
|
76
|
+
const run = ++this.run;
|
|
77
|
+
this.set('starting', { host: null, step: 'connect' });
|
|
78
|
+
this.boot(run).catch((err) => { if (run === this.run) this.fail(err.message); });
|
|
79
|
+
return this.status();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async boot(run) {
|
|
83
|
+
const bin = await this.findBin((step) => { if (run === this.run) this.set('starting', { step }); });
|
|
84
|
+
if (run !== this.run) return;
|
|
85
|
+
this.set('starting', { step: 'connect' });
|
|
86
|
+
const host = await new Promise((resolve, reject) => {
|
|
87
|
+
const child = spawn(bin, ['tunnel', '--config', emptyConfig(), '--no-autoupdate', '--url', `http://127.0.0.1:${this.port}`], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
88
|
+
this.child = child;
|
|
89
|
+
let tail = '', name = null;
|
|
90
|
+
const timer = setTimeout(() => reject(new Error('Timed out waiting for Cloudflare. Check your connection and try again.')), 45000);
|
|
91
|
+
const done = (fn, v) => { clearTimeout(timer); fn(v); };
|
|
92
|
+
// The name is printed a second before the connection is up.
|
|
93
|
+
child.stderr.on('data', (d) => {
|
|
94
|
+
tail = (tail + d).slice(-4000);
|
|
95
|
+
const m = !name && URL_RE.exec(tail);
|
|
96
|
+
if (m && m[1] !== 'api.trycloudflare.com') name = m[1];
|
|
97
|
+
if (name && /Registered tunnel connection/.test(tail)) done(resolve, name);
|
|
98
|
+
});
|
|
99
|
+
child.on('error', (err) => done(reject, new Error(err.code === 'ENOENT' ? 'cloudflared couldn’t be started.' : err.message)));
|
|
100
|
+
child.on('exit', (code) => {
|
|
101
|
+
if (this.child !== child) return; // stopped on purpose
|
|
102
|
+
done(reject, new Error(this.state === 'on' ? 'The public address closed. Make a new one.' : `cloudflared stopped (${code}).`));
|
|
103
|
+
if (this.state === 'on' && run === this.run) this.fail('The public address closed. Make a new one.');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
if (run !== this.run) return;
|
|
107
|
+
this.allow(host);
|
|
108
|
+
this.host = host;
|
|
109
|
+
this.set('starting', { step: 'check' });
|
|
110
|
+
for (const end = Date.now() + 90000; Date.now() < end; await sleep(2000)) {
|
|
111
|
+
if (run !== this.run) return;
|
|
112
|
+
if (await this.check(host)) { if (run === this.run) this.set('on', { host }); return; }
|
|
113
|
+
}
|
|
114
|
+
throw new Error('Cloudflare connected, but the address doesn’t reach this office from outside. Try again in a minute.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fail(message) {
|
|
118
|
+
this.run++;
|
|
119
|
+
this.drop();
|
|
120
|
+
this.set('error', { error: message, host: null });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
drop() {
|
|
124
|
+
const child = this.child;
|
|
125
|
+
this.child = null;
|
|
126
|
+
if (this.host) this.forget(this.host);
|
|
127
|
+
if (child) { try { child.kill('SIGTERM'); } catch {} }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stop() {
|
|
131
|
+
this.run++;
|
|
132
|
+
this.drop();
|
|
133
|
+
this.set('off', { host: null });
|
|
134
|
+
return this.status();
|
|
135
|
+
}
|
|
136
|
+
}
|
package/server/visits.js
CHANGED
|
@@ -19,10 +19,11 @@ import fs from 'node:fs';
|
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { execFile, spawn } from 'node:child_process';
|
|
22
|
-
import { snapshotTree, summarize, fileDiff } from './changes.js';
|
|
22
|
+
import { snapshotTree, summarize, fileDiff, noFilters } from './changes.js';
|
|
23
23
|
|
|
24
24
|
export const ACCESS = ['off', 'read', 'edit'];
|
|
25
25
|
const MAX_OPEN = 3; // working visits per guest link
|
|
26
|
+
const MAX_COPIES = 6; // copies on disk per guest link, working or waiting for review
|
|
26
27
|
const MAX_READ = 24 * 1024; // what one answer carries, under the relay's 64 KB a frame
|
|
27
28
|
const MAX_WRITE = 40 * 1024; // what one write may carry, likewise
|
|
28
29
|
const MAX_FILE = 2 * 1024 * 1024; // the most a file may grow to
|
|
@@ -112,6 +113,11 @@ export class Visits {
|
|
|
112
113
|
this.push(v);
|
|
113
114
|
return { sid: v.sid, access };
|
|
114
115
|
}
|
|
116
|
+
// Each new job of theirs is a whole copy of the project, so they can't
|
|
117
|
+
// leave more than a few lying about.
|
|
118
|
+
if (this.list.filter((x) => x.guest.link === link.id && !x.applied && !x.discarded && fs.existsSync(x.dir)).length >= MAX_COPIES) {
|
|
119
|
+
throw fail(429, `You have ${MAX_COPIES} copies of this office’s projects open or waiting for review. Ask the owner to apply or discard some first.`);
|
|
120
|
+
}
|
|
115
121
|
const src = this.office.repoPath(repo);
|
|
116
122
|
if (!src) throw fail(404, 'That project folder is gone.');
|
|
117
123
|
const base = await snapshotTree(src);
|
|
@@ -122,7 +128,7 @@ export class Visits {
|
|
|
122
128
|
const idx = tmpIndex();
|
|
123
129
|
try {
|
|
124
130
|
await git(src, ['read-tree', base], { GIT_INDEX_FILE: idx });
|
|
125
|
-
await git(src, [`--work-tree=${dir}`, 'checkout-index', '-a', '-f'], { GIT_INDEX_FILE: idx });
|
|
131
|
+
await git(src, [...await noFilters(src), `--work-tree=${dir}`, 'checkout-index', '-a', '-f'], { GIT_INDEX_FILE: idx });
|
|
126
132
|
} catch (err) {
|
|
127
133
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
128
134
|
throw fail(500, `Could not copy ${repo}: ${String(err.stderr || err.message).slice(0, 200)}`);
|
|
@@ -347,6 +353,9 @@ export class Visits {
|
|
|
347
353
|
if (summary) this.log(v, summary, 'text');
|
|
348
354
|
await this.record(v);
|
|
349
355
|
const n = v.changes?.count || 0;
|
|
356
|
+
// Nothing to review, so nothing to keep: a follow-up gets a fresh copy.
|
|
357
|
+
// (If the copy couldn't be read there is no telling, so it stays.)
|
|
358
|
+
if (v.changes && !n) this.drop(v);
|
|
350
359
|
const what = v.access !== 'edit' ? '' : n ? ` ${n} file${n === 1 ? '' : 's'} changed in the copy. Review them on the job and apply or discard.` : ' Nothing changed.';
|
|
351
360
|
this.log(v, `${{ done: 'Finished', failed: 'Hit a wall', stopped: 'Stopped' }[status] || 'Ended'}.${what}`, 'result', { ok: status === 'done' });
|
|
352
361
|
this.push(v);
|
|
@@ -369,8 +378,9 @@ export class Visits {
|
|
|
369
378
|
const idx = tmpIndex();
|
|
370
379
|
try {
|
|
371
380
|
await git(src, ['read-tree', v.base], { GIT_INDEX_FILE: idx });
|
|
372
|
-
await
|
|
373
|
-
|
|
381
|
+
const nf = await noFilters(src);
|
|
382
|
+
await git(src, [...nf, `--work-tree=${v.dir}`, 'add', '-A'], { GIT_INDEX_FILE: idx });
|
|
383
|
+
return (await git(src, [...nf, `--work-tree=${v.dir}`, 'write-tree'], { GIT_INDEX_FILE: idx })).trim();
|
|
374
384
|
} finally {
|
|
375
385
|
fs.rmSync(idx, { force: true });
|
|
376
386
|
}
|
package/shared/shop.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// What the office economy pays and sells.
|
|
2
|
+
//
|
|
3
|
+
// Shared by the server (which pays, charges and keeps the books) and the
|
|
4
|
+
// browser (which shows the shop and draws what was bought), so a price or a
|
|
5
|
+
// rate can never mean one thing in the ledger and another on screen.
|
|
6
|
+
|
|
7
|
+
// Pay. An agent earns by the active minute (time the model was actually
|
|
8
|
+
// working, not time spent waiting on the boss or a colleague) plus a bonus
|
|
9
|
+
// for finishing, and both are scaled by the model that did the work: getting
|
|
10
|
+
// a job done on a lighter model is worth more than brute-forcing it on the
|
|
11
|
+
// heaviest. Only finished jobs pay. A failed or stopped job pays nothing.
|
|
12
|
+
// All money is naira. perMinute and finishBonus are the defaults; the boss
|
|
13
|
+
// sets them in Settings and setPay() applies them, in the server and, from
|
|
14
|
+
// the economy status, in the browser.
|
|
15
|
+
export const PAY = {
|
|
16
|
+
perMinute: 1000,
|
|
17
|
+
finishBonus: 2500,
|
|
18
|
+
// Minutes beyond this on one job are not paid: a job that drags on is not
|
|
19
|
+
// a job done better.
|
|
20
|
+
maxMinutes: 60,
|
|
21
|
+
// The office's cut of every naira an agent earns, for the boss to spend.
|
|
22
|
+
potShare: 0.4,
|
|
23
|
+
// A boss's "great work" on a finished job pays its finish bonus again.
|
|
24
|
+
kudosOncePerJob: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const naira = (v) => `₦${Math.round(Number(v) || 0).toLocaleString('en-NG')}`;
|
|
28
|
+
export function setPay(p = {}) {
|
|
29
|
+
for (const k of ['perMinute', 'finishBonus']) if (Number.isFinite(p[k]) && p[k] >= 0) PAY[k] = p[k];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MODEL_PAY = { haiku: 3, sonnet: 1.75, opus: 1 };
|
|
33
|
+
export const modelTier = (model) => Object.keys(MODEL_PAY).find((t) => String(model || '').includes(t)) || '';
|
|
34
|
+
export const modelPay = (model) => MODEL_PAY[modelTier(model)] || 1;
|
|
35
|
+
|
|
36
|
+
// The cut, in whole naira. The agent's share is rounded down and the pot
|
|
37
|
+
// takes the rest, so the two always add up to what was earned.
|
|
38
|
+
export function split(gross) {
|
|
39
|
+
const g = Math.max(0, Math.round(gross));
|
|
40
|
+
const agent = Math.floor(g * (1 - PAY.potShare));
|
|
41
|
+
return { gross: g, agent, pot: g - agent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Everything for sale. `scope` says who owns it and where it goes:
|
|
45
|
+
// desk on one agent's desk; an agent buys it with their own pay, or the
|
|
46
|
+
// boss buys it for them out of the pot
|
|
47
|
+
// office somewhere in the room; bought out of the pot
|
|
48
|
+
// floor / wall the room's finish; bought out of the pot, one in use at a time
|
|
49
|
+
// layout a different floor plan; free to switch between, one in use at a time
|
|
50
|
+
export const ITEMS = [
|
|
51
|
+
// desk
|
|
52
|
+
{ id: 'mug', scope: 'desk', name: 'Coffee mug', price: 2000, icon: '☕' },
|
|
53
|
+
{ id: 'duck', scope: 'desk', name: 'Rubber duck', price: 3000, icon: '🦆' },
|
|
54
|
+
{ id: 'cactus', scope: 'desk', name: 'Cactus', price: 3500, icon: '🌵' },
|
|
55
|
+
{ id: 'photo', scope: 'desk', name: 'Photo frame', price: 5000, icon: '🖼️' },
|
|
56
|
+
{ id: 'lamp', scope: 'desk', name: 'Desk lamp', price: 6000, icon: '💡' },
|
|
57
|
+
{ id: 'headphones', scope: 'desk', name: 'Headphones', price: 8000, icon: '🎧' },
|
|
58
|
+
{ id: 'lava', scope: 'desk', name: 'Lava lamp', price: 9000, icon: '🫧' },
|
|
59
|
+
{ id: 'bonsai', scope: 'desk', name: 'Bonsai', price: 12000, icon: '🌳' },
|
|
60
|
+
{ id: 'keyboard', scope: 'desk', name: 'Mechanical keyboard', price: 14000, icon: '⌨️' },
|
|
61
|
+
{ id: 'trophy', scope: 'desk', name: 'Little trophy', price: 15000, icon: '🏆' },
|
|
62
|
+
{ id: 'monitor', scope: 'desk', name: 'Second monitor', price: 20000, icon: '🖥️' },
|
|
63
|
+
{ id: 'chair', scope: 'desk', name: 'Gaming chair', price: 30000, icon: '💺' },
|
|
64
|
+
{ id: 'cat', scope: 'desk', name: 'Desk cat', price: 40000, icon: '🐈' },
|
|
65
|
+
// office
|
|
66
|
+
{ id: 'neon', scope: 'office', name: 'Neon sign', price: 50000, icon: '🔆' },
|
|
67
|
+
{ id: 'vending', scope: 'office', name: 'Vending machine', price: 70000, icon: '🥤' },
|
|
68
|
+
{ id: 'arcade', scope: 'office', name: 'Arcade cabinet', price: 80000, icon: '🕹️' },
|
|
69
|
+
{ id: 'jukebox', scope: 'office', name: 'Jukebox', price: 90000, icon: '🎵' },
|
|
70
|
+
{ id: 'aquarium', scope: 'office', name: 'Aquarium', price: 120000, icon: '🐠' },
|
|
71
|
+
{ id: 'pingpong', scope: 'office', name: 'Ping-pong table', price: 150000, icon: '🏓' },
|
|
72
|
+
// floor and walls
|
|
73
|
+
{ id: 'floor-oak', scope: 'floor', name: 'Oak floor', price: 0, color: '#ece3d4', icon: '🟫' },
|
|
74
|
+
{ id: 'floor-walnut', scope: 'floor', name: 'Walnut floor', price: 40000, color: '#b48a63', icon: '🟫' },
|
|
75
|
+
{ id: 'floor-terrazzo', scope: 'floor', name: 'Terrazzo', price: 40000, color: '#e6e1da', icon: '⬜' },
|
|
76
|
+
{ id: 'floor-concrete', scope: 'floor', name: 'Polished concrete', price: 40000, color: '#c9c9c4', icon: '⬛' },
|
|
77
|
+
{ id: 'wall-cream', scope: 'wall', name: 'Cream walls', price: 0, color: '#f5f0e8', icon: '🧱' },
|
|
78
|
+
{ id: 'wall-sage', scope: 'wall', name: 'Sage walls', price: 35000, color: '#d6e2d0', icon: '🧱' },
|
|
79
|
+
{ id: 'wall-navy', scope: 'wall', name: 'Navy walls', price: 35000, color: '#3d4f6e', icon: '🧱' },
|
|
80
|
+
{ id: 'wall-blush', scope: 'wall', name: 'Blush walls', price: 35000, color: '#f1d9d2', icon: '🧱' },
|
|
81
|
+
// layouts
|
|
82
|
+
{ id: 'layout-classic', scope: 'layout', name: 'Classic', price: 0, icon: '🏢', blurb: 'Two rows of desks, the library and server room on the back wall.' },
|
|
83
|
+
{ id: 'layout-pods', scope: 'layout', name: 'Pods', price: 0, icon: '🧩', blurb: 'Desks in two back-to-back clusters of four, a table and rug between them.' },
|
|
84
|
+
{ id: 'layout-studio', scope: 'layout', name: 'Studio', price: 0, icon: '🎨', blurb: 'One long row of desks along the back, the floor left open for the table and the pool.' },
|
|
85
|
+
{ id: 'layout-campus', scope: 'layout', name: 'Campus', price: 0, icon: '🏛️', blurb: 'Desks facing in round a courtyard, with the pool table at its heart.' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export const itemById = (id) => ITEMS.find((i) => i.id === id);
|
|
89
|
+
// What every office starts out owning and using.
|
|
90
|
+
export const DEFAULTS = { floor: 'floor-oak', wall: 'wall-cream', layout: 'layout-classic' };
|
|
91
|
+
// The scopes where one choice is in use at a time.
|
|
92
|
+
export const SINGLE = new Set(['floor', 'wall', 'layout']);
|
|
93
|
+
|
|
94
|
+
// Milestones. Each pays a one-off bonus, split like any pay.
|
|
95
|
+
export const ACHIEVEMENTS = [
|
|
96
|
+
{ id: 'first-job', name: 'First day', blurb: 'Finished a first job.', bonus: 2500, test: (s) => s.jobs >= 1 },
|
|
97
|
+
{ id: 'ten-jobs', name: 'Regular', blurb: 'Finished 10 jobs.', bonus: 10000, test: (s) => s.jobs >= 10 },
|
|
98
|
+
{ id: 'fifty-jobs', name: 'Veteran', blurb: 'Finished 50 jobs.', bonus: 40000, test: (s) => s.jobs >= 50 },
|
|
99
|
+
{ id: 'haiku-hero', name: 'Haiku hero', blurb: 'Finished 5 jobs on Haiku.', bonus: 15000, test: (s) => (s.byTier.haiku || 0) >= 5 },
|
|
100
|
+
{ id: 'lean', name: 'Lean machine', blurb: 'Finished a job on Haiku in under 2 active minutes.', bonus: 5000, test: (s, j) => j?.tier === 'haiku' && j.minutes < 2 },
|
|
101
|
+
{ id: 'kudos', name: 'Great work', blurb: 'Got kudos from the boss.', bonus: 5000, test: (s) => s.kudos >= 1 },
|
|
102
|
+
{ id: 'streak-3', name: 'On a roll', blurb: 'Finished jobs three days running.', bonus: 12000, test: (s) => s.streak >= 3 },
|
|
103
|
+
{ id: 'big-earner', name: 'Big earner', blurb: 'Earned ₦200,000 in all.', bonus: 20000, test: (s) => s.earned >= 200000 },
|
|
104
|
+
// Quality over volume: none of these scale with the model, so nobody gains
|
|
105
|
+
// by picking a model for the badge.
|
|
106
|
+
{ id: 'straight-through', name: 'Straight through', blurb: 'Got kudos on a job that never needed reopening.', bonus: 4000, test: (s) => s.cleanKudos >= 1 },
|
|
107
|
+
{ id: 'second-wind', name: 'Second wind', blurb: 'Finished a job again after it was reopened.', bonus: 3000, test: (s) => s.comebacks >= 1 },
|
|
108
|
+
{ id: 'team-player', name: 'Team player', blurb: 'Finished 3 jobs handed over by a colleague.', bonus: 8000, test: (s) => s.handed >= 3 },
|
|
109
|
+
{ id: 'second-eyes', name: 'Second pair of eyes', blurb: 'Finished 3 reviews or test passes for a colleague.', bonus: 8000, test: (s) => s.reviews >= 3 },
|
|
110
|
+
{ id: 'quick-study', name: 'Quick study', blurb: 'Finished a job in under half your usual time (after 5 jobs).', bonus: 5000, test: (s, j) => !!j?.quick },
|
|
111
|
+
{ id: 'shopper', name: 'Treat yourself', blurb: 'Bought something for the desk.', bonus: 1500, test: (s) => s.bought >= 1 },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// Titles, from lifetime earnings. Shown on the name tag and in the chat.
|
|
115
|
+
export const LEVELS = [
|
|
116
|
+
{ at: 0, title: 'Intern' },
|
|
117
|
+
{ at: 25000, title: 'Associate' },
|
|
118
|
+
{ at: 100000, title: 'Specialist' },
|
|
119
|
+
{ at: 300000, title: 'Senior' },
|
|
120
|
+
{ at: 800000, title: 'Principal' },
|
|
121
|
+
{ at: 2000000, title: 'Distinguished' },
|
|
122
|
+
];
|
|
123
|
+
export function levelOf(earned) {
|
|
124
|
+
let n = 0;
|
|
125
|
+
LEVELS.forEach((l, i) => { if ((Number(earned) || 0) >= l.at) n = i; });
|
|
126
|
+
return { n: n + 1, title: LEVELS[n].title, next: LEVELS[n + 1]?.at ?? null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A streak of working days lifts the wage (not the finish bonus).
|
|
130
|
+
export const STREAK_BOOST = [{ days: 14, boost: 0.2 }, { days: 7, boost: 0.1 }, { days: 3, boost: 0.05 }];
|
|
131
|
+
export const streakBoost = (days) => STREAK_BOOST.find((b) => days >= b.days)?.boost || 0;
|
|
132
|
+
|
|
133
|
+
// What a boss may tip from the pot in one go.
|
|
134
|
+
export const TIP = { min: 500, max: 50000, presets: [1000, 5000, 20000] };
|