atproto tamogachi (https://bsky.app/profile/cee.wtf/post/3mmf477wius2p)
0

Configure Feed

Select the types of activity you want to include in your feed.

lets go

+167 -87
+12 -3
README.md
··· 28 28 ## Docker 29 29 30 30 ```sh 31 + PUBLIC_URL="https://atprotogachi.example" \ 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 - and stores SQLite state in a named Docker volume. Set `PUBLIC_URL` in 36 - `compose.yaml` to the public HTTPS origin and provide `CLOUDFLARED_TOKEN` when 37 - starting the stack. 37 + and stores SQLite state in a named Docker volume. `PUBLIC_URL` must be the public 38 + HTTPS origin configured on the tunnel. ATProto OAuth providers fetch 39 + `$PUBLIC_URL/oauth-client-metadata.json` server-side, so the URL must be 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 + - Each actor gets a shared turn cooldown after acting, so one player cannot 51 + sweep every action in a loop. 52 + - Crowd activity quietly increases decay pressure after the first active human, 53 + while solo play stays at baseline difficulty. 54 + - The pet moves through visible warning states before death, and death requires 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
-16
ROADMAP.md
··· 1 - # Roadmap 2 - 3 - ## Near Term 4 - 5 - 1. Publish Lexicons for `app.atprotogachi.action` and optional pet snapshots. 6 - 2. Replace local SQLite with Postgres and transaction-backed cooldown checks. 7 - 3. Add a dedicated pet account for profile, status snapshots, and announcements. 8 - 4. Harden OAuth deployment config for public client metadata and callback origins. 9 - 5. Harden agent participation with scoped tokens, DID signatures, rate limits, and blocked actors. 10 - 11 - ## Later 12 - 13 - - Daily community milestones. 14 - - Multiple pets keyed by pet DID. 15 - - Read-only public activity pages from indexed action records. 16 - - Agent policies: caretakers, rivals, scheduled helpers, and opt-in autonomous communities.
+1 -1
compose.yaml
··· 5 5 expose: 6 6 - "5173" 7 7 environment: 8 - PUBLIC_URL: "https://atprotogachi.vlan.foo" 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"
+18 -10
public/app.js
··· 51 51 } 52 52 53 53 function cooldownFor(action) { 54 - return snapshot?.cooldowns?.[`global:${snapshot.pet.petDid}:${action}`]; 54 + const global = snapshot?.cooldowns?.[`global:${snapshot.pet.petDid}:${action}`]; 55 + const personal = session.actorDid ? snapshot?.cooldowns?.[`personal:${session.actorDid}:${action}`] : null; 56 + const turn = session.actorDid ? snapshot?.cooldowns?.[`turn:${session.actorDid}`] : null; 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 - els.bubble.textContent = `${snapshot.pet.moodState} / pressure ${snapshot.pet.activityPressure.toFixed(2)}x`; 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 - const note = !session.authenticated ? "login required" : ready ? meta.note : `cooldown ${fmtTimer(cooldown)}`; 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 - els.queue.innerHTML = Object.keys(actionMeta).map(key => { 104 + const turnCooldown = session.actorDid ? snapshot.cooldowns?.[`turn:${session.actorDid}`] : null; 105 + els.queue.innerHTML = ` 106 + <div><span>next action</span><time>${turnCooldown ? fmtTimer(turnCooldown) : "ready"}</time></div> 107 + ${Object.keys(actionMeta).map(key => { 102 108 const meta = actionMeta[key]; 103 - const cooldown = cooldownFor(key); 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 - }).join(""); 112 + }).join("")} 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 + const care = snapshot.care || {}; 120 128 els.signal.innerHTML = ` 121 - <div><span>mood</span><strong>${snapshot.pet.moodState}</strong></div> 122 - <div><span>pressure</span><strong>${snapshot.pet.activityPressure.toFixed(2)}x</strong></div> 123 - <div><span>decay/hr</span><strong>${Object.values(decay).map(value => value.toFixed(1)).join("/")}</strong></div> 129 + <div><span>condition</span><strong>${care.label || snapshot.pet.moodState}</strong></div> 130 + <div><span>weakest</span><strong>${care.lowestStat ? `${care.lowestStat.name} ${care.lowestStat.value}%` : "unknown"}</strong></div> 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 - els.bubble.textContent = result.error === "cooldown" ? `ready in ${fmtTimer(result.readyAt)}` : result.error || "action failed"; 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
+1 -6
public/index.html
··· 14 14 </header> 15 15 16 16 <main class="layout"> 17 - <nav class="nav" aria-label="Primary"> 18 - <a class="active" href="/">pet</a> 19 - <a href="/ROADMAP.md">roadmap</a> 20 - </nav> 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 - <h1>ready to help</h1> 27 + <h1>pick one action</h1> 33 28 <div class="actions" id="actions"></div> 34 29 </div> 35 30 </section>
+1 -31
public/styles.css
··· 91 91 92 92 .layout { 93 93 display: grid; 94 - grid-template-columns: 220px minmax(0, 1fr) 340px; 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 - } 100 - 101 - .nav { 102 - border-right: 1px solid var(--line); 103 - min-height: calc(100vh - 136px); 104 - padding-right: 14px; 105 - } 106 - 107 - .nav a { 108 - display: block; 109 - padding: 16px 20px; 110 - color: var(--muted); 111 - text-decoration: none; 112 - border-radius: 8px; 113 - margin-bottom: 10px; 114 - } 115 - 116 - .nav a.active, 117 - .nav a:hover { 118 - background: rgba(59, 130, 246, 0.16); 119 - color: var(--text); 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 - } 364 - 365 - .nav { 366 - min-height: auto; 367 - border-right: 0; 368 - display: flex; 369 - gap: 10px; 370 - padding: 0; 371 341 } 372 342 373 343 .pet-panel {
+134 -20
server.js
··· 32 32 const tuning = { 33 33 baseDecayPerHour: { hunger: 4.8, mood: 3, energy: 2.1, cleanliness: 2.7 }, 34 34 uniqueActorWindowHours: 24, 35 - maxActivityMultiplier: 2.4, 35 + maxActivityMultiplier: 2.6, 36 36 agentWeight: 0.35, 37 - repeatActionPenalty: 0.55 37 + repeatActionPenalty: 0.55, 38 + turnCooldownMs: 180_000, 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 - return Math.min(tuning.maxActivityMultiplier, 1 + weightedActors * 0.14); 360 + const crowdLoad = Math.max(0, weightedActors - 1); 361 + return Math.min(tuning.maxActivityMultiplier, 1 + Math.log1p(crowdLoad) * 0.42); 362 + } 363 + 364 + async function lastAttentionAt() { 365 + const rows = await getRows(` 366 + SELECT accepted_at FROM accepted_actions 367 + ORDER BY accepted_at DESC 368 + LIMIT 1; 369 + `); 370 + return rows[0]?.accepted_at || null; 371 + } 372 + 373 + function lowestStat(stats) { 374 + const [name, value] = Object.entries(stats).sort((a, b) => a[1] - b[1])[0]; 375 + return { name, value: Math.round(value) }; 376 + } 377 + 378 + function careStatus(pet, lastAttention, nowMs) { 379 + const lowest = lowestStat(pet.stats); 380 + if (pet.moodState === "dead") { 381 + return { state: "dead", label: "gone", message: "no life signs", lowestStat: lowest, lastAttentionAt: lastAttention }; 382 + } 383 + 384 + const idleHours = lastAttention ? Math.max(0, (nowMs - Date.parse(lastAttention)) / 3_600_000) : Infinity; 385 + if (lowest.value <= 0 && idleHours >= tuning.criticalNeglectHours) { 386 + return { state: "dead", label: "gone", message: "attention stopped for too long", lowestStat: lowest, lastAttentionAt: lastAttention }; 387 + } 388 + if (lowest.value <= 5) return { state: "critical", label: "critical", message: `${lowest.name} is nearly gone`, lowestStat: lowest, lastAttentionAt: lastAttention }; 389 + if (lowest.value <= 15) return { state: "urgent", label: "urgent", message: `${lowest.name} needs care soon`, lowestStat: lowest, lastAttentionAt: lastAttention }; 390 + if (lowest.value <= 30) return { state: "warning", label: "watch", message: `${lowest.name} is slipping`, lowestStat: lowest, lastAttentionAt: lastAttention }; 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 + const lastAttention = await lastAttentionAt(); 373 407 pet.activityPressure = pressure; 374 408 375 - if (elapsedHours >= 1 / 60) { 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 - pet.moodState = moodState(pet.stats); 417 + const status = careStatus(pet, lastAttention, nowMs); 418 + pet.moodState = status.state === "dead" ? "dead" : moodState(pet.stats); 384 419 await savePet(pet); 385 420 return pet; 386 421 } 387 422 388 - function renderPet(pet) { 423 + function renderPet(pet, status = careStatus(pet, null, Date.now())) { 389 424 const sprites = pet.traits.includes("sprout") ? sproutPetSprites : petSprites; 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 - =( o O )= 450 + =( o O )= .-. 415 451 / \ 416 - /|___|\ 452 + /|___|\ ( ) 417 453 _/ \_ 454 + '-' 418 455 `, 419 456 sad: sprite` 420 457 /\_/\ 421 - =( ._. )= 422 - / \ 423 - /|___|\ 458 + =( ;_; )= 459 + _/ \_ 460 + |___| 424 461 / \ 425 462 `, 426 463 tired: sprite` ··· 436 473 _/ \_ 437 474 /_|___|_\ 438 475 ~ ~ 476 + `, 477 + warning: sprite` 478 + /\_/\ 479 + =( o_o )= 480 + _/ \_ 481 + /_|___|_\ 482 + / \ 483 + needs care 484 + `, 485 + urgent: sprite` 486 + /\_/\ 487 + =( O_O )= 488 + _/| |\_ 489 + _|_|_ 490 + / \ 491 + low stat 492 + `, 493 + critical: sprite` 494 + /\_/\ 495 + =( x_o )= 496 + _/ \_ 497 + /_|___|_\ 498 + _/ \_ 499 + critical 500 + `, 501 + dead: sprite` 502 + /\_/\ 503 + =( x_x )= 504 + / \ 505 + /|___|\ 506 + _ _ 439 507 ` 440 508 }; 441 509 ··· 459 527 hungry: sprite` 460 528 ,^, 461 529 /\_/\ 462 - =( o O )= 530 + =( o O )= .-. 463 531 / \ 464 - /|___|\ 532 + /|___|\ ( ) 465 533 _/ \_ 534 + '-' 466 535 `, 467 536 sad: sprite` 468 537 ,^, 469 538 /\_/\ 470 - =( ._. )= 471 - / \ 472 - /|___|\ 539 + =( ;_; )= 540 + _/ \_ 541 + |___| 473 542 / \ 474 543 `, 475 544 tired: sprite` ··· 487 556 _/ \_ 488 557 /_|___|_\ 489 558 ~ ~ 559 + `, 560 + warning: sprite` 561 + ,^, 562 + /\_/\ 563 + =( o_o )= 564 + _/ \_ 565 + /_|___|_\ 566 + / \ 567 + needs care 568 + `, 569 + urgent: sprite` 570 + ,^, 571 + /\_/\ 572 + =( O_O )= 573 + _/| |\_ 574 + _|_|_ 575 + / \ 576 + low stat 577 + `, 578 + critical: sprite` 579 + ,^, 580 + /\_/\ 581 + =( x_o )= 582 + _/ \_ 583 + /_|___|_\ 584 + _/ \_ 585 + critical 586 + `, 587 + dead: sprite` 588 + ,^, 589 + /\_/\ 590 + =( x_x )= 591 + / \ 592 + /|___|\ 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 + const visiblePet = { ...pet }; 604 + delete visiblePet.activityPressure; 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 - pet, 509 - render: { format: "ascii", text: renderPet(pet) }, 615 + pet: visiblePet, 616 + render: { format: "ascii", text: renderPet(pet, care) }, 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 + const turnKey = `turn:${actor.did}`; 683 792 const cooldownRows = await getRows(` 684 793 SELECT key, ready_at FROM cooldowns 685 - WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)}); 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 + const turnKey = `turn:${actor.did}`; 748 858 const cooldownRows = await getRows(` 749 859 SELECT key, ready_at FROM cooldowns 750 - WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)}); 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 + 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 + ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at; 887 + INSERT INTO cooldowns (key, ready_at) 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,