@gigzen/populace 0.1.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/LICENSE +661 -0
- package/README.md +258 -0
- package/adapters/buzzbuzz.mjs +247 -0
- package/adapters/contract.md +164 -0
- package/adapters/template-rest.mjs +192 -0
- package/adapters/template.mjs +80 -0
- package/examples/buzzbuzz/populace-report.html +245 -0
- package/examples/buzzbuzz/populace-report.json +280 -0
- package/examples/buzzbuzz/populace.config.mjs +51 -0
- package/examples/buzzbuzz/run-test.ps1 +61 -0
- package/examples/demo/adapters/demo.mjs +90 -0
- package/examples/demo/populace-report.html +230 -0
- package/examples/demo/populace-report.json +219 -0
- package/examples/demo/populace.config.mjs +22 -0
- package/examples/rest-api/README.md +85 -0
- package/examples/rest-api/adapter.mjs +166 -0
- package/examples/rest-api/populace.config.mjs +40 -0
- package/examples/rest-api/server.mjs +247 -0
- package/examples/token-expiry/expiry-demo.mjs +119 -0
- package/package.json +56 -0
- package/populace.config.example.mjs +65 -0
- package/src/cli.mjs +591 -0
- package/src/config.mjs +186 -0
- package/src/contract.mjs +130 -0
- package/src/diagnose.mjs +40 -0
- package/src/engine/agent.mjs +264 -0
- package/src/engine/geo.mjs +59 -0
- package/src/engine/index.mjs +4 -0
- package/src/engine/personas.mjs +115 -0
- package/src/engine/world.mjs +120 -0
- package/src/html-report.mjs +218 -0
- package/src/index.mjs +38 -0
- package/src/instrument.mjs +299 -0
- package/src/net.mjs +175 -0
- package/src/report.mjs +251 -0
- package/src/selftest.mjs +1369 -0
- package/src/smoke.mjs +274 -0
- package/src/version.mjs +24 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Config loading and the production safety guard.
|
|
2
|
+
//
|
|
3
|
+
// The guard is the most important code in this product. Populace creates REAL
|
|
4
|
+
// accounts and writes REAL rows through a customer's REAL API. Pointed at
|
|
5
|
+
// production it would put invented people in front of paying users — that is
|
|
6
|
+
// deception, not testing, and it is painful to unpick afterwards.
|
|
7
|
+
//
|
|
8
|
+
// So the guard is deliberately hard to get past by accident, and it refuses in
|
|
9
|
+
// three independent ways. Any one of them is enough to stop a run.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
|
|
15
|
+
export class ConfigError extends Error {}
|
|
16
|
+
|
|
17
|
+
const strip = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
|
|
18
|
+
|
|
19
|
+
/** Pull every string out of a nested object, so we can scan a whole target block. */
|
|
20
|
+
function stringsIn(value, found = []) {
|
|
21
|
+
if (typeof value === "string") found.push(value);
|
|
22
|
+
else if (Array.isArray(value)) value.forEach((v) => stringsIn(v, found));
|
|
23
|
+
else if (value && typeof value === "object") Object.values(value).forEach((v) => stringsIn(v, found));
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
environment: "test",
|
|
29
|
+
// How long any single adapter call may take before it is recorded as a
|
|
30
|
+
// timeout and the run moves on. Without this a dead socket hangs the whole
|
|
31
|
+
// simulation. Set 0 to disable if your adapter does legitimately long work.
|
|
32
|
+
timeoutMs: 20_000,
|
|
33
|
+
// Extra attempts for calls that never reached the server. Transport only —
|
|
34
|
+
// an error your API actually returned is a finding and is never retried.
|
|
35
|
+
retries: 3,
|
|
36
|
+
// Consecutive unreachable calls before Populace declares the target down and
|
|
37
|
+
// stops, instead of retrying every call for the rest of the run. 0 disables.
|
|
38
|
+
giveUpAfter: 12,
|
|
39
|
+
population: { agents: 6, cities: ["manila", "mumbai"], tickSeconds: 5, minutes: 10 },
|
|
40
|
+
// Comfortably inside a 1-hour token, which is the common default.
|
|
41
|
+
session: { refreshEveryMinutes: 30 },
|
|
42
|
+
report: { path: "populace-report.json" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function loadConfig({ configPath, cwd = process.cwd(), overrides = {} } = {}) {
|
|
46
|
+
// reportPath is not a population setting; keep it out of that spread.
|
|
47
|
+
const { reportPath: _reportPath, ...populationOverrides } = overrides;
|
|
48
|
+
const file = path.resolve(cwd, configPath || "populace.config.mjs");
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(file)) {
|
|
51
|
+
throw new ConfigError(
|
|
52
|
+
`No config found at ${path.relative(cwd, file) || file}\n\n` +
|
|
53
|
+
` Create one with: populace init\n` +
|
|
54
|
+
` Or point at one: populace run --config path/to/populace.config.mjs`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const loaded = (await import(pathToFileURL(file).href)).default;
|
|
59
|
+
if (!loaded || typeof loaded !== "object") {
|
|
60
|
+
throw new ConfigError(`${path.basename(file)} must \`export default\` a config object.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const config = {
|
|
64
|
+
...DEFAULTS,
|
|
65
|
+
...loaded,
|
|
66
|
+
population: {
|
|
67
|
+
...DEFAULTS.population,
|
|
68
|
+
...(loaded.population || {}),
|
|
69
|
+
...populationOverrides,
|
|
70
|
+
},
|
|
71
|
+
session: { ...DEFAULTS.session, ...(loaded.session || {}) },
|
|
72
|
+
report: {
|
|
73
|
+
...DEFAULTS.report,
|
|
74
|
+
...(loaded.report || {}),
|
|
75
|
+
// --report wins over the config file, so a run can put its output
|
|
76
|
+
// somewhere else without editing anything.
|
|
77
|
+
...(overrides.reportPath ? { path: overrides.reportPath } : {}),
|
|
78
|
+
},
|
|
79
|
+
_dir: path.dirname(file),
|
|
80
|
+
_file: file,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (!config.adapter) {
|
|
84
|
+
throw new ConfigError(`Config is missing \`adapter\` — the path to your adapter module.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
guardProduction(config);
|
|
88
|
+
return config;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Three independent refusals. Each exists because a different mistake is easy
|
|
93
|
+
* to make at 1am, and the cost of getting it wrong is borne by the customer's
|
|
94
|
+
* real users rather than by whoever made the mistake.
|
|
95
|
+
*/
|
|
96
|
+
export function guardProduction(config) {
|
|
97
|
+
const refuse = (why, fix) => {
|
|
98
|
+
throw new ConfigError(`REFUSING TO RUN\n\n ${why}\n\n ${fix}`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// 1. The environment must SAY it is not production. Opt in, never assume.
|
|
102
|
+
const env = String(config.environment || "").toLowerCase();
|
|
103
|
+
if (!["test", "staging", "dev", "development", "sandbox", "local"].includes(env)) {
|
|
104
|
+
refuse(
|
|
105
|
+
env === "production" || env === "prod"
|
|
106
|
+
? `The config declares environment: "${config.environment}".`
|
|
107
|
+
: `The config declares environment: "${config.environment || "(unset)"}", which is not a recognised non-production environment.`,
|
|
108
|
+
`Populace only runs against test environments. Set environment: "test"\n` +
|
|
109
|
+
` in ${path.basename(config._file || "populace.config.mjs")} — and make sure that is actually true.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Explicit denylist — a customer names their production hosts once and can
|
|
114
|
+
// never hit them again, however the config is later edited.
|
|
115
|
+
const denied = [
|
|
116
|
+
...(config.neverRunAgainst || []),
|
|
117
|
+
...String(process.env.POPULACE_PRODUCTION_URLS || "")
|
|
118
|
+
.split(",")
|
|
119
|
+
.map((s) => s.trim())
|
|
120
|
+
.filter(Boolean),
|
|
121
|
+
].map(strip);
|
|
122
|
+
|
|
123
|
+
if (denied.length) {
|
|
124
|
+
const targets = stringsIn(config.target).map(strip).filter(Boolean);
|
|
125
|
+
const hit = targets.find((t) => denied.some((d) => d && (t === d || t.includes(d) || d.includes(t))));
|
|
126
|
+
if (hit) {
|
|
127
|
+
refuse(
|
|
128
|
+
`The target matches a host listed in neverRunAgainst:\n ${hit}`,
|
|
129
|
+
`Simulated people must never be visible to real users.\n` +
|
|
130
|
+
` Point \`target\` at a separate test environment.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. A live-looking hostname with nothing declared to protect it. Not proof of
|
|
136
|
+
// production, so this one is a warning rather than a refusal — but it is
|
|
137
|
+
// loud, because "I forgot to fill in neverRunAgainst" is the likeliest
|
|
138
|
+
// version of this mistake.
|
|
139
|
+
if (!denied.length) {
|
|
140
|
+
config._warnings = [
|
|
141
|
+
...(config._warnings || []),
|
|
142
|
+
`neverRunAgainst is empty. List your production URLs there so this can never point at them.`,
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
return config;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Load the customer's adapter module and sanity-check its shape. */
|
|
149
|
+
export async function loadAdapter(config) {
|
|
150
|
+
const file = path.resolve(config._dir || process.cwd(), config.adapter);
|
|
151
|
+
if (!fs.existsSync(file)) {
|
|
152
|
+
throw new ConfigError(`Adapter not found: ${config.adapter}\n Looked in ${file}`);
|
|
153
|
+
}
|
|
154
|
+
const mod = await import(pathToFileURL(file).href);
|
|
155
|
+
const factory = mod.createAdapter || mod.default;
|
|
156
|
+
if (typeof factory !== "function") {
|
|
157
|
+
throw new ConfigError(
|
|
158
|
+
`${config.adapter} must export \`createAdapter(config)\`.\n` +
|
|
159
|
+
` See adapters/contract.md for the full contract.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
// An adapter that refuses to build is almost always a configuration mistake
|
|
163
|
+
// (a missing env var, usually). Present it as one instead of a stack trace —
|
|
164
|
+
// the person hitting this is evaluating the product in their first minute.
|
|
165
|
+
let adapter;
|
|
166
|
+
try {
|
|
167
|
+
adapter = await factory(config.target ?? {}, config);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw new ConfigError(
|
|
170
|
+
`Adapter "${path.basename(config.adapter)}" could not start:\n\n` +
|
|
171
|
+
` ${String(error.message || error).replace(/\n/g, "\n ")}\n\n` +
|
|
172
|
+
` Check \`target\` in ${path.basename(config._file || "populace.config.mjs")}.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (!adapter || typeof adapter !== "object") {
|
|
176
|
+
throw new ConfigError(`createAdapter() must return an object of methods.`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof adapter.createUser !== "function") {
|
|
179
|
+
throw new ConfigError(
|
|
180
|
+
`Adapter "${adapter.name || config.adapter}" has no createUser().\n` +
|
|
181
|
+
` That is the one method every adapter must implement — without an\n` +
|
|
182
|
+
` identity there is nobody to simulate.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return adapter;
|
|
186
|
+
}
|
package/src/contract.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The adapter contract, as data.
|
|
2
|
+
//
|
|
3
|
+
// Kept here rather than only in prose so that `populace doctor` can tell a
|
|
4
|
+
// customer exactly which parts of their app the simulation will and will not
|
|
5
|
+
// exercise — before they spend a run finding out.
|
|
6
|
+
|
|
7
|
+
export const CONTRACT = [
|
|
8
|
+
{
|
|
9
|
+
method: "createUser",
|
|
10
|
+
required: true,
|
|
11
|
+
group: "identity",
|
|
12
|
+
exercises: "sign-up, sign-in, and whatever your app does on first contact with a new account",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
method: "setProfile",
|
|
16
|
+
group: "identity",
|
|
17
|
+
exercises: "the settings a new user configures before they start",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
method: "refreshSession",
|
|
21
|
+
group: "identity",
|
|
22
|
+
exercises: "token refresh — without it, any run longer than your token lifetime collapses and looks like your API failing",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
method: "reportLocation",
|
|
26
|
+
group: "world",
|
|
27
|
+
exercises: "high-frequency writes — the heaviest sustained load most apps take",
|
|
28
|
+
},
|
|
29
|
+
{ method: "post", group: "social", exercises: "user-generated content creation" },
|
|
30
|
+
{
|
|
31
|
+
method: "recentPostsByOthers",
|
|
32
|
+
group: "social",
|
|
33
|
+
exercises: "feed reads under concurrent writes, and whether your permission rules leak",
|
|
34
|
+
},
|
|
35
|
+
{ method: "like", group: "social", exercises: "high-contention writes on shared rows" },
|
|
36
|
+
{ method: "comment", group: "social", exercises: "nested content and its notifications" },
|
|
37
|
+
{
|
|
38
|
+
method: "openConversation",
|
|
39
|
+
group: "messaging",
|
|
40
|
+
exercises: "conversation creation between two accounts that have never met",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
method: "sendMessage",
|
|
44
|
+
group: "messaging",
|
|
45
|
+
exercises: "delivery, ordering, receipts, and realtime fan-out",
|
|
46
|
+
},
|
|
47
|
+
{ method: "listGroups", group: "groups", exercises: "shared-resource reads" },
|
|
48
|
+
{ method: "joinGroup", group: "groups", exercises: "membership writes and counter accuracy" },
|
|
49
|
+
{
|
|
50
|
+
method: "deleteUser",
|
|
51
|
+
required: true,
|
|
52
|
+
group: "cleanup",
|
|
53
|
+
exercises: "account deletion and cascade — the path almost nobody tests",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export const CONTRACT_METHODS = CONTRACT.map((c) => c.method);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `signIn` is a CLEANUP CAPABILITY, deliberately not a fourteenth contract
|
|
61
|
+
* method.
|
|
62
|
+
*
|
|
63
|
+
* It takes no part in a run. It exists only so `clean` can ask "does this
|
|
64
|
+
* identity exist?" without creating it. Without it, clean reaches an account
|
|
65
|
+
* through createUser — which signs UP when the identity is absent — so
|
|
66
|
+
* cleaning an already-clean environment writes a row to the customer's auth
|
|
67
|
+
* table for every agent purely to prove the table is empty, and the per-account
|
|
68
|
+
* result cannot distinguish "found and removed" from "was never there".
|
|
69
|
+
*
|
|
70
|
+
* Kept out of CONTRACT on purpose: coverage is a statement about how much of
|
|
71
|
+
* the customer's app a RUN exercises, and folding a cleanup-only capability
|
|
72
|
+
* into that denominator would drop every existing adapter to 13/14 and make the
|
|
73
|
+
* published "13-method contract" wrong without anything having got worse.
|
|
74
|
+
* It is reported separately instead.
|
|
75
|
+
*
|
|
76
|
+
* Shape:
|
|
77
|
+
* async signIn({ name, phone, persona, index })
|
|
78
|
+
* -> user when the account exists
|
|
79
|
+
* -> null when it definitively does not
|
|
80
|
+
* throws on transport or unexpected failure (never swallow — a failure
|
|
81
|
+
* to look is not evidence of absence)
|
|
82
|
+
*/
|
|
83
|
+
export const CLEANUP_CAPABILITY = {
|
|
84
|
+
method: "signIn",
|
|
85
|
+
exercises:
|
|
86
|
+
"checking whether a simulated identity exists without creating it, so cleanup never writes to your auth table",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** True when the adapter can be asked about an account without creating one. */
|
|
90
|
+
export function canSignInOnly(adapter) {
|
|
91
|
+
const fn = adapter?.[CLEANUP_CAPABILITY.method];
|
|
92
|
+
return typeof fn === "function" && !isStub(fn);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A method that exists but does nothing is NOT coverage.
|
|
97
|
+
*
|
|
98
|
+
* Without this, a freshly scaffolded adapter reports 12/12 and produces a
|
|
99
|
+
* clean run while testing absolutely nothing — the worst possible outcome for
|
|
100
|
+
* a tool whose entire value is telling you the truth about your app.
|
|
101
|
+
*/
|
|
102
|
+
export function isStub(fn) {
|
|
103
|
+
if (typeof fn !== "function") return true;
|
|
104
|
+
const src = Function.prototype.toString.call(fn);
|
|
105
|
+
const open = src.indexOf("{");
|
|
106
|
+
const close = src.lastIndexOf("}");
|
|
107
|
+
if (open === -1 || close <= open) return false; // concise arrow — real code
|
|
108
|
+
const body = src
|
|
109
|
+
.slice(open + 1, close)
|
|
110
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
111
|
+
.replace(/\/\/.*$/gm, "")
|
|
112
|
+
.trim();
|
|
113
|
+
if (!body) return true;
|
|
114
|
+
return /^throw\s+new\s+\w*Error\s*\(\s*(["'`]).*not implemented.*\1\s*\)\s*;?$/i.test(body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function coverageOf(adapter) {
|
|
118
|
+
const implemented = [];
|
|
119
|
+
const missing = [];
|
|
120
|
+
for (const entry of CONTRACT) {
|
|
121
|
+
const fn = adapter?.[entry.method];
|
|
122
|
+
(typeof fn === "function" && !isStub(fn) ? implemented : missing).push(entry);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
implemented,
|
|
126
|
+
missing,
|
|
127
|
+
ratio: implemented.length / CONTRACT.length,
|
|
128
|
+
label: `${implemented.length}/${CONTRACT.length}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/diagnose.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// What `populace doctor` decides, separated from how it prints.
|
|
2
|
+
//
|
|
3
|
+
// The decision used to live inside the command, interleaved with console.log
|
|
4
|
+
// and process.exitCode, which meant the one thing that stops a customer running
|
|
5
|
+
// a useless simulation — "are you actually ready?" — could not be tested
|
|
6
|
+
// without spawning a process and matching strings. This is the judgement on its
|
|
7
|
+
// own, as data.
|
|
8
|
+
|
|
9
|
+
import { canSignInOnly, coverageOf } from "./contract.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} o
|
|
13
|
+
* @param {object} o.config loaded config
|
|
14
|
+
* @param {object} o.adapter the raw adapter
|
|
15
|
+
* @param {boolean|null} o.reachable true / false / null when not checked
|
|
16
|
+
* @returns {{coverage, cleanup, guarded, blockers: string[], ready: boolean}}
|
|
17
|
+
*/
|
|
18
|
+
export function diagnose({ config, adapter, reachable = null }) {
|
|
19
|
+
const coverage = coverageOf(adapter);
|
|
20
|
+
const blockers = [];
|
|
21
|
+
|
|
22
|
+
// Order matters only for the message; both are reported when both apply.
|
|
23
|
+
if (reachable === false) blockers.push("the target is unreachable");
|
|
24
|
+
|
|
25
|
+
const missingRequired = coverage.missing.filter((c) => c.required);
|
|
26
|
+
if (missingRequired.length) {
|
|
27
|
+
blockers.push(
|
|
28
|
+
`${missingRequired.map((c) => c.method).join(" and ")} ` +
|
|
29
|
+
`${missingRequired.length > 1 ? "are" : "is"} required`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
coverage,
|
|
35
|
+
cleanup: canSignInOnly(adapter) ? "read-only" : "create-then-delete",
|
|
36
|
+
guarded: (config?.neverRunAgainst || []).length,
|
|
37
|
+
blockers,
|
|
38
|
+
ready: blockers.length === 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// One simulated person.
|
|
2
|
+
//
|
|
3
|
+
// This file knows how to BE someone — where they move, how often they post,
|
|
4
|
+
// when they take a break, who they talk to. It knows nothing about any
|
|
5
|
+
// particular app: every action goes through an adapter (adapters/contract.md).
|
|
6
|
+
//
|
|
7
|
+
// That separation is the product. Pointing the simulation at a different app
|
|
8
|
+
// means writing one adapter, not editing this file. If app-specific logic ever
|
|
9
|
+
// creeps in here, Populace has quietly collapsed back into a test script.
|
|
10
|
+
|
|
11
|
+
import { advance, buildRoute, haversineKm } from "./geo.mjs";
|
|
12
|
+
import { chatterFor, replyLine } from "./personas.mjs";
|
|
13
|
+
|
|
14
|
+
const chance = (p) => Math.random() < p;
|
|
15
|
+
const pickOne = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
16
|
+
|
|
17
|
+
export class Agent {
|
|
18
|
+
constructor(persona, adapter, index, options = {}) {
|
|
19
|
+
this.persona = persona;
|
|
20
|
+
this.adapter = adapter;
|
|
21
|
+
this.index = index;
|
|
22
|
+
|
|
23
|
+
// Deterministic identity per agent, so re-runs REUSE accounts rather than
|
|
24
|
+
// leaving a trail of abandoned ones across a customer's test environment.
|
|
25
|
+
const prefix = options.phonePrefix ?? "0900";
|
|
26
|
+
this.phone = `${prefix}${String(1000000 + index).slice(-7)}`;
|
|
27
|
+
|
|
28
|
+
this.route = buildRoute(persona.city, index + 1);
|
|
29
|
+
this.legIndex = 0;
|
|
30
|
+
this.progressKm = 0;
|
|
31
|
+
this.position = this.route[0];
|
|
32
|
+
this.distanceKm = 0;
|
|
33
|
+
this.earnings = 0;
|
|
34
|
+
this.onBreak = false;
|
|
35
|
+
this.log = [];
|
|
36
|
+
this.stats = { posts: 0, likes: 0, comments: 0, messages: 0, groups: 0, reauths: 0, errors: 0 };
|
|
37
|
+
// Failures that did not come from the adapter — i.e. bugs in this engine.
|
|
38
|
+
this.engineErrors = [];
|
|
39
|
+
|
|
40
|
+
// Access tokens expire. A run longer than the token lifetime would see
|
|
41
|
+
// every agent start failing at once — and a customer would reasonably read
|
|
42
|
+
// that as THEIR API collapsing under load. Lying to someone about their own
|
|
43
|
+
// system is the worst failure this product could have, so sessions are
|
|
44
|
+
// refreshed on a cadence well inside any normal expiry.
|
|
45
|
+
this.refreshEveryMs = options.refreshEveryMs ?? 30 * 60 * 1000;
|
|
46
|
+
this.lastRefreshAt = Date.now();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
note(what) {
|
|
50
|
+
this.log.push(what);
|
|
51
|
+
if (this.log.length > 6) this.log.shift();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Only call adapter methods the adapter actually implements. */
|
|
55
|
+
can(method) {
|
|
56
|
+
return typeof this.adapter[method] === "function";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ask whether this person's account exists, WITHOUT creating it.
|
|
61
|
+
*
|
|
62
|
+
* user — it exists, and we are now signed in as them
|
|
63
|
+
* null — it definitively does not exist
|
|
64
|
+
* undefined — the adapter cannot tell us (no signIn capability)
|
|
65
|
+
*
|
|
66
|
+
* The three-way answer matters: cleanup must never turn "I could not look"
|
|
67
|
+
* into "there was nothing there".
|
|
68
|
+
*/
|
|
69
|
+
async findAccount() {
|
|
70
|
+
if (!this.can("signIn")) return undefined;
|
|
71
|
+
const user = await this.adapter.signIn({
|
|
72
|
+
name: this.persona.name,
|
|
73
|
+
phone: this.phone,
|
|
74
|
+
persona: this.persona,
|
|
75
|
+
index: this.index,
|
|
76
|
+
});
|
|
77
|
+
this.user = user || null;
|
|
78
|
+
return user || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async ensureAccount() {
|
|
82
|
+
this.user = await this.adapter.createUser({
|
|
83
|
+
name: this.persona.name,
|
|
84
|
+
phone: this.phone,
|
|
85
|
+
persona: this.persona,
|
|
86
|
+
index: this.index,
|
|
87
|
+
});
|
|
88
|
+
if (this.can("setProfile")) await this.adapter.setProfile(this.user, this.persona);
|
|
89
|
+
this.lastRefreshAt = Date.now();
|
|
90
|
+
this.note("signed in");
|
|
91
|
+
return this.user.id;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Keep this person's session alive.
|
|
96
|
+
*
|
|
97
|
+
* Two layers on purpose. A refresh is cheap and usual; re-authenticating from
|
|
98
|
+
* scratch is the fallback for when the refresh token itself has gone. An
|
|
99
|
+
* agent that silently 401s for the rest of a run still LOOKS busy in the
|
|
100
|
+
* table while testing nothing at all, which is the failure mode this exists
|
|
101
|
+
* to prevent.
|
|
102
|
+
*/
|
|
103
|
+
async ensureFreshSession() {
|
|
104
|
+
if (!this.can("refreshSession") || !this.user) return;
|
|
105
|
+
if (Date.now() - this.lastRefreshAt < this.refreshEveryMs) return;
|
|
106
|
+
|
|
107
|
+
this.lastRefreshAt = Date.now();
|
|
108
|
+
try {
|
|
109
|
+
await this.adapter.refreshSession(this.user);
|
|
110
|
+
this.note("refreshed session");
|
|
111
|
+
} catch {
|
|
112
|
+
await this.ensureAccount();
|
|
113
|
+
this.stats.reauths += 1;
|
|
114
|
+
this.note("re-authenticated");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One step of a life: move a little, then maybe do something social. */
|
|
119
|
+
async tick(secondsPerTick, world) {
|
|
120
|
+
try {
|
|
121
|
+
// Before anything else — including while on a break, since a break can
|
|
122
|
+
// easily outlast a token.
|
|
123
|
+
await this.ensureFreshSession();
|
|
124
|
+
|
|
125
|
+
if (this.onBreak) {
|
|
126
|
+
if (chance(0.35)) {
|
|
127
|
+
this.onBreak = false;
|
|
128
|
+
this.note("back on the road");
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (chance(this.persona.breakiness)) {
|
|
133
|
+
this.onBreak = true;
|
|
134
|
+
this.note("taking a break");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- move ---
|
|
139
|
+
const km = (this.persona.speedKmh / 3600) * secondsPerTick;
|
|
140
|
+
const next = advance(this.route, this.legIndex, this.progressKm, km);
|
|
141
|
+
this.distanceKm += haversineKm(this.position, next.position);
|
|
142
|
+
this.earnings = this.distanceKm * this.persona.rate;
|
|
143
|
+
this.position = next.position;
|
|
144
|
+
this.legIndex = next.legIndex;
|
|
145
|
+
this.progressKm = next.progressKm;
|
|
146
|
+
|
|
147
|
+
if (this.can("reportLocation")) {
|
|
148
|
+
await this.adapter.reportLocation(this.user, {
|
|
149
|
+
lat: this.position.lat,
|
|
150
|
+
lng: this.position.lng,
|
|
151
|
+
distanceKm: this.distanceKm,
|
|
152
|
+
earnings: this.earnings,
|
|
153
|
+
platform: this.persona.platform,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- social ---
|
|
158
|
+
if (chance(this.persona.postiness)) await this.post();
|
|
159
|
+
if (chance(this.persona.likeliness)) await this.reactToSomeone();
|
|
160
|
+
if (chance(this.persona.chattiness)) await this.chat(world);
|
|
161
|
+
if (chance(0.02)) await this.joinAGroup();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// An adapter failure is expected material: the instrumentation wrapper has
|
|
164
|
+
// already recorded it, and one person's app breaking should not end
|
|
165
|
+
// everyone else's shift.
|
|
166
|
+
//
|
|
167
|
+
// An UNTAGGED failure never reached the adapter, so it is a bug in this
|
|
168
|
+
// engine. Swallowing it silently would let Populace break and still print
|
|
169
|
+
// a clean report — the same defect as the failure-rate tolerance band that
|
|
170
|
+
// was removed from the verdict, and worse here, because the tool would be
|
|
171
|
+
// vouching for an app it never actually exercised.
|
|
172
|
+
this.stats.errors += 1;
|
|
173
|
+
if (!error?.fromAdapter) {
|
|
174
|
+
this.engineErrors.push(String(error?.stack || error?.message || error).slice(0, 300));
|
|
175
|
+
}
|
|
176
|
+
this.note(`error: ${String(error.message || error).slice(0, 60)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async post() {
|
|
181
|
+
if (!this.can("post")) return;
|
|
182
|
+
const body = chatterFor(this.persona.cityKey, this.stats.posts + this.index);
|
|
183
|
+
await this.adapter.post(this.user, body);
|
|
184
|
+
this.stats.posts += 1;
|
|
185
|
+
this.note(`posted: ${body.slice(0, 34)}…`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Take the id out of whatever the adapter returned.
|
|
190
|
+
*
|
|
191
|
+
* The contract says these methods return "posts" and "groups" without saying
|
|
192
|
+
* they must be objects, and plenty of APIs answer a list endpoint with bare
|
|
193
|
+
* ids. `smoke` already accepted both — it used `first.id ?? first` — but the
|
|
194
|
+
* engine did not, so an adapter returning strings passed the smoke test and
|
|
195
|
+
* then failed on the first tick of a real run. That is exactly the "subtly
|
|
196
|
+
* wrong adapter" case smoke exists to rule out, so the two now agree.
|
|
197
|
+
*/
|
|
198
|
+
static idOf(item) {
|
|
199
|
+
if (item === null || item === undefined) return undefined;
|
|
200
|
+
if (typeof item === "object") return item.id ?? item.uuid ?? item._id ?? item.key;
|
|
201
|
+
return item; // already a bare id
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async reactToSomeone() {
|
|
205
|
+
if (!this.can("recentPostsByOthers") || !this.can("like")) return;
|
|
206
|
+
const posts = await this.adapter.recentPostsByOthers(this.user, 10);
|
|
207
|
+
if (!posts?.length) return;
|
|
208
|
+
const target = pickOne(posts);
|
|
209
|
+
const targetId = Agent.idOf(target);
|
|
210
|
+
if (targetId === undefined) return;
|
|
211
|
+
await this.adapter.like(this.user, targetId);
|
|
212
|
+
this.stats.likes += 1;
|
|
213
|
+
|
|
214
|
+
if (chance(0.4) && this.can("comment")) {
|
|
215
|
+
const body = replyLine(this.stats.comments + this.index);
|
|
216
|
+
await this.adapter.comment(this.user, targetId, body);
|
|
217
|
+
this.stats.comments += 1;
|
|
218
|
+
this.note(`commented "${body.slice(0, 22)}…"`);
|
|
219
|
+
} else {
|
|
220
|
+
this.note("liked a post");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async chat(world) {
|
|
225
|
+
if (!this.can("openConversation") || !this.can("sendMessage")) return;
|
|
226
|
+
const others = world.agents.filter((a) => a.user && a.user.id !== this.user.id);
|
|
227
|
+
if (!others.length) return;
|
|
228
|
+
const other = pickOne(others);
|
|
229
|
+
|
|
230
|
+
const conversationId = await this.adapter.openConversation(this.user, other.user.id);
|
|
231
|
+
if (!conversationId) return;
|
|
232
|
+
await this.adapter.sendMessage(this.user, conversationId, replyLine(this.stats.messages + this.index));
|
|
233
|
+
this.stats.messages += 1;
|
|
234
|
+
this.note(`messaged ${other.persona.name.split(" ")[0]}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async joinAGroup() {
|
|
238
|
+
if (!this.can("listGroups") || !this.can("joinGroup")) return;
|
|
239
|
+
const groups = await this.adapter.listGroups(this.user);
|
|
240
|
+
if (!groups?.length) return;
|
|
241
|
+
const group = pickOne(groups);
|
|
242
|
+
const groupId = Agent.idOf(group);
|
|
243
|
+
if (groupId === undefined) return;
|
|
244
|
+
await this.adapter.joinGroup(this.user, groupId);
|
|
245
|
+
this.stats.groups += 1;
|
|
246
|
+
this.note(`joined ${groupId}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Delete this person's account, and say whether that actually happened.
|
|
251
|
+
*
|
|
252
|
+
* It used to return undefined on the two paths where it does nothing — no
|
|
253
|
+
* deleteUser on the adapter, or no account to delete — which teardown counted
|
|
254
|
+
* as a successful removal. A run could therefore report "Cleanup complete —
|
|
255
|
+
* 6 accounts removed" having deleted none of them. The caller has to be able
|
|
256
|
+
* to tell "done" from "there was nothing to do".
|
|
257
|
+
*/
|
|
258
|
+
async selfDestruct() {
|
|
259
|
+
if (!this.can("deleteUser")) return { deleted: false, why: "adapter has no deleteUser" };
|
|
260
|
+
if (!this.user) return { deleted: false, why: "no account to delete" };
|
|
261
|
+
await this.adapter.deleteUser(this.user);
|
|
262
|
+
return { deleted: true };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Movement.
|
|
2
|
+
//
|
|
3
|
+
// People don't teleport. Each one gets a looping set of waypoints around their
|
|
4
|
+
// city and walks between them at their own speed, so the location updates your
|
|
5
|
+
// app receives look like a real shift rather than a random-number generator.
|
|
6
|
+
|
|
7
|
+
const R = 6371; // km
|
|
8
|
+
|
|
9
|
+
export function haversineKm(a, b) {
|
|
10
|
+
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
|
11
|
+
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
|
12
|
+
const la1 = (a.lat * Math.PI) / 180;
|
|
13
|
+
const la2 = (b.lat * Math.PI) / 180;
|
|
14
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(la1) * Math.cos(la2);
|
|
15
|
+
return 2 * R * Math.asin(Math.sqrt(h));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A closed loop of waypoints around a centre, unique per seed. */
|
|
19
|
+
export function buildRoute(center, seed, legs = 6, radiusKm = 4) {
|
|
20
|
+
const pts = [];
|
|
21
|
+
const jitter = (n) => (Math.sin(seed * 12.9898 + n * 78.233) * 43758.5453) % 1;
|
|
22
|
+
for (let i = 0; i < legs; i++) {
|
|
23
|
+
const angle = (i / legs) * Math.PI * 2 + jitter(i);
|
|
24
|
+
const r = radiusKm * (0.55 + Math.abs(jitter(i + 7)) * 0.75);
|
|
25
|
+
pts.push({
|
|
26
|
+
lat: center.lat + (r / 111) * Math.cos(angle),
|
|
27
|
+
lng: center.lng + (r / (111 * Math.cos((center.lat * Math.PI) / 180))) * Math.sin(angle),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return pts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Advance along the route by `km`, returning the new position and leg index.
|
|
35
|
+
* Wraps around the loop so a shift can run indefinitely.
|
|
36
|
+
*/
|
|
37
|
+
export function advance(route, legIndex, progressKm, km) {
|
|
38
|
+
let leg = legIndex;
|
|
39
|
+
let progress = progressKm + km;
|
|
40
|
+
for (let guard = 0; guard < route.length * 2; guard++) {
|
|
41
|
+
const from = route[leg % route.length];
|
|
42
|
+
const to = route[(leg + 1) % route.length];
|
|
43
|
+
const legKm = haversineKm(from, to);
|
|
44
|
+
if (progress < legKm || legKm === 0) {
|
|
45
|
+
const t = legKm === 0 ? 0 : progress / legKm;
|
|
46
|
+
return {
|
|
47
|
+
position: {
|
|
48
|
+
lat: from.lat + (to.lat - from.lat) * t,
|
|
49
|
+
lng: from.lng + (to.lng - from.lng) * t,
|
|
50
|
+
},
|
|
51
|
+
legIndex: leg,
|
|
52
|
+
progressKm: progress,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
progress -= legKm;
|
|
56
|
+
leg += 1;
|
|
57
|
+
}
|
|
58
|
+
return { position: route[0], legIndex: 0, progressKm: 0 };
|
|
59
|
+
}
|