@gemwright/ai 0.0.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/Dockerfile +12 -0
- package/README.md +82 -1
- package/catalogs/aggressive-bodies.json +1 -0
- package/catalogs/crafts/alchemy.json +22 -0
- package/catalogs/crafts/blacksmith.json +110 -0
- package/catalogs/crafts/bowcraft.json +13 -0
- package/catalogs/crafts/carpentry.json +105 -0
- package/catalogs/crafts/inscription.json +67 -0
- package/catalogs/crafts/tailoring.json +72 -0
- package/catalogs/crafts/tinkering.json +61 -0
- package/catalogs/dungeons/covetous.json +1 -0
- package/catalogs/dungeons/deceit.json +1 -0
- package/catalogs/dungeons/despise.json +1 -0
- package/catalogs/dungeons/destard.json +1 -0
- package/catalogs/dungeons/fire.json +1 -0
- package/catalogs/dungeons/hythloth.json +1 -0
- package/catalogs/dungeons/ice.json +1 -0
- package/catalogs/dungeons/khaldun.json +1 -0
- package/catalogs/dungeons/misc-dungeons.json +1 -0
- package/catalogs/dungeons/orc-cave.json +1 -0
- package/catalogs/dungeons/shame.json +1 -0
- package/catalogs/dungeons/terathan-keep.json +1 -0
- package/catalogs/dungeons/wrong.json +1 -0
- package/catalogs/house-lots.json +86 -0
- package/catalogs/hunting-grounds.json +10071 -0
- package/catalogs/magery-spells.json +697 -0
- package/catalogs/monsters.json +1 -0
- package/catalogs/prices.json +466 -0
- package/catalogs/tamables.json +864 -0
- package/catalogs/taming-grounds.json +2198 -0
- package/dist/index.js +55289 -0
- package/dist/life-journal-worker.js +469 -0
- package/dist/load-test.js +55098 -0
- package/dist/path-worker.js +1384 -0
- package/dist/persistence-worker.js +16503 -0
- package/dist/progress-report.js +182 -0
- package/dist/report.js +163 -0
- package/dist/serve.js +62078 -0
- package/locations/ankhs.json +91 -0
- package/locations/areas.json +760 -0
- package/locations/cobblers.json +148 -0
- package/locations/escorts.json +115 -0
- package/locations/forges.json +18 -0
- package/locations/locations.json +1501 -0
- package/locations/new-haven.json +522 -0
- package/locations/route-corridors.json +1 -0
- package/locations/services-guide.json +1329 -0
- package/locations/services.json +144 -0
- package/locations/teleporters.json +38 -0
- package/locations/vendors.json +1503 -0
- package/package.json +39 -3
- package/railway.json +6 -0
- package/start.sh +36 -0
- package/templates/archetypes-new-haven.json +38 -0
- package/templates/archetypes.json +274 -0
- package/templates/population.example.json +123 -0
- package/tuning.json +17 -0
- package/world/names.json +4055 -0
- package/world/world.json +743 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/progress-report.ts
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { parseArgs } from "util";
|
|
6
|
+
var { values } = parseArgs({
|
|
7
|
+
args: process.argv.slice(2),
|
|
8
|
+
options: {
|
|
9
|
+
db: { type: "string" },
|
|
10
|
+
before: { type: "string" },
|
|
11
|
+
minutes: { type: "string" },
|
|
12
|
+
out: { type: "string" }
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
if (!values.db)
|
|
16
|
+
throw new Error("--db is required");
|
|
17
|
+
var minutes = Number(values.minutes ?? 60);
|
|
18
|
+
var WORK = ["mined", "chopped", "fished", "crafted", "kills", "goldFromShops", "tamed", "stolen", "escorts"];
|
|
19
|
+
var PROGRESS = /\b(killed|defeated|looted|mined|chopped|caught|sold|crafted|tamed|bought|earned|paid|smelted|gained|delivered)\b/i;
|
|
20
|
+
var STRANDED = /No public moongate can be reached|stranded/i;
|
|
21
|
+
var shape = (text) => text.replace(/\d+/g, "#").replace(/\s+/g, " ").slice(0, 90);
|
|
22
|
+
var open = (file) => new Database(file, { readonly: true });
|
|
23
|
+
var now = open(values.db);
|
|
24
|
+
var end = now.query("SELECT MAX(at) AS at FROM activity_samples").get()?.at ?? Date.now();
|
|
25
|
+
var before = values.before ? open(values.before) : undefined;
|
|
26
|
+
var start = before?.query("SELECT MAX(at) AS at FROM activity_samples").get()?.at ?? end - minutes * 60000;
|
|
27
|
+
var windowMinutes = Math.max(0, (end - start) / 60000);
|
|
28
|
+
var deathLog = JSON.parse(now.query("SELECT value FROM metadata WHERE key = ?").get("lasting")?.value ?? "{}")["death-log"] ?? [];
|
|
29
|
+
var deaths = new Map;
|
|
30
|
+
for (const [key] of deathLog) {
|
|
31
|
+
const [serial = "0", place = "", cause = "unknown", at = "0"] = key.split("|");
|
|
32
|
+
if (Number(at) < start)
|
|
33
|
+
continue;
|
|
34
|
+
deaths.set(Number(serial), [...deaths.get(Number(serial)) ?? [], `${place} (${cause})`]);
|
|
35
|
+
}
|
|
36
|
+
var worthAt = (id) => now.query("SELECT COALESCE(gold, 0) + COALESCE(bank, 0) AS w FROM activity_samples WHERE resident_id = ? AND at <= ? ORDER BY at DESC LIMIT 1").get(id, start)?.w;
|
|
37
|
+
var skillTotal = (db, id) => {
|
|
38
|
+
const row = db?.query("SELECT skills_json AS s FROM skill_snapshots WHERE resident_id = ?").get(id);
|
|
39
|
+
if (!row)
|
|
40
|
+
return;
|
|
41
|
+
const skills = JSON.parse(row.s);
|
|
42
|
+
return Object.values(skills).reduce((n, v) => n + (typeof v === "number" ? v : v.base ?? v.value), 0);
|
|
43
|
+
};
|
|
44
|
+
var workDone = (db, id) => {
|
|
45
|
+
const row = db?.query("SELECT totals_json AS t FROM activity WHERE resident_id = ?").get(id);
|
|
46
|
+
if (!row)
|
|
47
|
+
return;
|
|
48
|
+
const totals = JSON.parse(row.t);
|
|
49
|
+
return WORK.reduce((n, k) => n + (totals[k] ?? 0), 0);
|
|
50
|
+
};
|
|
51
|
+
function repeated(steps) {
|
|
52
|
+
let best = { run: [], times: 0 };
|
|
53
|
+
for (let size = 2;size <= 6; size++)
|
|
54
|
+
for (let i = 0;i + size <= steps.length; i++) {
|
|
55
|
+
const run = steps.slice(i, i + size);
|
|
56
|
+
if (new Set(run).size < 2)
|
|
57
|
+
continue;
|
|
58
|
+
let times = 0;
|
|
59
|
+
for (let j = 0;j + size <= steps.length; )
|
|
60
|
+
if (steps.slice(j, j + size).every((s, k) => s === run[k])) {
|
|
61
|
+
times++;
|
|
62
|
+
j += size;
|
|
63
|
+
} else
|
|
64
|
+
j++;
|
|
65
|
+
if (times > best.times || times === best.times && size > best.run.length)
|
|
66
|
+
best = { run, times };
|
|
67
|
+
}
|
|
68
|
+
return best;
|
|
69
|
+
}
|
|
70
|
+
var activeNow = now.query("SELECT id, proposed_name AS name, template_id AS template, binding_json AS binding, checkpoint_json AS checkpoint FROM residents WHERE desired_active = 1").all();
|
|
71
|
+
var priorActive = before?.query("SELECT id, proposed_name AS name FROM residents WHERE desired_active = 1").all();
|
|
72
|
+
var priorIds = new Set(priorActive?.map((resident) => resident.id));
|
|
73
|
+
var currentIds = new Set(activeNow.map((resident) => resident.id));
|
|
74
|
+
var allIds = new Set(now.query("SELECT id FROM residents").all().map((resident) => resident.id));
|
|
75
|
+
var residents = priorActive ? activeNow.filter((resident) => priorIds.has(resident.id)) : activeNow;
|
|
76
|
+
var cohort = {
|
|
77
|
+
matched: residents.map((resident) => resident.id),
|
|
78
|
+
introduced: priorActive ? activeNow.filter((resident) => !priorIds.has(resident.id)).map(({ id, name }) => ({ id, name })) : [],
|
|
79
|
+
retired: (priorActive ?? []).filter((resident) => !currentIds.has(resident.id) && allIds.has(resident.id)),
|
|
80
|
+
missing: (priorActive ?? []).filter((resident) => !allIds.has(resident.id))
|
|
81
|
+
};
|
|
82
|
+
var latestWorth = (database, id) => database.query("SELECT gold, bank FROM activity_samples WHERE resident_id = ? ORDER BY at DESC LIMIT 1").get(id);
|
|
83
|
+
var rows = residents.map((r) => {
|
|
84
|
+
const samples = now.query("SELECT at, x, y, phase, COALESCE(gold, 0) AS gold, COALESCE(bank, 0) AS bank FROM activity_samples WHERE resident_id = ? AND at >= ? ORDER BY at").all(r.id, start);
|
|
85
|
+
const first = samples[0];
|
|
86
|
+
const last = samples.at(-1);
|
|
87
|
+
const opening = worthAt(r.id) ?? (first ? first.gold + first.bank : 0);
|
|
88
|
+
const oldWorth = before ? latestWorth(before, r.id) : undefined;
|
|
89
|
+
const currentWorth = latestWorth(now, r.id);
|
|
90
|
+
const goldComparable = before ? oldWorth?.gold != null && oldWorth.bank != null && currentWorth?.gold != null && currentWorth.bank != null : !!last;
|
|
91
|
+
const gold = before ? goldComparable && oldWorth && currentWorth ? (currentWorth.gold ?? 0) + (currentWorth.bank ?? 0) - (oldWorth.gold ?? 0) - (oldWorth.bank ?? 0) : 0 : last ? last.gold + last.bank - opening : 0;
|
|
92
|
+
let idleMinutes = 0;
|
|
93
|
+
let stillSince;
|
|
94
|
+
for (const s of samples) {
|
|
95
|
+
const still = stillSince && Math.max(Math.abs(s.x - stillSince.x), Math.abs(s.y - stillSince.y)) <= 3 && s.gold + s.bank === stillSince.gold + stillSince.bank && !["leisure", "practice"].includes(s.phase);
|
|
96
|
+
if (!still)
|
|
97
|
+
stillSince = s;
|
|
98
|
+
else if (stillSince)
|
|
99
|
+
idleMinutes = Math.max(idleMinutes, (s.at - stillSince.at) / 60000);
|
|
100
|
+
}
|
|
101
|
+
const places = new Set(samples.map((s) => `${s.x >> 4},${s.y >> 4}`)).size;
|
|
102
|
+
const phases = {};
|
|
103
|
+
for (const s of samples)
|
|
104
|
+
phases[s.phase ?? "?"] = (phases[s.phase ?? "?"] ?? 0) + 1;
|
|
105
|
+
const checkpoint = JSON.parse(r.checkpoint);
|
|
106
|
+
const memories = (checkpoint.memories ?? []).filter((m) => Date.parse(m.at) >= start && !/ said "/.test(m.text));
|
|
107
|
+
const steps = memories.map((m) => shape(m.text));
|
|
108
|
+
const skills = (skillTotal(now, r.id) ?? 0) - (skillTotal(before, r.id) ?? skillTotal(now, r.id) ?? 0);
|
|
109
|
+
const work = (workDone(now, r.id) ?? 0) - (workDone(before, r.id) ?? workDone(now, r.id) ?? 0);
|
|
110
|
+
const progressed = memories.find((m) => PROGRESS.test(m.text));
|
|
111
|
+
const loop = repeated(steps);
|
|
112
|
+
const serial = Number(JSON.parse(r.binding ?? "{}").characterSerial ?? 0);
|
|
113
|
+
const died = deaths.get(serial) ?? [];
|
|
114
|
+
const base = {
|
|
115
|
+
id: r.id,
|
|
116
|
+
comparable: {
|
|
117
|
+
gold: goldComparable,
|
|
118
|
+
skills: !!before && skillTotal(before, r.id) !== undefined && skillTotal(now, r.id) !== undefined,
|
|
119
|
+
work: !!before && workDone(before, r.id) !== undefined && workDone(now, r.id) !== undefined
|
|
120
|
+
},
|
|
121
|
+
name: r.name,
|
|
122
|
+
trade: r.template,
|
|
123
|
+
serial,
|
|
124
|
+
gold,
|
|
125
|
+
places,
|
|
126
|
+
skills: Math.round(skills * 10) / 10,
|
|
127
|
+
work,
|
|
128
|
+
deaths: died,
|
|
129
|
+
idleMinutes: Math.round(idleMinutes),
|
|
130
|
+
phases
|
|
131
|
+
};
|
|
132
|
+
const recent = steps.slice(-3).join(" / ") || "nothing remembered in the window";
|
|
133
|
+
if (died.length)
|
|
134
|
+
return { ...base, label: "died", reason: died.join("; ") };
|
|
135
|
+
if (memories.some((m) => STRANDED.test(m.text)) && gold <= 0 && work <= 0)
|
|
136
|
+
return { ...base, label: "stranded", reason: memories.findLast((m) => STRANDED.test(m.text))?.text ?? "" };
|
|
137
|
+
if (gold > 0 || skills > 0 || work > 0 || !before && progressed)
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
label: "productive",
|
|
141
|
+
reason: [
|
|
142
|
+
gold > 0 ? `+${gold} gold` : "",
|
|
143
|
+
skills > 0 ? `+${skills.toFixed(1)} skill` : "",
|
|
144
|
+
work > 0 ? `+${work} work` : "",
|
|
145
|
+
progressed && !(gold > 0 || skills > 0 || work > 0) ? progressed.text.slice(0, 80) : ""
|
|
146
|
+
].filter(Boolean).join(", ")
|
|
147
|
+
};
|
|
148
|
+
if (idleMinutes >= 10)
|
|
149
|
+
return { ...base, label: "idle", reason: `${Math.round(idleMinutes)} min standing still: ${recent}` };
|
|
150
|
+
if (loop.times >= 3)
|
|
151
|
+
return { ...base, label: "looping", reason: `${loop.times}\xD7 ${loop.run.join(" \u2192 ")}` };
|
|
152
|
+
if (places <= 2)
|
|
153
|
+
return { ...base, label: "idle", reason: recent };
|
|
154
|
+
return { ...base, label: "wandering", reason: `${places} places, no progress: ${recent}` };
|
|
155
|
+
});
|
|
156
|
+
var order = ["died", "stranded", "looping", "idle", "wandering", "productive"];
|
|
157
|
+
var count = (label, list = rows) => list.filter((r) => r.label === label).length;
|
|
158
|
+
console.log(`Last ${windowMinutes.toFixed(1)} minutes to ${new Date(end).toISOString().slice(0, 16).replace("T", " ")} UTC, ${rows.length} residents${before ? ", skills and work compared with --before" : ""}`);
|
|
159
|
+
if (before)
|
|
160
|
+
console.log(`Cohort: matched ${cohort.matched.length}, introduced ${cohort.introduced.length}, retired ${cohort.retired.length}, missing ${cohort.missing.length}`);
|
|
161
|
+
console.log(order.map((l) => `${l} ${count(l)}`).join(" \xB7 "));
|
|
162
|
+
var sum = (f) => rows.reduce((n, r) => n + f(r), 0);
|
|
163
|
+
console.log(`Population: skills ${before ? `${sum((r) => r.skills) >= 0 ? "+" : ""}${sum((r) => r.skills).toFixed(1)}` : "n/a (needs --before)"} \xB7 gold ${sum((r) => r.gold) >= 0 ? "+" : ""}${sum((r) => r.gold).toLocaleString("en-GB")} \xB7 deaths ${sum((r) => r.deaths.length)} \xB7 residents gaining skill ${rows.filter((r) => r.skills > 0).length}, gaining gold ${rows.filter((r) => r.gold > 0).length}`);
|
|
164
|
+
var trades = [...new Set(rows.map((r) => r.trade))].sort();
|
|
165
|
+
console.log(`
|
|
166
|
+
By trade (productive of all):`);
|
|
167
|
+
for (const t of trades) {
|
|
168
|
+
const list = rows.filter((r) => r.trade === t);
|
|
169
|
+
console.log(` ${t.padEnd(26)} ${String(count("productive", list)).padStart(3)} / ${String(list.length).padEnd(3)} ${order.slice(0, 5).filter((l) => count(l, list)).map((l) => `${l} ${count(l, list)}`).join(", ")}`);
|
|
170
|
+
}
|
|
171
|
+
for (const label of order.slice(0, 5)) {
|
|
172
|
+
const list = rows.filter((r) => r.label === label);
|
|
173
|
+
if (!list.length)
|
|
174
|
+
continue;
|
|
175
|
+
console.log(`
|
|
176
|
+
${label} (${list.length}):`);
|
|
177
|
+
for (const r of list)
|
|
178
|
+
console.log(` ${r.name.padEnd(18)} ${r.trade.padEnd(24)} ${r.reason.slice(0, 160)}`);
|
|
179
|
+
}
|
|
180
|
+
if (values.out)
|
|
181
|
+
writeFileSync(values.out, `${JSON.stringify({ start, end, minutes: windowMinutes, cohort, rows }, null, 1)}
|
|
182
|
+
`);
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/report.ts
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
var { values } = parseArgs({
|
|
6
|
+
args: process.argv.slice(2),
|
|
7
|
+
options: { db: { type: "string" }, days: { type: "string" } }
|
|
8
|
+
});
|
|
9
|
+
if (!values.db)
|
|
10
|
+
throw new Error("--db is required");
|
|
11
|
+
var db = new Database(values.db, { readonly: true });
|
|
12
|
+
var since = Date.now() - Number(values.days ?? 7) * 24 * 3600000;
|
|
13
|
+
var has = (table) => !!db.query("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ?").get(table)?.n;
|
|
14
|
+
var out = [];
|
|
15
|
+
var line = (text = "") => out.push(text);
|
|
16
|
+
var hours = (ms) => Math.max(1 / 60, ms / 3600000);
|
|
17
|
+
line(`# Population report (last ${values.days ?? 7} days)`);
|
|
18
|
+
var roster = db.query("SELECT template_id, desired_active FROM residents").all();
|
|
19
|
+
var byProfession = new Map;
|
|
20
|
+
for (const r of roster.filter((r) => r.desired_active)) {
|
|
21
|
+
const profession = r.template_id.replace(/^[a-z]+(?:-brae)?-/, "");
|
|
22
|
+
byProfession.set(profession, (byProfession.get(profession) ?? 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
line(`
|
|
25
|
+
## Population
|
|
26
|
+
|
|
27
|
+
Active residents: ${roster.filter((r) => r.desired_active).length} (${roster.length} in the roster)`);
|
|
28
|
+
line([...byProfession].sort((a, b) => b[1] - a[1]).map(([p, n]) => `${p} ${n}`).join(", "));
|
|
29
|
+
if (has("population_metrics")) {
|
|
30
|
+
const m = db.query(`SELECT COUNT(*) n, AVG(online) online, MIN(online) low, MAX(rss_mb) rss, MAX(lag_max_ms) lag, SUM(stalls) stalls
|
|
31
|
+
FROM population_metrics WHERE at > ?`).get(since);
|
|
32
|
+
if (m?.n)
|
|
33
|
+
line(`
|
|
34
|
+
Online: ${Math.round(m.online)} on average, ${m.low} at the lowest (${m.n} samples). Memory peak ${m.rss} MB; worst event-loop lag ${m.lag} ms; ${m.stalls} stalls over 1.5 s.`);
|
|
35
|
+
}
|
|
36
|
+
if (has("activity") && has("activity_samples")) {
|
|
37
|
+
const span = db.query("SELECT MIN(at) first, MAX(at) last FROM activity_samples").get();
|
|
38
|
+
const totals = {};
|
|
39
|
+
for (const r of db.query("SELECT totals_json FROM activity").all())
|
|
40
|
+
for (const [k, v] of Object.entries(JSON.parse(r.totals_json)))
|
|
41
|
+
totals[k] = (totals[k] ?? 0) + v;
|
|
42
|
+
const recorded = span?.first ? hours((span.last ?? 0) - span.first) : 1;
|
|
43
|
+
line(`
|
|
44
|
+
## Activity (since ${span?.first ? new Date(span.first).toISOString() : "never"})
|
|
45
|
+
`);
|
|
46
|
+
line(`| | Total | Per hour |
|
|
47
|
+
|---|---|---|`);
|
|
48
|
+
for (const [k, v] of Object.entries(totals).sort((a, b) => b[1] - a[1]))
|
|
49
|
+
line(`| ${k} | ${v.toLocaleString("en")} | ${Math.round(v / recorded).toLocaleString("en")} |`);
|
|
50
|
+
const wealth = db.query("SELECT SUM(COALESCE(gold, 0)) gold, SUM(COALESCE(bank, 0)) bank FROM activity_samples WHERE at = (SELECT MAX(at) FROM activity_samples)").get();
|
|
51
|
+
line(`
|
|
52
|
+
Gold now: ${wealth?.gold ?? 0} carried, ${wealth?.bank ?? 0} last reported banked.`);
|
|
53
|
+
}
|
|
54
|
+
var skills = (has("skill_snapshots") ? db.query("SELECT skills_json FROM skill_snapshots").all() : []).map((r) => Object.values(JSON.parse(r.skills_json)).reduce((n, s) => n + (s.base ?? 0), 0)).sort((a, b) => a - b);
|
|
55
|
+
if (skills.length)
|
|
56
|
+
line(`
|
|
57
|
+
## Skills
|
|
58
|
+
|
|
59
|
+
Skill points per resident: lowest ${skills[0]?.toFixed(1)}, median ${skills[Math.floor(skills.length / 2)]?.toFixed(1)}, highest ${skills.at(-1)?.toFixed(1)}.`);
|
|
60
|
+
if (has("trade_log")) {
|
|
61
|
+
line(`
|
|
62
|
+
## Trades
|
|
63
|
+
|
|
64
|
+
| Good | Side | Done | Failed | Amount | Gold |
|
|
65
|
+
|---|---|---|---|---|---|`);
|
|
66
|
+
for (const r of db.query(`SELECT good, role, SUM(completed) done, SUM(1 - completed) failed, SUM(amount * completed) amount, SUM(price * completed) gold
|
|
67
|
+
FROM trade_log WHERE at > ? GROUP BY good, role ORDER BY good, role`).all(since))
|
|
68
|
+
line(`| ${r.good} | ${r.role} | ${r.done} | ${r.failed} | ${r.amount ?? 0} | ${r.gold ?? 0} |`);
|
|
69
|
+
const reasons = db.query("SELECT reason, COUNT(*) n FROM trade_log WHERE at > ? AND completed = 0 AND reason IS NOT NULL GROUP BY reason ORDER BY n DESC").all(since);
|
|
70
|
+
if (reasons.length)
|
|
71
|
+
line(`
|
|
72
|
+
Why deals failed: ${reasons.map((r) => `${r.reason} (${r.n})`).join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
var states = { mount: new Map, pack: new Map };
|
|
75
|
+
var banked = [];
|
|
76
|
+
for (const r of db.query("SELECT checkpoint_json c FROM residents WHERE desired_active = 1").all()) {
|
|
77
|
+
const c = JSON.parse(r.c);
|
|
78
|
+
banked.push(c.funds?.bank ?? 0);
|
|
79
|
+
for (const [key, value] of [
|
|
80
|
+
["mount", c.mount?.state],
|
|
81
|
+
["pack", c.packAnimal?.state]
|
|
82
|
+
])
|
|
83
|
+
states[key].set(value ?? "none", (states[key].get(value ?? "none") ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
var show = (m) => [...m].map(([k, n]) => `${k} ${n}`).join(", ");
|
|
86
|
+
line(`
|
|
87
|
+
## Animals
|
|
88
|
+
|
|
89
|
+
Horses: ${show(states.mount)}
|
|
90
|
+
Pack animals: ${show(states.pack)}`);
|
|
91
|
+
if (has("activity_samples")) {
|
|
92
|
+
const rows = db.query(`WITH span AS (SELECT resident_id, MIN(at) f, MAX(at) l FROM activity_samples WHERE at > ? GROUP BY resident_id)
|
|
93
|
+
SELECT span.resident_id id, r.template_id t, f first, l last,
|
|
94
|
+
(SELECT COALESCE(gold, 0) + COALESCE(bank, 0) FROM activity_samples x WHERE x.resident_id = span.resident_id AND x.at = f) a,
|
|
95
|
+
(SELECT COALESCE(gold, 0) + COALESCE(bank, 0) FROM activity_samples x WHERE x.resident_id = span.resident_id AND x.at = l) b
|
|
96
|
+
FROM span JOIN residents r ON r.id = span.resident_id`).all(since);
|
|
97
|
+
const groups = new Map;
|
|
98
|
+
for (const r of rows) {
|
|
99
|
+
const group = groups.get(r.t.replace(/^[a-z]+(?:-brae)?-/, "")) ?? { now: [], rate: [] };
|
|
100
|
+
groups.set(r.t.replace(/^[a-z]+(?:-brae)?-/, ""), group);
|
|
101
|
+
group.now.push(r.b);
|
|
102
|
+
if (r.last - r.first >= 2 * 3600000)
|
|
103
|
+
group.rate.push((r.b - r.a) / hours(r.last - r.first));
|
|
104
|
+
}
|
|
105
|
+
const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)] ?? 0;
|
|
106
|
+
line(`
|
|
107
|
+
## Wealth
|
|
108
|
+
|
|
109
|
+
| Profession | Median gold | Richest | Median gold an hour |
|
|
110
|
+
|---|---|---|---|`);
|
|
111
|
+
for (const [name, g] of [...groups].sort((a, b) => median(b[1].now) - median(a[1].now)))
|
|
112
|
+
line(`| ${name} | ${median(g.now)} | ${Math.max(...g.now)} | ${g.rate.length ? median(g.rate).toFixed(0) : "-"} |`);
|
|
113
|
+
}
|
|
114
|
+
if (has("houses")) {
|
|
115
|
+
const kinds = db.query("SELECT house, COUNT(*) n FROM houses WHERE status = 'placed' GROUP BY house ORDER BY n DESC").all();
|
|
116
|
+
const count = (sql) => db.query(sql).get()?.n ?? 0;
|
|
117
|
+
line(`
|
|
118
|
+
## Housing
|
|
119
|
+
|
|
120
|
+
Houses: ${kinds.reduce((n, k) => n + k.n, 0)}${kinds.length ? ` (${kinds.map((k) => `${k.house} ${k.n}`).join(", ")})` : ""}`);
|
|
121
|
+
line(`Lots claimed: ${count("SELECT COUNT(*) n FROM houses WHERE status = 'claimed'")}, refused by the server: ${count("SELECT COUNT(*) n FROM bad_lots")}`);
|
|
122
|
+
line(`Saving: ${banked.filter((g) => g >= 5000 && g < 40377).length}, able to buy: ${banked.filter((g) => g >= 40377).length}`);
|
|
123
|
+
}
|
|
124
|
+
if (has("guilds")) {
|
|
125
|
+
const rows = db.query("SELECT g.name, g.abbr, g.side, g.alliance, COUNT(m.member) n FROM guilds g LEFT JOIN guild_members m ON m.guild = g.name GROUP BY g.name ORDER BY g.founded_at").all();
|
|
126
|
+
line(`
|
|
127
|
+
## Guilds
|
|
128
|
+
|
|
129
|
+
${rows.length ? `| Guild | Side | Alliance | Members |
|
|
130
|
+
|---|---|---|---|` : "No guilds."}`);
|
|
131
|
+
for (const g of rows)
|
|
132
|
+
line(`| ${g.name} [${g.abbr}] | ${g.side} | ${g.alliance ?? "-"} | ${g.n} |`);
|
|
133
|
+
const wars = db.query("SELECT id, declarer, opponent, status, started_at FROM guild_wars ORDER BY declared_at DESC LIMIT 10").all();
|
|
134
|
+
for (const w of wars) {
|
|
135
|
+
const kills = db.query("SELECT killer_guild guild, COUNT(*) n FROM war_kills WHERE war = ? GROUP BY killer_guild").all(w.id);
|
|
136
|
+
line(`War ${w.declarer} vs ${w.opponent}: ${w.status}${w.started_at ? ` since ${new Date(w.started_at).toISOString()}` : ""}; kills ${kills.map((k) => `${k.guild} ${k.n}`).join(", ") || "none"}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (has("decision_spend")) {
|
|
140
|
+
const spend = db.query("SELECT day, input_tokens FROM decision_spend ORDER BY day DESC LIMIT 30").all();
|
|
141
|
+
const usd = spend.reduce((n, r) => n + r.input_tokens * 0.042 / 1e6, 0);
|
|
142
|
+
line(`
|
|
143
|
+
## Model cost
|
|
144
|
+
|
|
145
|
+
Jev: $${usd.toFixed(4)} over ${spend.length} day(s) recorded.`);
|
|
146
|
+
}
|
|
147
|
+
if (has("dungeon_runs")) {
|
|
148
|
+
const runs = db.query("SELECT COUNT(*) n FROM dungeon_runs").get()?.n ?? 0;
|
|
149
|
+
line(`
|
|
150
|
+
## Dungeons
|
|
151
|
+
|
|
152
|
+
Runs recorded: ${runs}`);
|
|
153
|
+
}
|
|
154
|
+
if (has("player_signals")) {
|
|
155
|
+
const signals = db.query("SELECT kind, COUNT(*) n, COUNT(DISTINCT serial) who FROM player_signals WHERE at > ? GROUP BY kind").all(since);
|
|
156
|
+
line(`
|
|
157
|
+
## Players
|
|
158
|
+
|
|
159
|
+
${signals.map((s) => `${s.kind}: ${s.n} from ${s.who} player(s)`).join(`
|
|
160
|
+
`) || "No player signals."}`);
|
|
161
|
+
}
|
|
162
|
+
console.log(out.join(`
|
|
163
|
+
`));
|