Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Metadata
- Domain: late.sh - Command-Line Clubhouse for Computer People
- Primary audience: LLM agents working on this codebase, human contributors
- Last updated: 2026-07-03 (Clubhouse goes multiplayer: one process-global lobby seats every active human, first move frees your seat, everyone sees everyone walk; the embedded #lounge chat panel is replaced by speech bubbles over avatars plus a pinned composer footer; emotes, door arrival/departure ambience, shared dog petting, and a persisted first-visit tutorial ending in a scripted @bartender greeting — see the new `late-ssh/src/app/clubhouse/CONTEXT.md`. Previously: World Cup HUD viewer-timezone kick-offs; World Cup HUD as top-level screen `7`, a demand-gated FotMob live poller; dopewars door: prod infra + dedicated CI/CD — `infra/service-ssh.tf`, `.github/workflows/dopewars.yml`)
- Last updated: 2026-07-03 (Bartender drinks: @bartender charges Late Chips for drinks (AI-priced 100-1000, out-of-range refused, floor-guarded debit), per-user drunkenness in `user_drinks` decays over hours and tints username labels in the clubhouse and chat author labels — see `late-ssh/src/app/clubhouse/CONTEXT.md`. Same day: Clubhouse goes multiplayer: one process-global lobby seats every active human, first move frees your seat, everyone sees everyone walk; the embedded #lounge chat panel is replaced by speech bubbles over avatars plus a pinned composer footer; emotes, door arrival/departure ambience, shared dog petting, and a persisted first-visit tutorial ending in a scripted @bartender greeting — see the new `late-ssh/src/app/clubhouse/CONTEXT.md`. Previously: World Cup HUD viewer-timezone kick-offs; World Cup HUD as top-level screen `7`, a demand-gated FotMob live poller; dopewars door: prod infra + dedicated CI/CD — `infra/service-ssh.tf`, `.github/workflows/dopewars.yml`)
- Status: Active
- Stability note: Sections marked `[STABLE]` should change rarely. Sections marked `[VOLATILE]` are expected to change often.

Expand Down Expand Up @@ -278,7 +278,7 @@ flowchart LR
- `ShopService` (in `app/hub/shop/svc.rs`) exposes per-user `watch::Receiver<ShopSnapshot>` values and purchase result broadcasts. It loads marketplace items, user purchases, and chip balance into a per-user snapshot at session init and after changes; render/input gates read the snapshot instead of querying DB on every keypress. It also runs a Postgres LISTEN/NOTIFY listener for `shop_user_changed`, `shop_catalog_changed`, and `chip_user_changed`, so multiple SSH replicas can refresh active users after another process changes shop or chip state.
- `QuestService` (in `app/hub/dailies/svc.rs`) exposes per-user `watch::Receiver<QuestSnapshot>` values for the Hub Quests tab. Reward templates are DB-backed in `reward_templates`; rows with `is_quest = true` are eligible for daily/weekly quest draws. Current global draws live in `quest_assignments`, per-user progress lives in `user_quest_progress`, and per-user daily streaks live in `user_daily_quest_streaks`. The service assigns one Arcade daily quest, one multiplayer room-game daily quest, and one weekly quest on UTC periods, consumes structured Activity events for progress, pays template-defined chip rewards automatically once per assignment, pays daily-streak bonuses for consecutive days where at least one daily quest is completed (+100 through +500 chips), and listens for `quest_user_changed` / `quest_assignments_changed` notifications for cross-process refresh.
- `Hub` (in `app/hub`) is the global modal opened by reserved global `Ctrl+G` except during active Artboard editing. It owns cross-product surfaces such as Shop, Leaderboard, Quests, and Events. Former Guide content lives in the global `?` guide's Economy topic. Hub may summarize data from Arcade, Rooms, and economy services, but those domains keep their own runtime/service ownership; Hub-owned marketplace and entitlement projection code lives under `app/hub/shop`. Detailed Hub behavior lives in `late-ssh/src/app/hub/CONTEXT.md`.
- `ChipService` (in `app/games/chips/svc.rs`) manages the Late Chips economy: `ensure_chips(user_id)` creates new chip rows with 1000 chips, its activity reward task awards daily puzzle base chips from `reward_templates` after `GameWon` events, and reward-template payout helpers record minted game rewards in `game_payout_claims`. Chip-paying room games and Lateania hold a `ChipService` clone; Lateania boss achievements use lifetime payout claims for the Archdemon Mal'gareth (10,000 chips) and the King Who Was Promised Nothing (20,000 chips). The NetHack door (via its `NethackAwards` sink) uses the same lifetime payout claims for acquiring the Amulet of Yendor (10,000 chips) and ascending (20,000 chips), triggered by scraping the live vt100 screen.
- `ChipService` (in `app/games/chips/svc.rs`) manages the Late Chips economy: `ensure_chips(user_id)` creates new chip rows with 1000 chips, its activity reward task awards daily puzzle base chips from `reward_templates` after `GameWon` events, and reward-template payout helpers record minted game rewards in `game_payout_claims`. Chip-paying room games and Lateania hold a `ChipService` clone; Lateania boss achievements use lifetime payout claims for the Archdemon Mal'gareth (10,000 chips) and the King Who Was Promised Nothing (20,000 chips). The NetHack door (via its `NethackAwards` sink) uses the same lifetime payout claims for acquiring the Amulet of Yendor (10,000 chips) and ascending (20,000 chips), triggered by scraping the live vt100 screen. The clubhouse @bartender sells drinks through `ChipService::buy_drink`: one transaction that debits chips with a floor guard (`UserChips::deduct_for_drink`, only pours if the balance keeps the 100-chip floor; ledger reason `drink_purchase`, source_ref = drink name) and upserts the buyer's `user_drinks` buzz (`late-core/src/models/drinks.rs`: decay-at-read drunk points, 300/hour, capped at 6000). The AI decides drink and price (100-1000; out-of-range or unaffordable prices are refused uncharged so the debit always matches the quoted line) via `GhostService`'s ungrounded, schema-enforced JSON bartender flow in `app/ai/ghost.rs`; drunk levels tint username labels in the clubhouse and on chat author headers (a printed `(word)` from level 2 up, glow-only below).
- `BonsaiService` (in `app/bonsai/svc.rs`) owns tree care persistence and activity. First daily watering marks the care row and credits 200 chips through `UserChips`; watering is once per UTC day for everyone.
- `Activity` (in `app/activity`) owns the structured global user-action event type, channel helpers, and `ActivityPublisher` username lookup helper. `ActivityEvent` carries a dedupe id, `user_id`, `username`, display `action`, structured `ActivityKind`, category, and timestamp. Dashboard/sidebar display drains the same global broadcast stream through `ActivityFilter::dashboard()`. Quest-only progress signals such as score submissions and settled hand counts use `ActivityCategory::Quest`, which is intentionally hidden from the dashboard feed but consumed by `QuestService`.
- `RoomsService` (in `app/rooms/svc.rs`) owns persistent game-room creation/listing/deletion over `game_rooms` + associated `chat_rooms`, publishes `RoomsSnapshot` via `watch`, and emits `RoomsEvent` success/failure banners.
Expand Down
16 changes: 16 additions & 0 deletions late-core/migrations/094_create_user_drinks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Per-user tavern drink tally for the clubhouse @bartender. drunk_points is
-- the raw buzz at last_drink_at (chips spent on drinks, capped); the effective
-- level is computed at read time by decaying against elapsed time, so no
-- background sober-up task exists. lifetime_spent/drink_count are permanent
-- stats for future leaderboards.
CREATE TABLE user_drinks (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
created TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
updated TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
drunk_points BIGINT NOT NULL DEFAULT 0 CHECK (drunk_points >= 0),
lifetime_spent BIGINT NOT NULL DEFAULT 0 CHECK (lifetime_spent >= 0),
drink_count BIGINT NOT NULL DEFAULT 0 CHECK (drink_count >= 0),
last_drink_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp
);

CREATE INDEX user_drinks_last_drink_idx ON user_drinks (last_drink_at DESC);
51 changes: 51 additions & 0 deletions late-core/src/models/chips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub const INITIAL_CHIP_BALANCE: i64 = 1_000;
pub const CHIP_USER_CHANGED_CHANNEL: &str = "chip_user_changed";
pub const CHIP_GIFT_SENT_REASON: &str = "chip_gift_sent";
pub const CHIP_GIFT_RECEIVED_REASON: &str = "chip_gift_received";
pub const DRINK_PURCHASE_REASON: &str = "drink_purchase";
pub const DRINK_PURCHASE_SOURCE_KIND: &str = "bartender";

pub async fn listen_for_chip_changes(client: &Client) -> Result<()> {
client
Expand Down Expand Up @@ -140,6 +142,55 @@ impl UserChips {
Ok(row.map(Self::from))
}

/// Deduct chips for a bartender drink. Unlike [`Self::deduct`], the
/// gift-style guard applies: the pour only succeeds when the balance
/// stays at or above [`CHIP_FLOOR`], so the bar can't leave a user broke.
/// The ledger row carries the drink name so the tab is auditable.
/// Returns None if the user can't cover the drink and keep the floor.
pub async fn deduct_for_drink(
client: &impl GenericClient,
user_id: Uuid,
amount: i64,
drink: &str,
) -> Result<Option<Self>> {
ensure!(amount > 0, "drink price must be positive");
let row = client
.query_opt(
"WITH updated AS (
UPDATE user_chips
SET balance = balance - $2, updated = current_timestamp
WHERE user_id = $1 AND balance - $2 >= $3
RETURNING *
),
ledger AS (
INSERT INTO chip_ledger (user_id, delta, reason, source_kind, source_ref)
SELECT user_id, -$2, $4, $5, $6
FROM updated
RETURNING 1
),
chip_notify AS (
SELECT pg_notify($7, user_id::text)
FROM updated
),
chip_notified AS (
SELECT count(*) FROM chip_notify
)
SELECT updated.*
FROM updated, chip_notified",
&[
&user_id,
&amount,
&CHIP_FLOOR,
&DRINK_PURCHASE_REASON,
&DRINK_PURCHASE_SOURCE_KIND,
&drink,
&CHIP_USER_CHANGED_CHANNEL,
],
)
.await?;
Ok(row.map(Self::from))
}

pub async fn restore_floor(client: &Client, user_id: Uuid) -> Result<Self> {
let row = client
.query_one(
Expand Down
Loading
Loading