diff --git a/apps/server/src/db/integration.test.ts b/apps/server/src/db/integration.test.ts index 9c9cdc5..8cdf0aa 100644 --- a/apps/server/src/db/integration.test.ts +++ b/apps/server/src/db/integration.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_AVATAR_APPEARANCE, ROOM_CREATION_COST } from "@tilezo/protocol" import { sql } from "drizzle-orm"; import { DrizzleAuthStore, UsernameTakenError } from "../auth/auth"; import { DrizzleEconomyStore } from "../economy/economy"; +import { DrizzlePlaytimeRewardStore, PLAYTIME_ACTIVE_WINDOW_MS } from "../economy/playtimeRewards"; import { DrizzleFriendStore } from "../friends/friends"; import { DrizzleDirectMessageStore } from "../messaging/messaging"; import { createDatabase } from "./db"; @@ -27,13 +28,14 @@ describe("database integration", () => { const authStore = new DrizzleAuthStore(database); const economyStore = new DrizzleEconomyStore(database); + const playtimeRewardStore = new DrizzlePlaytimeRewardStore(database); const friendStore = new DrizzleFriendStore(database); const directMessageStore = new DrizzleDirectMessageStore(database); const persistence = new DrizzlePersistenceStore(database); beforeEach(async () => { await database.execute( - sql`TRUNCATE TABLE users, rooms, friendships, user_room_sessions, room_items, direct_messages, user_inventory RESTART IDENTITY CASCADE`, + sql`TRUNCATE TABLE users, rooms, friendships, user_room_sessions, room_items, direct_messages, user_inventory, user_playtime_rewards RESTART IDENTITY CASCADE`, ); }); @@ -200,6 +202,29 @@ describe("database integration", () => { }); }); + test("credits hourly active play rewards and persists remainder progress", async () => { + const owner = await seedUser("Dan"); + const startBalance = owner.dollars; + const startedAt = new Date("2026-06-15T00:00:00.000Z"); + let result: Awaited>; + + for (let index = 0; index <= 12; index += 1) { + result = await playtimeRewardStore.apply( + owner.id, + "activity", + new Date(startedAt.getTime() + index * PLAYTIME_ACTIVE_WINDOW_MS), + ); + } + + expect(result).toMatchObject({ + accruedActiveMs: 0, + awardedDollars: 500, + awardedIntervals: 1, + balance: startBalance + 500, + }); + expect(await economyStore.getBalance(owner.id)).toBe(startBalance + 500); + }); + test("rejects spending and purchases with insufficient funds", async () => { const owner = await seedUser("Dan"); await economyStore.spend(owner.id, owner.dollars); diff --git a/apps/server/src/db/migrations/0015_equal_roulette.sql b/apps/server/src/db/migrations/0015_equal_roulette.sql new file mode 100644 index 0000000..6fbc2f5 --- /dev/null +++ b/apps/server/src/db/migrations/0015_equal_roulette.sql @@ -0,0 +1,11 @@ +CREATE TABLE "user_playtime_rewards" ( + "user_id" text PRIMARY KEY NOT NULL, + "accrued_active_ms" integer DEFAULT 0 NOT NULL, + "last_activity_at" timestamp with time zone, + "last_accrued_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_playtime_rewards_accrued_active_ms_check" CHECK ("user_playtime_rewards"."accrued_active_ms" >= 0) +); +--> statement-breakpoint +ALTER TABLE "user_playtime_rewards" ADD CONSTRAINT "user_playtime_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0015_snapshot.json b/apps/server/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..e970bf2 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,966 @@ +{ + "id": "a9faef79-4b1e-48cd-b57d-fdf2491d48e0", + "prevId": "789cf7ee-ddc0-4a49-8ad4-0fe336efc43c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocked_users": { + "name": "blocked_users", + "schema": "", + "columns": { + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocked_users_blocked_user_id_idx": { + "name": "blocked_users_blocked_user_id_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocked_users_blocker_user_id_users_id_fk": { + "name": "blocked_users_blocker_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocker_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocked_users_blocked_user_id_users_id_fk": { + "name": "blocked_users_blocked_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocked_users_blocker_user_id_blocked_user_id_pk": { + "name": "blocked_users_blocker_user_id_blocked_user_id_pk", + "columns": ["blocker_user_id", "blocked_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "blocked_users_no_self_check": { + "name": "blocked_users_no_self_check", + "value": "\"blocked_users\".\"blocker_user_id\" <> \"blocked_users\".\"blocked_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.direct_messages": { + "name": "direct_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "direct_messages_pair_idx": { + "name": "direct_messages_pair_idx", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_recipient_idx": { + "name": "direct_messages_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_unread_idx": { + "name": "direct_messages_unread_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_deleted_idx": { + "name": "direct_messages_deleted_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "direct_messages_sender_user_id_users_id_fk": { + "name": "direct_messages_sender_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_recipient_user_id_users_id_fk": { + "name": "direct_messages_recipient_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["recipient_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "direct_messages_no_self_check": { + "name": "direct_messages_no_self_check", + "value": "\"direct_messages\".\"sender_user_id\" <> \"direct_messages\".\"recipient_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "friend_user_id": { + "name": "friend_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_friend_user_id_idx": { + "name": "friendships_friend_user_id_idx", + "columns": [ + { + "expression": "friend_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friendships_status_idx": { + "name": "friendships_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_id_users_id_fk": { + "name": "friendships_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_friend_user_id_users_id_fk": { + "name": "friendships_friend_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["friend_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_requested_by_user_id_users_id_fk": { + "name": "friendships_requested_by_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_id_friend_user_id_pk": { + "name": "friendships_user_id_friend_user_id_pk", + "columns": ["user_id", "friend_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "friendships_no_self_check": { + "name": "friendships_no_self_check", + "value": "\"friendships\".\"user_id\" <> \"friendships\".\"friend_user_id\"" + }, + "friendships_status_check": { + "name": "friendships_status_check", + "value": "\"friendships\".\"status\" IN ('pending', 'accepted')" + } + }, + "isRLSEnabled": false + }, + "public.room_items": { + "name": "room_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "z": { + "name": "z", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rotation": { + "name": "rotation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_items_room_id_idx": { + "name": "room_items_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "room_items_room_id_position_idx": { + "name": "room_items_room_id_position_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "x", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "y", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "z", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "room_items_room_id_rooms_id_fk": { + "name": "room_items_room_id_rooms_id_fk", + "tableFrom": "room_items", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rooms_owner_user_id_idx": { + "name": "rooms_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rooms_visibility_name_id_idx": { + "name": "rooms_visibility_name_id_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rooms_owner_user_id_users_id_fk": { + "name": "rooms_owner_user_id_users_id_fk", + "tableFrom": "rooms", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rooms_slug_unique": { + "name": "rooms_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_inventory": { + "name": "user_inventory", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_inventory_user_id_idx": { + "name": "user_inventory_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_inventory_user_id_users_id_fk": { + "name": "user_inventory_user_id_users_id_fk", + "tableFrom": "user_inventory", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_inventory_user_id_item_type_pk": { + "name": "user_inventory_user_id_item_type_pk", + "columns": ["user_id", "item_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_inventory_quantity_check": { + "name": "user_inventory_quantity_check", + "value": "\"user_inventory\".\"quantity\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_playtime_rewards": { + "name": "user_playtime_rewards", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accrued_active_ms": { + "name": "accrued_active_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_accrued_at": { + "name": "last_accrued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_playtime_rewards_user_id_users_id_fk": { + "name": "user_playtime_rewards_user_id_users_id_fk", + "tableFrom": "user_playtime_rewards", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_playtime_rewards_accrued_active_ms_check": { + "name": "user_playtime_rewards_accrued_active_ms_check", + "value": "\"user_playtime_rewards\".\"accrued_active_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_room_sessions": { + "name": "user_room_sessions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_room_sessions_room_id_idx": { + "name": "user_room_sessions_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_room_sessions_user_id_users_id_fk": { + "name": "user_room_sessions_user_id_users_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_room_sessions_room_id_rooms_id_fk": { + "name": "user_room_sessions_room_id_rooms_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username_key": { + "name": "username_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dollars": { + "name": "dollars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"hair\":\"short\",\"hairColor\":\"#7a4424\",\"skinTone\":\"#f2c097\",\"shirt\":\"crew\",\"shirtColor\":\"#2f5f7f\",\"pants\":\"straight\",\"pantsColor\":\"#d2c294\",\"shoes\":\"boots\",\"shoesColor\":\"#5b4218\"}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_key_unique": { + "name": "users_username_key_unique", + "nullsNotDistinct": false, + "columns": ["username_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 65f0bdf..a9f2cd4 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1781463352452, "tag": "0014_medical_supreme_intelligence", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1781550436210, + "tag": "0015_equal_roulette", + "breakpoints": true } ] } diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 42e850b..65936de 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -91,6 +91,23 @@ export const userInventory = pgTable( ], ); +export const userPlaytimeRewards = pgTable( + "user_playtime_rewards", + { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + accruedActiveMs: integer("accrued_active_ms").notNull().default(0), + lastActivityAt: timestamp("last_activity_at", { withTimezone: true }), + lastAccruedAt: timestamp("last_accrued_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + check("user_playtime_rewards_accrued_active_ms_check", sql`${table.accruedActiveMs} >= 0`), + ], +); + export const userRoomSessions = pgTable( "user_room_sessions", { diff --git a/apps/server/src/economy/playtimeRewards.test.ts b/apps/server/src/economy/playtimeRewards.test.ts new file mode 100644 index 0000000..d9b4a2f --- /dev/null +++ b/apps/server/src/economy/playtimeRewards.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test"; +import { + applyPlaytimeAccrual, + DrizzlePlaytimeRewardStore, + PLAYTIME_ACTIVE_WINDOW_MS, + PLAYTIME_REWARD_INTERVAL_MS, + type PlaytimeRewardApplyResult, + type PlaytimeRewardMutation, + PlaytimeRewardService, + type PlaytimeRewardState, + type PlaytimeRewardStore, +} from "./playtimeRewards"; + +const BASE_TIME = Date.parse("2026-06-15T00:00:00.000Z"); + +describe("applyPlaytimeAccrual", () => { + test("starts tracking on the first input without awarding immediately", () => { + const result = applyPlaytimeAccrual({ accruedActiveMs: 0 }, "activity", at(0)); + + expect(result).toMatchObject({ + accruedActiveMs: 0, + awardedDollars: 0, + awardedIntervals: 0, + }); + expect(result.lastActivityAt?.getTime()).toBe(BASE_TIME); + expect(result.lastAccruedAt?.getTime()).toBe(BASE_TIME); + }); + + test("accrues elapsed time between inputs inside the active window", () => { + const result = applyPlaytimeAccrual( + { + accruedActiveMs: 0, + lastActivityAt: at(0), + lastAccruedAt: at(0), + }, + "activity", + at(4 * 60 * 1000), + ); + + expect(result.accruedActiveMs).toBe(4 * 60 * 1000); + expect(result.awardedDollars).toBe(0); + expect(result.lastActivityAt?.getTime()).toBe(at(4 * 60 * 1000).getTime()); + expect(result.lastAccruedAt?.getTime()).toBe(at(4 * 60 * 1000).getTime()); + }); + + test("caps an idle gap at the five minute active window", () => { + const result = applyPlaytimeAccrual( + { + accruedActiveMs: 0, + lastActivityAt: at(0), + lastAccruedAt: at(0), + }, + "activity", + at(10 * 60 * 1000), + ); + + expect(result.accruedActiveMs).toBe(PLAYTIME_ACTIVE_WINDOW_MS); + expect(result.awardedDollars).toBe(0); + expect(result.lastActivityAt?.getTime()).toBe(at(10 * 60 * 1000).getTime()); + expect(result.lastAccruedAt?.getTime()).toBe(at(10 * 60 * 1000).getTime()); + }); + + test("awards multiple full hours and carries the remainder", () => { + const result = applyPlaytimeAccrual( + { + accruedActiveMs: 7_000_000, + lastActivityAt: at(0), + lastAccruedAt: at(0), + }, + "activity", + at(PLAYTIME_ACTIVE_WINDOW_MS), + ); + + expect(result.awardedIntervals).toBe(2); + expect(result.awardedDollars).toBe(1000); + expect(result.accruedActiveMs).toBe(7_000_000 + PLAYTIME_ACTIVE_WINDOW_MS - 2 * 3_600_000); + }); + + test("flushes only uncounted active time without extending activity", () => { + const result = applyPlaytimeAccrual( + { + accruedActiveMs: 0, + lastActivityAt: at(0), + lastAccruedAt: at(0), + }, + "flush", + at(10 * 60 * 1000), + ); + + expect(result.accruedActiveMs).toBe(PLAYTIME_ACTIVE_WINDOW_MS); + expect(result.lastActivityAt?.getTime()).toBe(BASE_TIME); + expect(result.lastAccruedAt?.getTime()).toBe(at(PLAYTIME_ACTIVE_WINDOW_MS).getTime()); + }); +}); + +describe("PlaytimeRewardService", () => { + test("flushes connected users once even when a user has multiple sockets", async () => { + const store = new MemoryPlaytimeRewardStore(); + const service = new PlaytimeRewardService(store); + + service.socketOpened("user_1"); + service.socketOpened("user_1"); + + await service.flushConnectedUsers(at(60_000)); + + expect(store.calls).toEqual([{ userId: "user_1", mutation: "flush", now: at(60_000) }]); + }); + + test("flushes accrued active time when the last socket closes", async () => { + const store = new MemoryPlaytimeRewardStore(); + const service = new PlaytimeRewardService(store); + + service.socketOpened("user_1"); + await service.recordActivity("user_1", at(0)); + await service.socketClosed("user_1", at(4 * 60 * 1000)); + + expect(store.states.get("user_1")?.accruedActiveMs).toBe(4 * 60 * 1000); + }); + + test("does not flush when another socket remains open", async () => { + const store = new MemoryPlaytimeRewardStore(); + const service = new PlaytimeRewardService(store); + + service.socketOpened("user_1"); + service.socketOpened("user_1"); + await service.recordActivity("user_1", at(0)); + store.calls.length = 0; + + await service.socketClosed("user_1", at(4 * 60 * 1000)); + + expect(store.calls).toEqual([]); + }); + + test("publishes a balance update when activity earns an hourly reward", async () => { + const store = new MemoryPlaytimeRewardStore(); + const published: Array<{ userId: string; dollars: number }> = []; + const service = new PlaytimeRewardService(store, { + publishBalanceUpdate(userId, dollars) { + published.push({ userId, dollars }); + }, + }); + store.balances.set("user_1", 500); + store.states.set("user_1", { + accruedActiveMs: PLAYTIME_REWARD_INTERVAL_MS - 100_000, + lastActivityAt: at(0), + lastAccruedAt: at(0), + }); + + await service.recordActivity("user_1", at(100_000)); + + expect(published).toEqual([{ userId: "user_1", dollars: 1000 }]); + expect(store.states.get("user_1")?.accruedActiveMs).toBe(0); + }); +}); + +describe("DrizzlePlaytimeRewardStore", () => { + test("applies activity and credits earned dollars in one transaction", async () => { + const db = queryDouble(); + const tx = queryDouble([ + [], + [ + { + accruedActiveMs: PLAYTIME_REWARD_INTERVAL_MS - 100_000, + lastActivityAt: at(0), + lastAccruedAt: at(0), + }, + ], + [{ dollars: 1000 }], + [], + ]); + db.transaction = async (callback: (transaction: unknown) => unknown) => callback(tx); + const store = new DrizzlePlaytimeRewardStore(db); + + await expect(store.apply("user_1", "activity", at(100_000))).resolves.toMatchObject({ + accruedActiveMs: 0, + awardedDollars: 500, + awardedIntervals: 1, + balance: 1000, + }); + }); + + test("does not create reward state during a flush for an unseen user", async () => { + const db = queryDouble(); + const tx = queryDouble([[]]); + db.transaction = async (callback: (transaction: unknown) => unknown) => callback(tx); + const store = new DrizzlePlaytimeRewardStore(db); + + await expect(store.apply("user_1", "flush", at(100_000))).resolves.toBeUndefined(); + }); +}); + +function at(offsetMs: number): Date { + return new Date(BASE_TIME + offsetMs); +} + +class MemoryPlaytimeRewardStore implements PlaytimeRewardStore { + readonly balances = new Map(); + readonly calls: Array<{ userId: string; mutation: PlaytimeRewardMutation; now: Date }> = []; + readonly states = new Map(); + + async apply( + userId: string, + mutation: PlaytimeRewardMutation, + now: Date, + ): Promise { + this.calls.push({ userId, mutation, now }); + + if (mutation === "activity" && !this.states.has(userId)) { + this.states.set(userId, { accruedActiveMs: 0 }); + } + + const state = this.states.get(userId); + + if (!state) { + return undefined; + } + + const result = applyPlaytimeAccrual(state, mutation, now); + this.states.set(userId, { + accruedActiveMs: result.accruedActiveMs, + lastActivityAt: result.lastActivityAt, + lastAccruedAt: result.lastAccruedAt, + }); + + if (result.awardedDollars <= 0) { + return result; + } + + const balance = (this.balances.get(userId) ?? 0) + result.awardedDollars; + this.balances.set(userId, balance); + return { ...result, balance }; + } +} + +function queryDouble( + results: unknown[][] = [], + // biome-ignore lint/suspicious/noExplicitAny: a structural stand-in for the Drizzle database. +): any { + let index = 0; + const chain: Record = { + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable and chainable. + then(resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(results[index++] ?? []).then(resolve, reject); + }, + }; + + for (const method of [ + "select", + "from", + "where", + "for", + "update", + "set", + "returning", + "insert", + "values", + "onConflictDoNothing", + ]) { + chain[method] = () => chain; + } + + return chain; +} diff --git a/apps/server/src/economy/playtimeRewards.ts b/apps/server/src/economy/playtimeRewards.ts new file mode 100644 index 0000000..24ced12 --- /dev/null +++ b/apps/server/src/economy/playtimeRewards.ts @@ -0,0 +1,215 @@ +import { eq, sql } from "drizzle-orm"; +import type { TilezoDatabase } from "../db/db"; +import { userPlaytimeRewards, users } from "../db/schema"; + +export const PLAYTIME_REWARD_DOLLARS = 500; +export const PLAYTIME_REWARD_INTERVAL_MS = 60 * 60 * 1000; +export const PLAYTIME_ACTIVE_WINDOW_MS = 5 * 60 * 1000; +export const PLAYTIME_REWARD_FLUSH_INTERVAL_MS = 60 * 1000; + +export type PlaytimeRewardMutation = "activity" | "flush"; + +export type PlaytimeRewardState = { + accruedActiveMs: number; + lastActivityAt?: Date; + lastAccruedAt?: Date; +}; + +export type PlaytimeRewardApplyResult = { + accruedActiveMs: number; + awardedDollars: number; + awardedIntervals: number; + balance?: number; + lastActivityAt?: Date; + lastAccruedAt?: Date; +}; + +export type PlaytimeRewardStore = { + apply( + userId: string, + mutation: PlaytimeRewardMutation, + now: Date, + ): Promise; +}; + +type PlaytimeRewardServiceOptions = { + now?: () => Date; + publishBalanceUpdate?: (userId: string, dollars: number) => void; +}; + +export class PlaytimeRewardService { + private readonly activeSocketsByUser = new Map(); + private readonly pendingByUser = new Map>(); + private readonly now: () => Date; + + constructor( + private readonly store: PlaytimeRewardStore, + private readonly options: PlaytimeRewardServiceOptions = {}, + ) { + this.now = options.now ?? (() => new Date()); + } + + socketOpened(userId: string): void { + this.activeSocketsByUser.set(userId, (this.activeSocketsByUser.get(userId) ?? 0) + 1); + } + + async socketClosed(userId: string, now = this.now()): Promise { + const remaining = Math.max(0, (this.activeSocketsByUser.get(userId) ?? 1) - 1); + + if (remaining > 0) { + this.activeSocketsByUser.set(userId, remaining); + return emptyResult(); + } + + this.activeSocketsByUser.delete(userId); + return await this.flushUser(userId, now); + } + + async recordActivity(userId: string, now = this.now()): Promise { + return await this.apply(userId, "activity", now); + } + + async flushConnectedUsers(now = this.now()): Promise { + const userIds = [...this.activeSocketsByUser.keys()]; + return await Promise.all(userIds.map((userId) => this.flushUser(userId, now))); + } + + async flushUser(userId: string, now = this.now()): Promise { + return await this.apply(userId, "flush", now); + } + + private async apply( + userId: string, + mutation: PlaytimeRewardMutation, + now: Date, + ): Promise { + return await this.enqueue(userId, async () => { + const result = (await this.store.apply(userId, mutation, now)) ?? emptyResult(); + + if (result.balance !== undefined && result.awardedDollars > 0) { + this.options.publishBalanceUpdate?.(userId, result.balance); + } + + return result; + }); + } + + private async enqueue(userId: string, operation: () => Promise): Promise { + const previous = this.pendingByUser.get(userId) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(operation); + const tracked = next.finally(() => { + if (this.pendingByUser.get(userId) === tracked) { + this.pendingByUser.delete(userId); + } + }); + this.pendingByUser.set(userId, tracked); + return await next; + } +} + +export class DrizzlePlaytimeRewardStore implements PlaytimeRewardStore { + constructor(private readonly db: TilezoDatabase) {} + + async apply( + userId: string, + mutation: PlaytimeRewardMutation, + now: Date, + ): Promise { + return await this.db.transaction(async (tx) => { + if (mutation === "activity") { + await tx.insert(userPlaytimeRewards).values({ userId }).onConflictDoNothing(); + } + + const [stored] = await tx + .select({ + accruedActiveMs: userPlaytimeRewards.accruedActiveMs, + lastActivityAt: userPlaytimeRewards.lastActivityAt, + lastAccruedAt: userPlaytimeRewards.lastAccruedAt, + }) + .from(userPlaytimeRewards) + .where(eq(userPlaytimeRewards.userId, userId)) + .for("update"); + + if (!stored) { + return undefined; + } + + const result = applyPlaytimeAccrual( + { + accruedActiveMs: stored.accruedActiveMs, + lastActivityAt: stored.lastActivityAt ?? undefined, + lastAccruedAt: stored.lastAccruedAt ?? undefined, + }, + mutation, + now, + ); + let balance: number | undefined; + + if (result.awardedDollars > 0) { + const [updatedUser] = await tx + .update(users) + .set({ dollars: sql`${users.dollars} + ${result.awardedDollars}` }) + .where(eq(users.id, userId)) + .returning({ dollars: users.dollars }); + balance = updatedUser?.dollars; + } + + await tx + .update(userPlaytimeRewards) + .set({ + accruedActiveMs: result.accruedActiveMs, + lastActivityAt: result.lastActivityAt ?? null, + lastAccruedAt: result.lastAccruedAt ?? null, + updatedAt: now, + }) + .where(eq(userPlaytimeRewards.userId, userId)); + + return { ...result, balance }; + }); + } +} + +export function applyPlaytimeAccrual( + state: PlaytimeRewardState, + mutation: PlaytimeRewardMutation, + now: Date, +): PlaytimeRewardApplyResult { + const nowMs = now.getTime(); + let accruedActiveMs = Math.max(0, state.accruedActiveMs); + let lastActivityAt = state.lastActivityAt; + let lastAccruedAt = state.lastAccruedAt; + + if (lastActivityAt && lastAccruedAt) { + const eligibleUntilMs = Math.min(nowMs, lastActivityAt.getTime() + PLAYTIME_ACTIVE_WINDOW_MS); + accruedActiveMs += Math.max(0, eligibleUntilMs - lastAccruedAt.getTime()); + + if (mutation === "activity") { + lastActivityAt = now; + lastAccruedAt = now; + } else { + lastAccruedAt = new Date(eligibleUntilMs); + } + } else if (mutation === "activity") { + lastActivityAt = now; + lastAccruedAt = now; + } + + const awardedIntervals = Math.floor(accruedActiveMs / PLAYTIME_REWARD_INTERVAL_MS); + accruedActiveMs %= PLAYTIME_REWARD_INTERVAL_MS; + + return { + accruedActiveMs, + awardedDollars: awardedIntervals * PLAYTIME_REWARD_DOLLARS, + awardedIntervals, + lastActivityAt, + lastAccruedAt, + }; +} + +function emptyResult(): PlaytimeRewardApplyResult { + return { + accruedActiveMs: 0, + awardedDollars: 0, + awardedIntervals: 0, + }; +} diff --git a/apps/server/src/net/handleMessage.test.ts b/apps/server/src/net/handleMessage.test.ts index 072c7be..25f8c18 100644 --- a/apps/server/src/net/handleMessage.test.ts +++ b/apps/server/src/net/handleMessage.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_AVATAR_APPEARANCE, type RoomItem, type ServerMessage } from "@t import type { ServerWebSocket } from "bun"; import type { PersistenceStore } from "../db/persistence"; import type { EconomyStore } from "../economy/economy"; +import type { PlaytimeRewardService } from "../economy/playtimeRewards"; import { DirectMessageError, type DirectMessageService } from "../messaging/messaging"; import type { Logger } from "../observability/logger"; import type { Metrics } from "../observability/metrics"; @@ -1246,6 +1247,46 @@ describe("handleMessage", () => { } }); + test("records playtime for accepted qualifying messages only", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const playtimeRewards = createPlaytimeRewardsDouble(); + const context = { + rooms, + publish() {}, + playtimeRewards: playtimeRewards.service, + }; + + handleMessage(ws, "{", context); + handleMessage(ws, JSON.stringify({ type: "room.list.request" }), context); + handleMessage(ws, JSON.stringify({ type: "ping", sentAt: "2026-05-10T00:00:00.000Z" }), { + ...context, + userRateLimits: new Map(), + }); + await flushAsyncMessages(); + + expect(playtimeRewards.activities).toEqual([]); + + handleMessage(ws, JSON.stringify({ type: "room.join", roomId: "lobby" }), context); + await flushAsyncMessages(); + + handleMessage(ws, JSON.stringify({ type: "avatar.move.request", target: { x: 1, y: 1 } }), { + ...context, + userRateLimits: new Map(), + }); + await flushAsyncMessages(); + + expect(playtimeRewards.activities).toEqual(["user_db_1", "user_db_1"]); + + handleMessage(ws, JSON.stringify({ type: "chat.say", text: "too fast" }), { + ...context, + userRateLimits: exhaustedRateLimitStore(ws.data.userId, "chat"), + }); + await flushAsyncMessages(); + + expect(playtimeRewards.activities).toEqual(["user_db_1", "user_db_1"]); + }); + test("resends a snapshot when the same socket rejoins its current room", async () => { const rooms = await RoomManager.create(); const ws = createSocket({ userId: "user_db_1", username: "Dan" }); @@ -1457,15 +1498,29 @@ describe("handleOpen", () => { connectionId: "socket_1", }); const userSockets = new Map(); + const playtimeRewards = createPlaytimeRewardsDouble(); handleOpen(ws, { rooms, publish() {}, userSockets, + playtimeRewards: playtimeRewards.service, }); - handleClose(ws, rooms, () => {}, undefined, undefined, undefined, userSockets); + handleClose( + ws, + rooms, + () => {}, + undefined, + undefined, + undefined, + userSockets, + playtimeRewards.service, + ); + await flushAsyncMessages(); expect(userSockets.has("user_db_1")).toBe(false); + expect(playtimeRewards.opened).toEqual(["user_db_1"]); + expect(playtimeRewards.closed).toEqual(["user_db_1"]); }); test("silently drops rejected resume rooms and logs clear failures", async () => { @@ -2305,6 +2360,43 @@ function createEconomyStore(): EconomyStore { }; } +function createPlaytimeRewardsDouble(): { + activities: string[]; + closed: string[]; + opened: string[]; + service: PlaytimeRewardService; +} { + const activities: string[] = []; + const closed: string[] = []; + const opened: string[] = []; + const result = { accruedActiveMs: 0, awardedDollars: 0, awardedIntervals: 0 }; + + return { + activities, + closed, + opened, + service: { + socketOpened(userId: string) { + opened.push(userId); + }, + async socketClosed(userId: string) { + closed.push(userId); + return result; + }, + async recordActivity(userId: string) { + activities.push(userId); + return result; + }, + async flushConnectedUsers() { + return []; + }, + async flushUser() { + return result; + }, + } as unknown as PlaytimeRewardService, + }; +} + function createSocket(data: SocketData = { userId: "user_1", dollars: 0 }) { const sent: ServerMessage[] = []; const subscribed: string[] = []; diff --git a/apps/server/src/net/handleMessage.ts b/apps/server/src/net/handleMessage.ts index 070677a..a8e756e 100644 --- a/apps/server/src/net/handleMessage.ts +++ b/apps/server/src/net/handleMessage.ts @@ -10,6 +10,7 @@ import { import type { ServerWebSocket } from "bun"; import type { PersistenceStore } from "../db/persistence"; import type { EconomyStore } from "../economy/economy"; +import type { PlaytimeRewardService } from "../economy/playtimeRewards"; import { DirectMessageError, type DirectMessageService } from "../messaging/messaging"; import type { Logger } from "../observability/logger"; import type { Metrics } from "../observability/metrics"; @@ -34,6 +35,7 @@ type Context = { joinVersions?: Map; joinTargets?: Map; userSockets?: UserSocketStore; + playtimeRewards?: PlaytimeRewardService; }; type RateLimitKind = keyof typeof RATE_LIMITS; @@ -86,7 +88,13 @@ export function handleMessage( return; } - void joinRoom(ws, parsed.value.roomId, context, { sendUnavailableError: true }); + void joinRoom(ws, parsed.value.roomId, context, { sendUnavailableError: true }).then( + (joined) => { + if (joined) { + recordPlaytimeActivity(ws, context); + } + }, + ); break; } @@ -123,6 +131,8 @@ export function handleMessage( return; } + recordPlaytimeActivity(ws, context); + if (path.length < 2) { // Target is the avatar's current tile: nothing to move, so do not broadcast an // empty path to the whole room (matches the bot mover's guard). @@ -174,6 +184,7 @@ export function handleMessage( // Only mirror the appearance onto the socket after the authoritative room update // succeeds, so a rejected update cannot leave the local copy out of sync. ws.data.appearance = parsed.value.appearance; + recordPlaytimeActivity(ws, context); context.publish(roomTopic(room.id), { type: "avatar.appearance.updated", @@ -255,6 +266,7 @@ export function handleMessage( text: parsed.value.text, sentAt: new Date().toISOString(), }); + recordPlaytimeActivity(ws, context); context.metrics?.increment("chat.accepted"); context.logger?.debug("room.chat.accepted", { ...socketFields(ws), @@ -291,6 +303,7 @@ export function handleMessage( username: ws.data.username, isTyping: parsed.value.isTyping, }); + recordPlaytimeActivity(ws, context); context.metrics?.increment("typing.accepted"); break; } @@ -401,6 +414,7 @@ export function handleOpen(ws: ServerWebSocket, context: Context): v context.presence?.connect(ws.data.userId, ws.data.connectionId); } registerUserSocket(ws, context.userSockets); + context.playtimeRewards?.socketOpened(ws.data.userId); context.metrics?.socketOpened(); context.logger?.info("websocket.opened", socketFields(ws)); // Subscribe to a per-user topic so direct messages can be delivered to this user's @@ -427,10 +441,12 @@ export function handleClose( metrics?: Metrics, presence?: PresenceTracker, userSockets?: UserSocketStore, + playtimeRewards?: PlaytimeRewardService, ) { metrics?.socketClosed(); const { roomId, userId } = ws.data; unregisterUserSocket(ws, userSockets); + flushPlaytimeOnSocketClose(userId, playtimeRewards, logger, metrics); if (ws.data.connectionId) { presence?.disconnect(userId, ws.data.connectionId); } @@ -460,7 +476,7 @@ async function joinRoom( roomId: string, context: Context, options: { sendUnavailableError: boolean }, -): Promise { +): Promise { const startedAt = performance.now(); try { @@ -492,7 +508,7 @@ async function joinRoom( if (access.code === "ROOM_NOT_FOUND" || !options.sendUnavailableError) { await clearLastRoomId(context, ws.data.userId); } - return; + return false; } const room = context.rooms.getOrCreate(roomId, ws.data.userId); @@ -507,7 +523,7 @@ async function joinRoom( sendError(ws, "ROOM_NOT_FOUND", "Room is not available"); } await clearLastRoomId(context, ws.data.userId); - return; + return false; } const previousRoomId = ws.data.roomId; @@ -526,7 +542,7 @@ async function joinRoom( ...socketFields(ws), roomId: room.id, }); - return; + return true; } const joinVersion = nextJoinVersion(context, ws.data.userId, room.id); @@ -611,6 +627,7 @@ async function joinRoom( ...socketFields(ws), roomId: room.id, }); + return true; } finally { context.metrics?.observe("room.join.duration", performance.now() - startedAt); } @@ -714,6 +731,7 @@ async function placeRoomItem( context.rooms.rememberRoomItem(room.id, placed); context.publish(roomTopic(room.id), { type: "room.item.placed", item: placed }); await sendInventoryUpdate(ws, context); + recordPlaytimeActivity(ws, context); context.metrics?.increment("room_item.place.accepted"); } @@ -765,6 +783,7 @@ async function moveRoomItem( context.rooms.rememberRoomItem(room.id, moved); context.publish(roomTopic(room.id), { type: "room.item.moved", item: moved }); + recordPlaytimeActivity(ws, context); context.metrics?.increment("room_item.move.accepted"); } @@ -797,6 +816,7 @@ async function pickupRoomItem( context.rooms.forgetRoomItem(room.id, pickedUp.id); context.publish(roomTopic(room.id), { type: "room.item.picked_up", itemId: pickedUp.id }); await sendInventoryUpdate(ws, context); + recordPlaytimeActivity(ws, context); context.metrics?.increment("room_item.pickup.accepted"); } @@ -849,6 +869,7 @@ async function interactWithRoomItem( context.rooms.rememberRoomItem(room.id, updated); context.publish(roomTopic(room.id), { type: "room.item.state_updated", item: updated }); + recordPlaytimeActivity(ws, context); context.metrics?.increment("room_item.interact.accepted"); } @@ -1073,6 +1094,7 @@ async function editDirectMessage( }; context.publish(userTopic(record.toUserId), message); context.publish(userTopic(record.fromUserId), message); + recordPlaytimeActivity(ws, context); context.metrics?.increment("dm_edit.accepted"); } catch (error) { if (error instanceof DirectMessageError) { @@ -1107,6 +1129,7 @@ async function deleteDirectMessage( }; context.publish(userTopic(record.toUserId), message); context.publish(userTopic(record.fromUserId), message); + recordPlaytimeActivity(ws, context); context.metrics?.increment("dm_delete.accepted"); } catch (error) { if (error instanceof DirectMessageError) { @@ -1132,6 +1155,7 @@ async function markDirectMessagesRead( try { const receipt = await context.directMessages.markRead(ws.data.userId, friendId); + recordPlaytimeActivity(ws, context); if (receipt.messageIds.length === 0) { return; @@ -1202,6 +1226,7 @@ async function sendDirectTyping( toUserId, isTyping, }); + recordPlaytimeActivity(ws, context); context.metrics?.increment("dm_typing.accepted"); } @@ -1230,6 +1255,7 @@ async function sendDirectMessage( // server-assigned id/timestamp stay in sync). context.publish(userTopic(record.toUserId), message); context.publish(userTopic(record.fromUserId), message); + recordPlaytimeActivity(ws, context); context.metrics?.increment("dm.sent"); } catch (error) { if (error instanceof DirectMessageError) { @@ -1262,6 +1288,37 @@ async function sendInventoryUpdate( } } +function recordPlaytimeActivity(ws: ServerWebSocket, context: Context): void { + const recorded = context.playtimeRewards?.recordActivity(ws.data.userId); + + if (!recorded) { + return; + } + + void recorded.catch((error) => { + context.metrics?.increment("playtime_reward.activity_failed"); + context.logger?.warn("playtime_reward.activity_failed", { ...socketFields(ws), error }); + }); +} + +function flushPlaytimeOnSocketClose( + userId: string, + playtimeRewards: PlaytimeRewardService | undefined, + logger?: Logger, + metrics?: Metrics, +): void { + const flushed = playtimeRewards?.socketClosed(userId); + + if (!flushed) { + return; + } + + void flushed.catch((error) => { + metrics?.increment("playtime_reward.close_flush_failed"); + logger?.warn("playtime_reward.close_flush_failed", { userId, error }); + }); +} + function send(ws: ServerWebSocket, message: ServerMessage): void { const result = ws.send(encodeServerMessage(message)); diff --git a/apps/server/src/serverRuntime.test.ts b/apps/server/src/serverRuntime.test.ts index 6ccb454..b3f89f9 100644 --- a/apps/server/src/serverRuntime.test.ts +++ b/apps/server/src/serverRuntime.test.ts @@ -257,6 +257,8 @@ describe("startServerRuntime", () => { expect(runtime.config.host).toBe("127.0.0.1"); expect(harness.serveOptions.websocket.maxPayloadLength).toBeGreaterThan(0); + expect(harness.timers).toHaveLength(3); + harness.timers[1]?.callback(); await stopRuntime(runtime); }); diff --git a/apps/server/src/serverRuntime.ts b/apps/server/src/serverRuntime.ts index 37c9771..2d4df85 100644 --- a/apps/server/src/serverRuntime.ts +++ b/apps/server/src/serverRuntime.ts @@ -8,6 +8,11 @@ import { getConfig, type ServerConfig } from "./config"; import { createDatabase } from "./db/db"; import { DrizzlePersistenceStore, type PersistenceStore } from "./db/persistence"; import { DrizzleEconomyStore, type EconomyStore } from "./economy/economy"; +import { + DrizzlePlaytimeRewardStore, + PLAYTIME_REWARD_FLUSH_INTERVAL_MS, + PlaytimeRewardService, +} from "./economy/playtimeRewards"; import { DrizzleFriendStore, FriendService } from "./friends/friends"; import { corsHeaders, createHttpRouter } from "./http/router"; import { DirectMessageService, DrizzleDirectMessageStore } from "./messaging/messaging"; @@ -186,6 +191,25 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< const userSockets: UserSocketStore = new Map(); const joinVersions = new Map(); const joinTargets = new Map(); + const playtimeRewards = database + ? new PlaytimeRewardService(new DrizzlePlaytimeRewardStore(database), { + publishBalanceUpdate(userId, dollars) { + publish(userTopic(userId), { type: "balance.updated", dollars }); + }, + }) + : undefined; + const playtimeRewardTimer = playtimeRewards + ? setIntervalRef(() => { + void playtimeRewards.flushConnectedUsers().catch((error) => { + metrics.increment("playtime_reward.periodic_flush_failed"); + logger.warn("playtime_reward.periodic_flush_failed", { error }); + }); + }, PLAYTIME_REWARD_FLUSH_INTERVAL_MS) + : undefined; + + if (playtimeRewardTimer) { + unrefTimer(playtimeRewardTimer); + } const router = createHttpRouter({ config, @@ -237,6 +261,7 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< presence, userSockets, economy, + playtimeRewards, }); }, message(ws, message) { @@ -254,6 +279,7 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< joinVersions, joinTargets, economy, + playtimeRewards, }); return; } @@ -273,7 +299,7 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< ); }, close(ws) { - handleClose(ws, rooms, publish, logger, metrics, presence, userSockets); + handleClose(ws, rooms, publish, logger, metrics, presence, userSockets, playtimeRewards); }, }, }); @@ -439,6 +465,9 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< logger.info("server.shutdown", { signal }); clearIntervalRef(botTimer); clearIntervalRef(rateLimiterPruneTimer); + if (playtimeRewardTimer) { + clearIntervalRef(playtimeRewardTimer); + } metrics.stopEventLoopMonitor(); await server.stop(true); processRef.exit(0); diff --git a/docs/overview.md b/docs/overview.md index 7f90891..4264df7 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,7 +13,7 @@ The current product loop is: 7. Click tiles to request server-authoritative movement. 8. Send and receive room chat messages. 9. If you own the room, place, move, rotate, and pick up room furniture bought from the catalogue. -10. Earn a starting balance of $500 and spend it on room creation and furniture. +10. Earn a starting balance of $500, then earn $500 for every hour of active play. 11. Buy furniture once, keep it in a persistent inventory, and place or pick it up freely in owned rooms. 12. See live balance and inventory updates across sessions. 13. See users leave the room when they disconnect or switch rooms. @@ -37,8 +37,8 @@ Implemented: - Friend-gated direct messages: realtime delivery, persistence, and history. - Public room browser with live room population counts, plus per-user private rooms and player-created rooms persisted to PostgreSQL. -- Server-authoritative economy: starting balance, room creation fees, furniture catalogue, - persistent inventory, and live balance/inventory updates. +- Server-authoritative economy: starting balance, hourly active-play rewards, room creation fees, + furniture catalogue, persistent inventory, and live balance/inventory updates. - Owner-only room furniture placement, movement, rotation, pickup, persistence, and snapshot delivery. - Scripted, server-authoritative room bots (movement and chat). diff --git a/docs/persistence.md b/docs/persistence.md index 3e76194..b995595 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -14,6 +14,7 @@ The current server has: Persist: - Users, including account balance in `dollars`. +- Active play reward progress (`user_playtime_rewards`) for hourly dollar awards. - Rooms. - Room layouts. - Room items. @@ -40,6 +41,21 @@ Live avatar position should remain server-authoritative in memory for now. - Represents the account's cash balance in whole dollars. - Updated atomically by the economy store with row-level locking (`for("update")`). +### `user_playtime_rewards` + +| Column | Type | Notes | +| --- | --- | --- | +| `user_id` | `text` | FK to `users.id`, `onDelete: cascade`. Primary key. | +| `accrued_active_ms` | `integer` | Active playtime carried toward the next hourly reward. | +| `last_activity_at` | `timestamp with time zone` | Last qualifying gameplay/social input seen by the server. | +| `last_accrued_at` | `timestamp with time zone` | Last timestamp through which active playtime has been accrued. | + +- The server awards `$500` for each full hour of active play. +- A qualifying input keeps the user active for up to five minutes; longer gaps accrue only the + five-minute active window. +- Multiple sockets for the same user count as one earning stream. +- Reward progress and `users.dollars` are updated in the same transaction. + ### `user_inventory` | Column | Type | Notes | @@ -71,7 +87,9 @@ When using Docker Compose, the `migrate` service runs migrations before the serv 3. Load persisted `room_items` for known public and private rooms. 4. Create or refresh a private room for each successfully authenticated user. 5. Persist owner-approved furniture placement, movement, rotation, pickup, and item state changes. -6. Keep realtime room membership, movement, and chat in memory. +6. Accrue active play rewards from accepted gameplay/social WebSocket messages and publish + `balance.updated` when an hourly reward is earned. +7. Keep realtime room membership, movement, and chat in memory. ## Constraints