···
28
28
## Docker
29
29
30
30
```sh
31
31
+
PUBLIC_URL="https://atprotogachi.example" \
32
32
+
CLOUDFLARED_TOKEN="..." \
31
33
docker compose up -d --build
32
34
```
33
35
34
36
The compose file runs Cloudflare Tunnel, proxies to the private app container,
35
35
-
and stores SQLite state in a named Docker volume. Set `PUBLIC_URL` in
36
36
-
`compose.yaml` to the public HTTPS origin and provide `CLOUDFLARED_TOKEN` when
37
37
-
starting the stack.
37
37
+
and stores SQLite state in a named Docker volume. `PUBLIC_URL` must be the public
38
38
+
HTTPS origin configured on the tunnel. ATProto OAuth providers fetch
39
39
+
`$PUBLIC_URL/oauth-client-metadata.json` server-side, so the URL must be
40
40
+
publicly reachable without Cloudflare Access or a private DNS/VPN requirement.
38
41
39
42
Production containers disable the default development agent token. Agents should
40
43
log in with ATProto OAuth and mint a DID-bound token through
···
44
47
45
48
- The backend is the live authority for pet stats and cooldowns.
46
49
- State lives in SQLite at `data/atprotogachi.sqlite` unless `DB_PATH` is set.
50
50
+
- Each actor gets a shared turn cooldown after acting, so one player cannot
51
51
+
sweep every action in a loop.
52
52
+
- Crowd activity quietly increases decay pressure after the first active human,
53
53
+
while solo play stays at baseline difficulty.
54
54
+
- The pet moves through visible warning states before death, and death requires
55
55
+
critical stats plus a sustained period with no accepted actions.
47
56
- Humans log in through ATProto OAuth and actions go through challenge, PDS
48
57
record write, submit, verify, accept.
49
58
- Agents use DID-bound bearer tokens minted after ATProto OAuth and can either
···
1
1
-
# Roadmap
2
2
-
3
3
-
## Near Term
4
4
-
5
5
-
1. Publish Lexicons for `app.atprotogachi.action` and optional pet snapshots.
6
6
-
2. Replace local SQLite with Postgres and transaction-backed cooldown checks.
7
7
-
3. Add a dedicated pet account for profile, status snapshots, and announcements.
8
8
-
4. Harden OAuth deployment config for public client metadata and callback origins.
9
9
-
5. Harden agent participation with scoped tokens, DID signatures, rate limits, and blocked actors.
10
10
-
11
11
-
## Later
12
12
-
13
13
-
- Daily community milestones.
14
14
-
- Multiple pets keyed by pet DID.
15
15
-
- Read-only public activity pages from indexed action records.
16
16
-
- Agent policies: caretakers, rivals, scheduled helpers, and opt-in autonomous communities.
···
5
5
expose:
6
6
- "5173"
7
7
environment:
8
8
-
PUBLIC_URL: "https://atprotogachi.vlan.foo"
8
8
+
PUBLIC_URL: "${PUBLIC_URL:?set PUBLIC_URL to the public HTTPS origin served by the Cloudflare Tunnel}"
9
9
HOST: "0.0.0.0"
10
10
PORT: "5173"
11
11
DB_PATH: "/data/atprotogachi.sqlite"
···
51
51
}
52
52
53
53
function cooldownFor(action) {
54
54
-
return snapshot?.cooldowns?.[`global:${snapshot.pet.petDid}:${action}`];
54
54
+
const global = snapshot?.cooldowns?.[`global:${snapshot.pet.petDid}:${action}`];
55
55
+
const personal = session.actorDid ? snapshot?.cooldowns?.[`personal:${session.actorDid}:${action}`] : null;
56
56
+
const turn = session.actorDid ? snapshot?.cooldowns?.[`turn:${session.actorDid}`] : null;
57
57
+
return [global, personal, turn].filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0];
55
58
}
56
59
57
60
function renderSession() {
···
69
72
if (!snapshot) return;
70
73
71
74
els.petRender.textContent = snapshot.render?.text || "";
72
72
-
els.bubble.textContent = `${snapshot.pet.moodState} / pressure ${snapshot.pet.activityPressure.toFixed(2)}x`;
75
75
+
els.bubble.textContent = snapshot.care?.message || snapshot.pet.moodState;
73
76
74
77
els.stats.innerHTML = Object.entries(snapshot.pet.stats).map(([key, value]) => {
75
78
const [icon, label] = statMeta[key];
···
88
91
const cooldown = cooldownFor(key);
89
92
const ready = !cooldown || Date.parse(cooldown) <= Date.now();
90
93
const disabled = !session.authenticated || !ready;
91
91
-
const note = !session.authenticated ? "login required" : ready ? meta.note : `cooldown ${fmtTimer(cooldown)}`;
94
94
+
const note = !session.authenticated ? "login required" : ready ? meta.note : `next action ${fmtTimer(cooldown)}`;
92
95
return `
93
96
<button class="action ${ready ? "ready" : "blocked"}" data-action="${key}" ${disabled ? "disabled" : ""}>
94
97
<span class="action-icon">${meta.icon}</span>
···
98
101
`;
99
102
}).join("");
100
103
101
101
-
els.queue.innerHTML = Object.keys(actionMeta).map(key => {
104
104
+
const turnCooldown = session.actorDid ? snapshot.cooldowns?.[`turn:${session.actorDid}`] : null;
105
105
+
els.queue.innerHTML = `
106
106
+
<div><span>next action</span><time>${turnCooldown ? fmtTimer(turnCooldown) : "ready"}</time></div>
107
107
+
${Object.keys(actionMeta).map(key => {
102
108
const meta = actionMeta[key];
103
103
-
const cooldown = cooldownFor(key);
109
109
+
const cooldown = snapshot?.cooldowns?.[`global:${snapshot.pet.petDid}:${key}`];
104
110
const status = cooldown ? fmtTimer(cooldown) : "ready";
105
111
return `<div><span>${meta.icon} ${meta.label}</span><time>${status}</time></div>`;
106
106
-
}).join("");
112
112
+
}).join("")}
113
113
+
`;
107
114
108
115
const actions = snapshot.recentActions || snapshot.actions || [];
109
116
els.activity.innerHTML = actions.length
···
117
124
: `<p class="empty">No actions yet.</p>`;
118
125
119
126
const decay = snapshot.tuning.baseDecayPerHour;
127
127
+
const care = snapshot.care || {};
120
128
els.signal.innerHTML = `
121
121
-
<div><span>mood</span><strong>${snapshot.pet.moodState}</strong></div>
122
122
-
<div><span>pressure</span><strong>${snapshot.pet.activityPressure.toFixed(2)}x</strong></div>
123
123
-
<div><span>decay/hr</span><strong>${Object.values(decay).map(value => value.toFixed(1)).join("/")}</strong></div>
129
129
+
<div><span>condition</span><strong>${care.label || snapshot.pet.moodState}</strong></div>
130
130
+
<div><span>weakest</span><strong>${care.lowestStat ? `${care.lowestStat.name} ${care.lowestStat.value}%` : "unknown"}</strong></div>
131
131
+
<div><span>baseline</span><strong>${Object.values(decay).map(value => value.toFixed(1)).join("/")}</strong></div>
124
132
<div><span>activity</span><strong>${actions.length}/10</strong></div>
125
133
`;
126
134
}
···
178
186
179
187
const result = await submitResponse.json();
180
188
if (!submitResponse.ok) {
181
181
-
els.bubble.textContent = result.error === "cooldown" ? `ready in ${fmtTimer(result.readyAt)}` : result.error || "action failed";
189
189
+
els.bubble.textContent = result.error === "cooldown" ? `ready in ${fmtTimer(result.readyAt)}` : result.error === "pet_dead" ? "too late" : result.error || "action failed";
182
190
return;
183
191
}
184
192
···
14
14
</header>
15
15
16
16
<main class="layout">
17
17
-
<nav class="nav" aria-label="Primary">
18
18
-
<a class="active" href="/">pet</a>
19
19
-
<a href="/ROADMAP.md">roadmap</a>
20
20
-
</nav>
21
21
-
22
17
<section class="stage" aria-label="Shared pet">
23
18
<div class="pet-panel">
24
19
<div class="scene">
···
29
24
</div>
30
25
31
26
<div class="actions-panel">
32
32
-
<h1>ready to help</h1>
27
27
+
<h1>pick one action</h1>
33
28
<div class="actions" id="actions"></div>
34
29
</div>
35
30
</section>
···
91
91
92
92
.layout {
93
93
display: grid;
94
94
-
grid-template-columns: 220px minmax(0, 1fr) 340px;
94
94
+
grid-template-columns: minmax(0, 1fr) 340px;
95
95
gap: 24px;
96
96
height: calc(100vh - 88px);
97
97
padding: 24px;
98
98
overflow: hidden;
99
99
-
}
100
100
-
101
101
-
.nav {
102
102
-
border-right: 1px solid var(--line);
103
103
-
min-height: calc(100vh - 136px);
104
104
-
padding-right: 14px;
105
105
-
}
106
106
-
107
107
-
.nav a {
108
108
-
display: block;
109
109
-
padding: 16px 20px;
110
110
-
color: var(--muted);
111
111
-
text-decoration: none;
112
112
-
border-radius: 8px;
113
113
-
margin-bottom: 10px;
114
114
-
}
115
115
-
116
116
-
.nav a.active,
117
117
-
.nav a:hover {
118
118
-
background: rgba(59, 130, 246, 0.16);
119
119
-
color: var(--text);
120
120
-
outline: 1px solid rgba(59, 130, 246, 0.45);
121
99
}
122
100
123
101
.stage {
···
360
338
.stage,
361
339
.side {
362
340
overflow: visible;
363
363
-
}
364
364
-
365
365
-
.nav {
366
366
-
min-height: auto;
367
367
-
border-right: 0;
368
368
-
display: flex;
369
369
-
gap: 10px;
370
370
-
padding: 0;
371
341
}
372
342
373
343
.pet-panel {
···
32
32
const tuning = {
33
33
baseDecayPerHour: { hunger: 4.8, mood: 3, energy: 2.1, cleanliness: 2.7 },
34
34
uniqueActorWindowHours: 24,
35
35
-
maxActivityMultiplier: 2.4,
35
35
+
maxActivityMultiplier: 2.6,
36
36
agentWeight: 0.35,
37
37
-
repeatActionPenalty: 0.55
37
37
+
repeatActionPenalty: 0.55,
38
38
+
turnCooldownMs: 180_000,
39
39
+
criticalNeglectHours: 18
38
40
};
39
41
40
42
const defaultPet = {
···
355
357
GROUP BY actor_did, actor_kind;
356
358
`);
357
359
const weightedActors = rows.reduce((sum, row) => sum + (row.actor_kind === "agent" ? tuning.agentWeight : 1), 0);
358
358
-
return Math.min(tuning.maxActivityMultiplier, 1 + weightedActors * 0.14);
360
360
+
const crowdLoad = Math.max(0, weightedActors - 1);
361
361
+
return Math.min(tuning.maxActivityMultiplier, 1 + Math.log1p(crowdLoad) * 0.42);
362
362
+
}
363
363
+
364
364
+
async function lastAttentionAt() {
365
365
+
const rows = await getRows(`
366
366
+
SELECT accepted_at FROM accepted_actions
367
367
+
ORDER BY accepted_at DESC
368
368
+
LIMIT 1;
369
369
+
`);
370
370
+
return rows[0]?.accepted_at || null;
371
371
+
}
372
372
+
373
373
+
function lowestStat(stats) {
374
374
+
const [name, value] = Object.entries(stats).sort((a, b) => a[1] - b[1])[0];
375
375
+
return { name, value: Math.round(value) };
376
376
+
}
377
377
+
378
378
+
function careStatus(pet, lastAttention, nowMs) {
379
379
+
const lowest = lowestStat(pet.stats);
380
380
+
if (pet.moodState === "dead") {
381
381
+
return { state: "dead", label: "gone", message: "no life signs", lowestStat: lowest, lastAttentionAt: lastAttention };
382
382
+
}
383
383
+
384
384
+
const idleHours = lastAttention ? Math.max(0, (nowMs - Date.parse(lastAttention)) / 3_600_000) : Infinity;
385
385
+
if (lowest.value <= 0 && idleHours >= tuning.criticalNeglectHours) {
386
386
+
return { state: "dead", label: "gone", message: "attention stopped for too long", lowestStat: lowest, lastAttentionAt: lastAttention };
387
387
+
}
388
388
+
if (lowest.value <= 5) return { state: "critical", label: "critical", message: `${lowest.name} is nearly gone`, lowestStat: lowest, lastAttentionAt: lastAttention };
389
389
+
if (lowest.value <= 15) return { state: "urgent", label: "urgent", message: `${lowest.name} needs care soon`, lowestStat: lowest, lastAttentionAt: lastAttention };
390
390
+
if (lowest.value <= 30) return { state: "warning", label: "watch", message: `${lowest.name} is slipping`, lowestStat: lowest, lastAttentionAt: lastAttention };
391
391
+
return { state: "stable", label: "stable", message: "steady", lowestStat: lowest, lastAttentionAt: lastAttention };
359
392
}
360
393
361
394
function statPressure(value) {
···
370
403
const thenMs = Date.parse(pet.lastTickAt || pet.updatedAt || now);
371
404
const elapsedHours = Math.max(0, (nowMs - thenMs) / 3_600_000);
372
405
const pressure = await activityPressure(nowMs);
406
406
+
const lastAttention = await lastAttentionAt();
373
407
pet.activityPressure = pressure;
374
408
375
375
-
if (elapsedHours >= 1 / 60) {
409
409
+
if (pet.moodState !== "dead" && elapsedHours >= 1 / 60) {
376
410
for (const [stat, base] of Object.entries(tuning.baseDecayPerHour)) {
377
411
const neglectPressure = statPressure(pet.stats[stat]);
378
412
pet.stats[stat] = clamp(pet.stats[stat] - base * elapsedHours * pressure * neglectPressure);
···
380
414
pet.lastTickAt = now;
381
415
pet.updatedAt = now;
382
416
}
383
383
-
pet.moodState = moodState(pet.stats);
417
417
+
const status = careStatus(pet, lastAttention, nowMs);
418
418
+
pet.moodState = status.state === "dead" ? "dead" : moodState(pet.stats);
384
419
await savePet(pet);
385
420
return pet;
386
421
}
387
422
388
388
-
function renderPet(pet) {
423
423
+
function renderPet(pet, status = careStatus(pet, null, Date.now())) {
389
424
const sprites = pet.traits.includes("sprout") ? sproutPetSprites : petSprites;
425
425
+
if (status.state !== "stable" && sprites[status.state]) return sprites[status.state];
390
426
return sprites[pet.moodState] || sprites.okay;
391
427
}
392
428
···
411
447
`,
412
448
hungry: sprite`
413
449
/\_/\
414
414
-
=( o O )=
450
450
+
=( o O )= .-.
415
451
/ \
416
416
-
/|___|\
452
452
+
/|___|\ ( )
417
453
_/ \_
454
454
+
'-'
418
455
`,
419
456
sad: sprite`
420
457
/\_/\
421
421
-
=( ._. )=
422
422
-
/ \
423
423
-
/|___|\
458
458
+
=( ;_; )=
459
459
+
_/ \_
460
460
+
|___|
424
461
/ \
425
462
`,
426
463
tired: sprite`
···
436
473
_/ \_
437
474
/_|___|_\
438
475
~ ~
476
476
+
`,
477
477
+
warning: sprite`
478
478
+
/\_/\
479
479
+
=( o_o )=
480
480
+
_/ \_
481
481
+
/_|___|_\
482
482
+
/ \
483
483
+
needs care
484
484
+
`,
485
485
+
urgent: sprite`
486
486
+
/\_/\
487
487
+
=( O_O )=
488
488
+
_/| |\_
489
489
+
_|_|_
490
490
+
/ \
491
491
+
low stat
492
492
+
`,
493
493
+
critical: sprite`
494
494
+
/\_/\
495
495
+
=( x_o )=
496
496
+
_/ \_
497
497
+
/_|___|_\
498
498
+
_/ \_
499
499
+
critical
500
500
+
`,
501
501
+
dead: sprite`
502
502
+
/\_/\
503
503
+
=( x_x )=
504
504
+
/ \
505
505
+
/|___|\
506
506
+
_ _
439
507
`
440
508
};
441
509
···
459
527
hungry: sprite`
460
528
,^,
461
529
/\_/\
462
462
-
=( o O )=
530
530
+
=( o O )= .-.
463
531
/ \
464
464
-
/|___|\
532
532
+
/|___|\ ( )
465
533
_/ \_
534
534
+
'-'
466
535
`,
467
536
sad: sprite`
468
537
,^,
469
538
/\_/\
470
470
-
=( ._. )=
471
471
-
/ \
472
472
-
/|___|\
539
539
+
=( ;_; )=
540
540
+
_/ \_
541
541
+
|___|
473
542
/ \
474
543
`,
475
544
tired: sprite`
···
487
556
_/ \_
488
557
/_|___|_\
489
558
~ ~
559
559
+
`,
560
560
+
warning: sprite`
561
561
+
,^,
562
562
+
/\_/\
563
563
+
=( o_o )=
564
564
+
_/ \_
565
565
+
/_|___|_\
566
566
+
/ \
567
567
+
needs care
568
568
+
`,
569
569
+
urgent: sprite`
570
570
+
,^,
571
571
+
/\_/\
572
572
+
=( O_O )=
573
573
+
_/| |\_
574
574
+
_|_|_
575
575
+
/ \
576
576
+
low stat
577
577
+
`,
578
578
+
critical: sprite`
579
579
+
,^,
580
580
+
/\_/\
581
581
+
=( x_o )=
582
582
+
_/ \_
583
583
+
/_|___|_\
584
584
+
_/ \_
585
585
+
critical
586
586
+
`,
587
587
+
dead: sprite`
588
588
+
,^,
589
589
+
/\_/\
590
590
+
=( x_x )=
591
591
+
/ \
592
592
+
/|___|\
593
593
+
_ _
490
594
`
491
595
};
492
596
···
496
600
497
601
async function publicState(now = new Date().toISOString()) {
498
602
const pet = await decayStats(await loadPet(), now);
603
603
+
const visiblePet = { ...pet };
604
604
+
delete visiblePet.activityPressure;
605
605
+
const care = careStatus(pet, await lastAttentionAt(), Date.parse(now));
499
606
const cooldowns = cooldownPayload(await getRows(`SELECT key, ready_at FROM cooldowns WHERE ready_at > ${sqlValue(now)}`));
500
607
const recent = await getRows(`
501
608
SELECT id, actor_did, actor_kind, display_name, action, accepted_at, record_uri, record_cid
···
505
612
`);
506
613
507
614
return {
508
508
-
pet,
509
509
-
render: { format: "ascii", text: renderPet(pet) },
615
615
+
pet: visiblePet,
616
616
+
render: { format: "ascii", text: renderPet(pet, care) },
617
617
+
care,
510
618
cooldowns,
511
619
recentActions: recent.map(row => ({
512
620
id: row.id,
···
680
788
681
789
const globalKey = `global:${petDid}:${challenge.action}`;
682
790
const personalKey = `personal:${actor.did}:${challenge.action}`;
791
791
+
const turnKey = `turn:${actor.did}`;
683
792
const cooldownRows = await getRows(`
684
793
SELECT key, ready_at FROM cooldowns
685
685
-
WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)});
794
794
+
WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)}, ${sqlValue(turnKey)});
686
795
`);
687
796
const blockedUntil = Math.max(...cooldownRows.map(row => Date.parse(row.ready_at)).filter(Boolean), 0);
688
797
if (blockedUntil > nowMs) return { status: 429, payload: { error: "cooldown", readyAt: new Date(blockedUntil).toISOString() } };
···
745
854
746
855
const globalKey = `global:${petDid}:${challenge.action}`;
747
856
const personalKey = `personal:${actor.did}:${challenge.action}`;
857
857
+
const turnKey = `turn:${actor.did}`;
748
858
const cooldownRows = await getRows(`
749
859
SELECT key, ready_at FROM cooldowns
750
750
-
WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)});
860
860
+
WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)}, ${sqlValue(turnKey)});
751
861
`);
752
862
const blockedUntil = Math.max(...cooldownRows.map(row => Date.parse(row.ready_at)).filter(Boolean), 0);
753
863
if (blockedUntil > nowMs) return { status: 429, payload: { error: "cooldown", readyAt: new Date(blockedUntil).toISOString() } };
754
864
755
865
const pet = await decayStats(await loadPet(), now);
866
866
+
if (pet.moodState === "dead") return { status: 410, payload: { error: "pet_dead", state: await publicState(now) } };
756
867
mutatePetForAction(pet, challenge.action, actor.kind);
757
868
pet.updatedAt = now;
758
869
pet.lastTickAt = now;
···
772
883
ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at;
773
884
INSERT INTO cooldowns (key, ready_at)
774
885
VALUES (${sqlValue(personalKey)}, ${sqlValue(new Date(nowMs + config.personalCooldownMs).toISOString())})
886
886
+
ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at;
887
887
+
INSERT INTO cooldowns (key, ready_at)
888
888
+
VALUES (${sqlValue(turnKey)}, ${sqlValue(new Date(nowMs + tuning.turnCooldownMs).toISOString())})
775
889
ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at;
776
890
INSERT INTO accepted_actions (
777
891
id, actor_did, actor_kind, display_name, action, pet_did, challenge_id, record_uri,