diff --git a/apps/server/drizzle/0017_weekly_cocktail.sql b/apps/server/drizzle/0017_weekly_cocktail.sql new file mode 100644 index 00000000..8fb02f48 --- /dev/null +++ b/apps/server/drizzle/0017_weekly_cocktail.sql @@ -0,0 +1,9 @@ +-- Singleton row (id always = 1) persisting the weekly cocktail composition. +-- No row is seeded here -- the app defaults to the in-code SEED +-- (weekly-cocktail.ts) until the first admin rotation writes through. +CREATE TABLE "weekly_cocktail" ( + "id" integer PRIMARY KEY DEFAULT 1, + "name" varchar(40) NOT NULL, + "decks" text[] NOT NULL, + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); diff --git a/apps/server/drizzle/meta/0017_snapshot.json b/apps/server/drizzle/meta/0017_snapshot.json new file mode 100644 index 00000000..6b0f2d05 --- /dev/null +++ b/apps/server/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1465 @@ +{ + "id": "515f643a-54de-44b6-801c-ae8412e3337f", + "prevId": "32187f3e-938a-4759-87c9-5ce035534fa8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.action_logs": { + "name": "action_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "action_logs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "lobby_code": { + "name": "lobby_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_allowlist": { + "name": "chat_allowlist", + "schema": "", + "columns": { + "message": { + "name": "message", + "type": "varchar(200)", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_logs": { + "name": "chat_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chat_logs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "lobby_code": { + "name": "lobby_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_id": { + "name": "moderation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flagged": { + "name": "flagged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "moderation_verdict": { + "name": "moderation_verdict", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flagged_messages": { + "name": "flagged_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "flagged_messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matches": { + "name": "matches", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "flagged_at": { + "name": "flagged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_results": { + "name": "game_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "game_results_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "lobby_code": { + "name": "lobby_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "mod_id": { + "name": "mod_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "players": { + "name": "players", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_cache": { + "name": "leaderboard_cache", + "schema": "", + "columns": { + "mod_id": { + "name": "mod_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "game_mode": { + "name": "game_mode", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wins": { + "name": "wins", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "losses": { + "name": "losses", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "season_best": { + "name": "season_best", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lb_player_idx": { + "name": "lb_player_idx", + "columns": [ + { + "expression": "mod_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "leaderboard_cache_mod_id_game_mode_season_rank_pk": { + "name": "leaderboard_cache_mod_id_game_mode_season_rank_pk", + "columns": [ + "mod_id", + "game_mode", + "season", + "rank" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matchmaking_matches": { + "name": "matchmaking_matches", + "schema": "", + "columns": { + "match_id": { + "name": "match_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "lobby_code": { + "name": "lobby_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "mod_id": { + "name": "mod_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "game_mode": { + "name": "game_mode", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "players": { + "name": "players", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lobby_state": { + "name": "lobby_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "game_started_at": { + "name": "game_started_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "matchmaking_matches_lobby_code_unique": { + "name": "matchmaking_matches_lobby_code_unique", + "nullsNotDistinct": false, + "columns": [ + "lobby_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matchmaking_ratings": { + "name": "matchmaking_ratings", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mod_id": { + "name": "mod_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "game_mode": { + "name": "game_mode", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 600 + }, + "wins": { + "name": "wins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "losses": { + "name": "losses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "season_best": { + "name": "season_best", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "best_match_id": { + "name": "best_match_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "best_at": { + "name": "best_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_match_at": { + "name": "last_match_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decay_applied_at": { + "name": "decay_applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mmr_rating_idx": { + "name": "mmr_rating_idx", + "columns": [ + { + "expression": "mod_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matchmaking_ratings_player_id_players_id_fk": { + "name": "matchmaking_ratings_player_id_players_id_fk", + "tableFrom": "matchmaking_ratings", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "matchmaking_ratings_player_id_mod_id_game_mode_season_pk": { + "name": "matchmaking_ratings_player_id_mod_id_game_mode_season_pk", + "columns": [ + "player_id", + "mod_id", + "game_mode", + "season" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_branches": { + "name": "mod_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "mod_branches_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mod_branches_name_unique": { + "name": "mod_branches_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_release": { + "name": "mod_release", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "mod_release_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "smods_version": { + "name": "smods_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'latest'" + }, + "lovely_version": { + "name": "lovely_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'latest'" + }, + "branch_id": { + "name": "branch_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "mod_release_branch_id_mod_branches_id_fk": { + "name": "mod_release_branch_id_mod_branches_id_fk", + "tableFrom": "mod_release", + "tableTo": "mod_branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mod_versions": { + "name": "mod_versions", + "schema": "", + "columns": { + "mod_id": { + "name": "mod_id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'0.0.0'" + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_bans": { + "name": "player_bans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ban_type": { + "name": "ban_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "player_bans_player_idx": { + "name": "player_bans_player_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "player_bans_player_id_players_id_fk": { + "name": "player_bans_player_id_players_id_fk", + "tableFrom": "player_bans", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "steam_id_hash": { + "name": "steam_id_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_id_hash": { + "name": "discord_id_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "use_discord_name": { + "name": "use_discord_name", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferred_joker": { + "name": "preferred_joker", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'j_joker'" + }, + "privileges": { + "name": "privileges", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "steam_name": { + "name": "steam_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "chat_enabled": { + "name": "chat_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "chat_blocked": { + "name": "chat_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tos_accepted_version": { + "name": "tos_accepted_version", + "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": { + "players_steam_id_hash_idx": { + "name": "players_steam_id_hash_idx", + "columns": [ + { + "expression": "steam_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "steam_id_hash IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "players_discord_id_hash_idx": { + "name": "players_discord_id_hash_idx", + "columns": [ + { + "expression": "discord_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "discord_id_hash IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "refresh_tokens_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_idx": { + "name": "refresh_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_player_id_players_id_fk": { + "name": "refresh_tokens_player_id_players_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reported_lobby_messages": { + "name": "reported_lobby_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "reported_lobby_messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "lobby_id": { + "name": "lobby_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lobby_code": { + "name": "lobby_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "reports_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "lobby_id": { + "name": "lobby_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lobby_code": { + "name": "lobby_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_id": { + "name": "reported_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "tos_version": { + "name": "tos_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weekly_cocktail": { + "name": "weekly_cocktail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "decks": { + "name": "decks", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 7d4c3949..cd323149 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1789200000000, "tag": "0016_mod_releases", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1789300000000, + "tag": "0017_weekly_cocktail", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/features/draft/config.route.ts b/apps/server/src/features/draft/config.route.ts new file mode 100644 index 00000000..52aa8af6 --- /dev/null +++ b/apps/server/src/features/draft/config.route.ts @@ -0,0 +1,15 @@ +// Game-config endpoints, mounted at /api/config. Read-only, authenticated. + +import { Router } from 'express' +import { authenticate } from '../../middleware/authenticate.js' +import { getWeeklyCocktail } from './weekly-cocktail.js' + +const router = Router() + +router.use(authenticate) + +router.get('/weekly-cocktail', (_req, res) => { + res.status(200).json(getWeeklyCocktail()) +}) + +export default router diff --git a/apps/server/src/features/draft/draft-constants.ts b/apps/server/src/features/draft/draft-constants.ts new file mode 100644 index 00000000..3c484f5c --- /dev/null +++ b/apps/server/src/features/draft/draft-constants.ts @@ -0,0 +1,52 @@ +// Draft vocabulary: canonical Balatro/SMODS center keys and vanilla stake +// indices, shared as WIRE vocabulary with the Lua client (BalatroMultiplayer). +// Kills magic strings/numbers -- every draft module references this one +// source instead of re-typing literals. + +/** + * Every deck this feature can reference by key: the 15 vanilla SMODS center + * decks plus the MultiplayerPvP custom decks (draftable and cocktail-only). + */ +export const DECK = { + RED: 'b_red', + BLUE: 'b_blue', + YELLOW: 'b_yellow', + GREEN: 'b_green', + BLACK: 'b_black', + MAGIC: 'b_magic', + NEBULA: 'b_nebula', + GHOST: 'b_ghost', + ABANDONED: 'b_abandoned', + CHECKERED: 'b_checkered', + ZODIAC: 'b_zodiac', + PAINTED: 'b_painted', + ANAGLYPH: 'b_anaglyph', + PLASMA: 'b_plasma', + ERRATIC: 'b_erratic', + VIOLET: 'b_mp_violet', + ORANGE: 'b_mp_orange', + COCKTAIL: 'b_mp_cocktail', + INDIGO: 'b_mp_indigo', + GRADIENT: 'b_mp_gradient', + ORACLE: 'b_mp_oracle', + HEIDELBERG: 'b_mp_heidelberg', + ECHO: 'b_mp_echodeck', +} as const + +export type DeckKey = (typeof DECK)[keyof typeof DECK] + +/** + * Named vanilla SMODS stake indices (stable). Index-based, not a closed enum -- + * modded stakes take higher indices assigned at load and a policy may + * reference any by number; these names just kill magic numbers for vanilla. + */ +export const STAKE = { + WHITE: 1, + RED: 2, + GREEN: 3, + BLACK: 4, + BLUE: 5, + PURPLE: 6, + ORANGE: 7, + GOLD: 8, +} as const diff --git a/apps/server/src/features/draft/draft-policy.ts b/apps/server/src/features/draft/draft-policy.ts new file mode 100644 index 00000000..b93c5d98 --- /dev/null +++ b/apps/server/src/features/draft/draft-policy.ts @@ -0,0 +1,173 @@ +// Draft pool policy: per-queue configuration for the server-generated deck+stake +// draft (Botlatro parity -- see Botlatro-Multiplayer src/utils/TupleBans.ts). +// +// Keyed by the SAME (modId, gameMode) string the matchmaking queues use (ranked +// is encoded in gameMode: 'ranked:pvp_standard' vs 'pvp_standard' are distinct +// queues/policies). Everything here is SEED data, meant to move into a DB table +// so it's live-tunable like the bot's deck_mults/stake_mults/banned_decks. + +import { queueKey } from '../../state/matchmaking.js' +import { DECK, type DeckKey, STAKE } from './draft-constants.js' +import { generateDraftPool } from './generate-draft-pool.js' + +export interface DeckPolicy { + key: string + /** Roulette weight for draft tiles. 0 = never appears as its own tile. */ + draftWeight: number +} + +export interface StakePolicy { + /** SMODS stake index (see STAKE for the vanilla names; modded stakes are higher). */ + id: number + weight: number +} + +export interface DraftPolicy { + /** Number of items (deck+stake pairs) in a pool. */ + poolSize: number + /** Max occurrences of any one stake in a pool. */ + maxPerStake: number + /** Max occurrences of any one deck in a pool. */ + maxPerDeck: number + /** Minimum-occurrence guarantees per stake: each listed stake is forced to + * appear at least `min` times. The generator reserves slots so every + * guarantee is met. Empty = no guarantee. */ + guaranteedStakes: StakeGuarantee[] + decks: DeckPolicy[] + stakes: StakePolicy[] +} + +export interface StakeGuarantee { + stakeId: number + min: number +} + +const deck = (key: DeckKey, draftWeight: number): DeckPolicy => ({ + key, + draftWeight, +}) + +// The 15 vanilla decks, all draftable at weight 1. +const VANILLA_DECKS: DeckPolicy[] = [ + DECK.RED, + DECK.BLUE, + DECK.YELLOW, + DECK.GREEN, + DECK.BLACK, + DECK.MAGIC, + DECK.NEBULA, + DECK.GHOST, + DECK.ABANDONED, + DECK.CHECKERED, + DECK.ZODIAC, + DECK.PAINTED, + DECK.ANAGLYPH, + DECK.PLASMA, + DECK.ERRATIC, +].map((key) => deck(key, 1)) + +// MultiplayerPvP custom decks that are actually draftable: Violet, Orange, +// Cocktail. Indigo/Gradient/Oracle/Heidelberg/Echo used to sit here at +// draftWeight 0 just to carry cocktail-eligibility metadata; that now lives in +// COCKTAIL_ELIGIBLE_DECKS below, so they're dropped from this draftable-only list. +const PVP_CUSTOM_DECKS: DeckPolicy[] = [ + deck(DECK.VIOLET, 1), + deck(DECK.ORANGE, 1), + deck(DECK.COCKTAIL, 1), +] + +// Every deck may appear in a cocktail except the cocktail deck itself. +export const COCKTAIL_ELIGIBLE_DECKS: DeckKey[] = Object.values(DECK).filter( + (key) => key !== DECK.COCKTAIL, +) + +// 5 of the 8 vanilla stakes (Red/Blue/Orange excluded), equally weighted -- +// matches Botlatro's ranked set. Draftable stakes are per-policy; another +// queue can list a different subset. +const RANKED_DRAFTABLE_STAKES: StakePolicy[] = [ + { id: STAKE.WHITE, weight: 1 }, + { id: STAKE.GREEN, weight: 1 }, + { id: STAKE.BLACK, weight: 1 }, + { id: STAKE.PURPLE, weight: 1 }, + { id: STAKE.GOLD, weight: 1 }, +] + +const PVP_RANKED_POLICY: DraftPolicy = { + poolSize: 9, + maxPerStake: 4, + maxPerDeck: 3, + guaranteedStakes: [{ stakeId: STAKE.WHITE, min: 1 }], + decks: [...VANILLA_DECKS, ...PVP_CUSTOM_DECKS], + stakes: RANKED_DRAFTABLE_STAKES, +} + +const PVP_MOD_ID = 'MultiplayerPvP' +const PVP_GAME_MODES = [ + 'pvp_standard', + 'pvp_expanded', + 'pvp_vanilla', + 'pvp_smallworld', +] + +// Deep-copy so each row is independently tunable -- shared references would +// make "re-tune ranked without touching the default" silently impossible. +function clonePolicy(policy: DraftPolicy): DraftPolicy { + return { + ...policy, + decks: policy.decks.map((d) => ({ ...d })), + stakes: policy.stakes.map((s) => ({ ...s })), + } +} + +// Seed rows. Every MultiplayerPvP queue draws the mod-wide default; explicit +// ranked rows exist so ranked can be re-tuned independently later. A queue +// diverges by getting its own row, never by losing server-driven drafts. +const policies = new Map() +policies.set(queueKey(PVP_MOD_ID, '*'), clonePolicy(PVP_RANKED_POLICY)) +for (const mode of PVP_GAME_MODES) { + policies.set( + queueKey(PVP_MOD_ID, `ranked:${mode}`), + clonePolicy(PVP_RANKED_POLICY), + ) +} + +/** + * Policy lookup with fallthrough: exact queue key, then the mod-wide default + * (key ':*'), then undefined -- meaning "this queue is not server-driven; + * the client generates its own pool" (the mod-side fallback path). + */ +export function getDraftPolicy( + modId: string, + gameMode: string, +): DraftPolicy | undefined { + return ( + policies.get(queueKey(modId, gameMode)) ?? + policies.get(queueKey(modId, '*')) + ) +} + +// Deterministic PRNG (mulberry32) purely for the startup self-check below -- +// its output never reaches a real draft, so any fixed seed is fine. +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * Boot-time self-check: generates one pool per distinct policy row with a + * fixed rng. generateDraftPool already retries internally on a greedy + * dead-end, so this only throws for a genuinely infeasible policy -- + * crashing boot loudly instead of surfacing as a per-request failure later. + */ +export function validateDraftPolicies(): void { + const distinct = new Set(policies.values()) + for (const policy of distinct) { + generateDraftPool(policy, mulberry32(1)) + } +} diff --git a/apps/server/src/features/draft/draft.repository.ts b/apps/server/src/features/draft/draft.repository.ts new file mode 100644 index 00000000..8258dc97 --- /dev/null +++ b/apps/server/src/features/draft/draft.repository.ts @@ -0,0 +1,71 @@ +// Draft persistence seam: per-match issued POOLS (the draft tuples offered to +// a match). The service depends on the `DraftRepository` interface, not a +// concrete store, so the feature is testable via the in-memory fake (real +// fakes over interaction mocks). +// +// Every method is `async` even though this impl is fully synchronous -- the +// interface is the seam for a future Drizzle-backed store (a draft_pools +// table, deferred until the pending moderation schema/migration lands) that +// awaits for real. The service must never change when that swap happens. + +import type { DraftTuple } from './generate-draft-pool.js' + +// --- the contract (what the service depends on) --- + +export interface DraftRepository { + getPool(matchId: string): Promise + savePool(matchId: string, pool: DraftTuple[]): Promise +} + +// --- in-memory implementation --- + +const DAY_MS = 24 * 60 * 60 * 1000 // default eviction TTL + +export interface InMemoryDraftRepositoryOptions { + /** Entries untouched (written) for longer than this are evicted. */ + ttlMs?: number + /** Injectable ms-epoch clock so eviction is testable with a fake clock. */ + now?: () => number +} + +interface PoolEntry { + pool: DraftTuple[] + touchedAt: number +} + +export class InMemoryDraftRepository implements DraftRepository { + private pools = new Map() + + // --- TTL eviction --- + private readonly ttlMs: number + private readonly now: () => number + + private isExpired(touchedAt: number): boolean { + return this.now() - touchedAt > this.ttlMs + } + + // A draft lives minutes; the TTL only bounds memory over the process + // lifetime. Sweeping on writes (no timer) suffices: a quiet server holds at + // most the last day's matches, an active one sweeps constantly. + private sweep(): void { + for (const [matchId, entry] of this.pools) + if (this.isExpired(entry.touchedAt)) this.pools.delete(matchId) + } + // --- end TTL eviction --- + + constructor(opts: InMemoryDraftRepositoryOptions = {}) { + this.ttlMs = opts.ttlMs ?? DAY_MS + this.now = opts.now ?? Date.now + } + + async getPool(matchId: string): Promise { + const entry = this.pools.get(matchId) + if (!entry || this.isExpired(entry.touchedAt)) return undefined + return entry.pool + } + + async savePool(matchId: string, pool: DraftTuple[]): Promise { + this.sweep() + this.pools.set(matchId, { pool, touchedAt: this.now() }) + } +} diff --git a/apps/server/src/features/draft/draft.request.ts b/apps/server/src/features/draft/draft.request.ts new file mode 100644 index 00000000..e23b560a --- /dev/null +++ b/apps/server/src/features/draft/draft.request.ts @@ -0,0 +1,20 @@ +// Pure HTTP-input parsing for the draft feature: no req/res, no I/O -- takes +// plain values, returns typed data or throws ValidationError. Messages are +// technical (matchmaking.route.ts convention): these guards are unreachable by +// a normal client, defense-in-depth against a buggy/malicious one. Pure parsing +// keeps every rule unit-testable; the route handler stays parse -> service -> respond. + +import { ValidationError } from '../../shared/utils/errors.js' + +// matchId is an opaque route param, not user-composed text -- the bound +// rejects garbage input, not a real length limit. +const MAX_MATCH_ID_LENGTH = 128 + +export function parseMatchIdParam(params: { matchId?: unknown }): string { + const { matchId } = params + if (typeof matchId !== 'string' || matchId.trim().length === 0) + throw new ValidationError('matchId is required') + if (matchId.length > MAX_MATCH_ID_LENGTH) + throw new ValidationError('matchId is invalid') + return matchId +} diff --git a/apps/server/src/features/draft/draft.route.ts b/apps/server/src/features/draft/draft.route.ts new file mode 100644 index 00000000..48b6f5c9 --- /dev/null +++ b/apps/server/src/features/draft/draft.route.ts @@ -0,0 +1,42 @@ +// Draft endpoints, mounted at /api/matches: +// POST /:matchId/draft-pool -> issue (or re-fetch) the server-generated pool +// Opt-in: a mod that never calls this keeps a fully client-side draft, no server state. + +import { Router } from 'express' +import { authenticate } from '../../middleware/authenticate.js' +import { matches } from '../../state/matchmaking.js' +import { getDraftPolicy } from './draft-policy.js' +import { InMemoryDraftRepository } from './draft.repository.js' +import { parseMatchIdParam } from './draft.request.js' +import { makeDraftService } from './draft.service.js' + +const service = makeDraftService({ + repo: new InMemoryDraftRepository(), + getMatch: (matchId) => { + const match = matches.get(matchId) + if (!match) return undefined + return { + matchId: match.matchId, + modId: match.modId, + gameMode: match.gameMode, + playerIds: match.playerIds, + } + }, + getPolicy: getDraftPolicy, +}) + +const router = Router() + +router.use(authenticate) + +router.post('/:matchId/draft-pool', async (req, res, next) => { + try { + const matchId = parseMatchIdParam(req.params) + const result = await service.issueDraftPool(matchId, req.player!.playerId) + res.status(200).json(result) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/apps/server/src/features/draft/draft.service.ts b/apps/server/src/features/draft/draft.service.ts new file mode 100644 index 00000000..8844dcbc --- /dev/null +++ b/apps/server/src/features/draft/draft.service.ts @@ -0,0 +1,102 @@ +// Draft service: issues server-generated draft pools, idempotently. + +import { ForbiddenError, NotFoundError } from '../../shared/utils/errors.js' +import { DECK } from './draft-constants.js' +import type { DraftPolicy } from './draft-policy.js' +import type { DraftRepository } from './draft.repository.js' +import { + type DraftTuple, + type Rng, + generateDraftPool, +} from './generate-draft-pool.js' +import { getWeeklyCocktail } from './weekly-cocktail.js' + +// Self-describing items: stamp this week's composition onto any cocktail +// tuple so the pool response carries it, no separate weekly-config fetch. +// Client appends its own "Cocktail" wording to the bare name. +function attachWeeklyCocktail(pool: DraftTuple[]): void { + const weekly = getWeeklyCocktail() + for (const tuple of pool) { + if (tuple.key === DECK.COCKTAIL) { + tuple.decks = [...weekly.decks] + tuple.name = weekly.name + } + } +} + +export interface DraftMatchInfo { + matchId: string + modId: string + gameMode: string + playerIds: string[] +} + +export interface DraftServiceDeps { + repo: DraftRepository + getMatch: (matchId: string) => DraftMatchInfo | undefined + getPolicy: (modId: string, gameMode: string) => DraftPolicy | undefined + rng?: Rng + log?: Pick +} + +export function makeDraftService(deps: DraftServiceDeps) { + const rng: Rng = deps.rng ?? Math.random + const log = deps.log ?? console + + // Single-flight per match: with an async repo, check-then-set (getPool -> + // generate -> savePool) spans awaits, so two concurrent first calls could + // both roll. Setting the map entry in the same task as the miss check makes + // the roll unique within this process; cross-process uniqueness is the + // future Drizzle impl's job (insert-if-absent returning the winner). + const inFlightRolls = new Map>() + + function requireParticipant( + matchId: string, + playerId: string, + ): DraftMatchInfo { + const match = deps.getMatch(matchId) + if (!match) throw new NotFoundError('Match not found') + if (!match.playerIds.includes(playerId)) + throw new ForbiddenError('Not a participant of this match') + return match + } + + return { + // Idempotent: FIRST call rolls and persists; every later call (transport + // retry, host reconnect) returns the identical pool. 404s when the queue + // has no policy -- the client's signal to fall back to its own generation. + async issueDraftPool( + matchId: string, + playerId: string, + ): Promise<{ pool: DraftTuple[]; reused: boolean }> { + const match = requireParticipant(matchId, playerId) + + const existing = await deps.repo.getPool(matchId) + if (existing) return { pool: existing, reused: true } + + const inFlight = inFlightRolls.get(matchId) + if (inFlight) return { pool: await inFlight, reused: true } + + const roll = (async () => { + const policy = deps.getPolicy(match.modId, match.gameMode) + if (!policy) throw new NotFoundError('No draft policy for this queue') + + const pool = generateDraftPool(policy, rng) + attachWeeklyCocktail(pool) + await deps.repo.savePool(matchId, pool) + log.log( + `[draft] issued pool for match ${matchId} (${match.modId}:${match.gameMode}) -- ${pool.length} tuples`, + ) + return pool + })() + inFlightRolls.set(matchId, roll) + try { + return { pool: await roll, reused: false } + } finally { + inFlightRolls.delete(matchId) + } + }, + } +} + +export type DraftService = ReturnType diff --git a/apps/server/src/features/draft/generate-draft-pool.ts b/apps/server/src/features/draft/generate-draft-pool.ts new file mode 100644 index 00000000..e8857af2 --- /dev/null +++ b/apps/server/src/features/draft/generate-draft-pool.ts @@ -0,0 +1,201 @@ +// Pure draft-pool generator: a faithful port of Botlatro's TupleBans generation +// (Botlatro-Multiplayer src/utils/TupleBans.ts), minus the Discord/emote plumbing. +// +// Efraimidis-Spirakis A-Res weighted sampling without replacement: each (deck, +// stake) pair gets priority rng()^(1/pairWeight); sorting descending yields a +// weighted-random permutation in one pass, no rejection loop. Weight 1 +// degrades to a plain uniform shuffle; pairWeight = deckWeight * stakeWeight +// reproduces independent stake- and deck-weighted picks. +// +// Invariants (enforced + property-tested): exactly poolSize tuples; every deck +// draftWeight > 0 and stake weight > 0; no duplicate pairs; no stake/deck over +// its maxPerStake/maxPerDeck cap; if guaranteedStakeId is set, a pre-pass +// reserves that stake's highest-priority candidates first, up to its minimum. +// +// Deviation from the bot: instead of per-slot stake-then-deck rolls with up to +// 100 retries, this ranks every candidate pair once per attempt and walks the +// ranking twice (guarantees, then fill); a dead-end re-ranks with fresh rng +// draws (MAX_GENERATION_ATTEMPTS) rather than corrupting or producing a +// stake-less tuple. Only failure mode is a loud throw -- up front +// (assertFeasible) or after exhausting every retry. + +import type { DraftPolicy } from './draft-policy.js' + +export interface DraftTuple { + key: string + stake: number + /** Composite deck (cocktail) only: the sub-composition it merges. */ + decks?: string[] + /** Composite deck only: bare display title (the client appends its own + * localized "Cocktail" suffix). */ + name?: string +} + +/** Random source in [0, 1). Injected so generation is deterministic in tests. */ +export type Rng = () => number + +interface StakePolicyLike { + id: number + weight: number +} + +interface Candidate { + key: string + stake: number + priority: number +} + +function assertFeasible( + policy: DraftPolicy, + deckCount: number, + stakes: StakePolicyLike[], +): void { + if (deckCount === 0) throw new Error('draft policy has no draftable decks') + if (stakes.length === 0) throw new Error('draft policy has no stakes') + if (policy.poolSize > deckCount * policy.maxPerDeck) + throw new Error('draft policy infeasible: poolSize exceeds deck capacity') + if (policy.poolSize > stakes.length * policy.maxPerStake) + throw new Error('draft policy infeasible: poolSize exceeds stake capacity') + if (policy.poolSize > deckCount * stakes.length) + throw new Error( + 'draft policy infeasible: poolSize exceeds distinct pair capacity', + ) + // Each guarantee must target a rollable stake, fit its cap, and all guarantees + // together must fit the pool -- else one goes silently unmet or forces an excluded stake. + let guaranteedTotal = 0 + for (const g of policy.guaranteedStakes) { + if (!stakes.some((st) => st.id === g.stakeId)) + throw new Error( + `draft policy infeasible: guaranteed stake ${g.stakeId} is not rollable`, + ) + if (g.min > policy.maxPerStake) + throw new Error( + `draft policy infeasible: guaranteed stake ${g.stakeId} min ${g.min} exceeds per-stake cap`, + ) + guaranteedTotal += g.min + } + if (guaranteedTotal > policy.poolSize) + throw new Error( + 'draft policy infeasible: guaranteed minimums exceed poolSize', + ) +} + +/** + * Ranks every (deck, stake) pair by Efraimidis-Spirakis priority (see module + * comment). FIXED iteration order (deck outer, stake inner) so exactly + * decks.length * stakes.length rng() draws happen in the same order + * regardless of outcome -- required for a seed to reproduce an identical pool. + */ +function rankCandidates( + decks: { key: string; weight: number }[], + stakes: StakePolicyLike[], + rng: Rng, +): Candidate[] { + const candidates: Candidate[] = [] + for (const d of decks) { + for (const s of stakes) { + const priority = rng() ** (1 / (d.weight * s.weight)) + candidates.push({ key: d.key, stake: s.id, priority }) + } + } + return candidates.sort((a, b) => b.priority - a.priority) +} + +/** + * Greedy fill over two independent caps (per-stake, per-deck) is a + * matroid-intersection problem: assertFeasible's checks are necessary but not + * sufficient, so an unlucky ranking can strand a tight policy short of + * poolSize even though some other selection would work. Returns null on a + * dead-end (never a partial/corrupt pool) so the caller can retry with a + * fresh ranking -- see generateDraftPool's retry loop. + */ +function selectPool( + candidates: Candidate[], + policy: DraftPolicy, + guarantees: DraftPolicy['guaranteedStakes'], + maxPerStake: number, +): DraftTuple[] | null { + const pool: DraftTuple[] = [] + const stakeCounts = new Map() + const deckCounts = new Map() + const taken = new Set() + + const stakeCount = (id: number) => stakeCounts.get(id) ?? 0 + const deckCount = (key: string) => deckCounts.get(key) ?? 0 + + const take = (c: Candidate): void => { + pool.push({ key: c.key, stake: c.stake }) + stakeCounts.set(c.stake, stakeCount(c.stake) + 1) + deckCounts.set(c.key, deckCount(c.key) + 1) + taken.add(c) + } + + // Guarantee pre-pass: take each guaranteed stake's highest-priority eligible + // candidates first, up to its minimum, before the general fill runs. + for (const g of guarantees) { + for (const c of candidates) { + if (stakeCount(g.stakeId) >= g.min) break + if (taken.has(c) || c.stake !== g.stakeId) continue + if (deckCount(c.key) >= policy.maxPerDeck) continue + take(c) + } + } + + // Fill: walk the ranking once more, taking any pair still fitting both caps. + for (const c of candidates) { + if (pool.length >= policy.poolSize) break + if (taken.has(c)) continue + if ( + stakeCount(c.stake) >= maxPerStake || + deckCount(c.key) >= policy.maxPerDeck + ) + continue + take(c) + } + + if (pool.length < policy.poolSize) return null + + return pool +} + +/** + * A dead-end retries with a fresh ranking rather than failing on unlucky + * candidate order; a valid pool is found almost immediately in practice (a + * real policy succeeds on attempt 0). A persistent failure across all + * attempts means a genuinely misconfigured policy -- caught by + * validateDraftPolicies() at startup, never as a per-request failure. + */ +const MAX_GENERATION_ATTEMPTS = 100 + +export function generateDraftPool(policy: DraftPolicy, rng: Rng): DraftTuple[] { + const decks = policy.decks + .filter((d) => d.draftWeight > 0) + .map((d) => ({ key: d.key, weight: d.draftWeight })) + const stakes = policy.stakes.filter((s) => s.weight > 0) + const maxPerStake = policy.maxPerStake + // A guarantee only survives if its stake is still rollable (weight > 0); + // a weight-0 guarantee degrades to none rather than forcing an excluded stake. + const guarantees = policy.guaranteedStakes.filter((g) => + stakes.some((st) => st.id === g.stakeId), + ) + assertFeasible( + { ...policy, guaranteedStakes: guarantees }, + decks.length, + stakes, + ) + + for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt++) { + const candidates = rankCandidates(decks, stakes, rng) + const pool = selectPool(candidates, policy, guarantees, maxPerStake) + if (pool) { + // Stable display order, mirroring the bot's orderTupleBans (stake, then deck). + return pool.sort( + (a, b) => a.stake - b.stake || a.key.localeCompare(b.key), + ) + } + } + + throw new Error( + `draft pool generation failed after ${MAX_GENERATION_ATTEMPTS} attempts -- policy appears infeasible`, + ) +} diff --git a/apps/server/src/features/draft/weekly-cocktail-admin.route.ts b/apps/server/src/features/draft/weekly-cocktail-admin.route.ts new file mode 100644 index 00000000..ae8bf678 --- /dev/null +++ b/apps/server/src/features/draft/weekly-cocktail-admin.route.ts @@ -0,0 +1,38 @@ +// Weekly-cocktail admin endpoints (draft feature, mounted under /admin so the +// public path is unchanged). Rotation applies at the next match start, no +// redeploy. Auth: shared x-admin-secret header, same as other /admin routes. +// GET /admin/weekly-cocktail +// PUT /admin/weekly-cocktail { "name": "Casjb", "decks": ["b_green", ...] } + +import { type Request, Router } from 'express' +import { env } from '../../env.js' +import { AppError } from '../../shared/utils/errors.js' +import { getWeeklyCocktail, setWeeklyCocktail } from './weekly-cocktail.js' + +const router = Router() + +// Inline (not middleware) to mirror the other /admin handlers' auth style. +function assertAdmin(req: Request): void { + if (req.headers['x-admin-secret'] !== env.ADMIN_SECRET) + throw new AppError('Unauthorized', 401) +} + +router.get('/weekly-cocktail', (req, res, next) => { + try { + assertAdmin(req) + res.json(getWeeklyCocktail()) + } catch (err) { + next(err) + } +}) + +router.put('/weekly-cocktail', async (req, res, next) => { + try { + assertAdmin(req) + res.json(await setWeeklyCocktail(req.body)) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/apps/server/src/features/draft/weekly-cocktail.ts b/apps/server/src/features/draft/weekly-cocktail.ts new file mode 100644 index 00000000..0f2af076 --- /dev/null +++ b/apps/server/src/features/draft/weekly-cocktail.ts @@ -0,0 +1,112 @@ +// Weekly cocktail: the community-curated composition the Cocktail deck uses +// this week. Rotated via the admin endpoint (PUT /admin/weekly-cocktail) -- +// no deploy needed. +// +// Persistence: boot loads the single-row `weekly_cocktail` table via +// `loadWeeklyCocktail()` (called from main.ts, mirroring loadConfigFromDb), +// defaulting to the in-code SEED when no row exists yet. Rotations +// write-through to the DB (`setWeeklyCocktail`) to survive a restart; the +// in-memory cache (`current`) keeps `getWeeklyCocktail()` synchronous for +// inline readers like draft.service.ts. +// +// `name` excludes the word "Cocktail" -- the client appends its own localized +// suffix (k_cocktail_suffix), so "Casjb" renders as "Casjb Cocktail" in +// English and translates elsewhere. + +import { ValidationError } from '../../shared/utils/errors.js' +import { + loadWeeklyCocktailFromDb, + saveWeeklyCocktailToDb, +} from '../../infrastructure/gateways/weekly-cocktail.gateway.js' +import { COCKTAIL_ELIGIBLE_DECKS } from './draft-policy.js' + +// O(1) membership check against the explicit allowlist (draft-policy.ts) -- +// rebuilt once at module load, not per call. +const COCKTAIL_ELIGIBLE_SET = new Set(COCKTAIL_ELIGIBLE_DECKS) + +export interface WeeklyCocktail { + /** Short display title, WITHOUT the "Cocktail" suffix (client-localized). */ + name: string + /** The forced composition (the Cocktail deck merges exactly these). */ + decks: string[] +} + +const SEED: WeeklyCocktail = { + name: 'Casjb', + decks: ['b_green', 'b_black', 'b_mp_orange'], +} + +let current: WeeklyCocktail = { ...SEED, decks: [...SEED.decks] } + +export function getWeeklyCocktail(): WeeklyCocktail { + return current +} + +/** + * Validate a rotation candidate: every deck must be on COCKTAIL_ELIGIBLE_DECKS + * (no smuggling in an excluded deck) and 1-3 decks (the Cocktail merges at + * most 3 effects). Pure -- throws ValidationError on violation, else returns + * the normalized WeeklyCocktail. + */ +export function validateWeeklyCocktail(input: unknown): WeeklyCocktail { + const { name, decks } = (input ?? {}) as { name?: unknown; decks?: unknown } + if (typeof name !== 'string' || name.trim().length === 0 || name.length > 40) + throw new ValidationError('name must be a non-empty string (max 40 chars)') + if (!Array.isArray(decks) || decks.length < 1 || decks.length > 3) + throw new ValidationError('decks must be an array of 1-3 deck keys') + + for (const key of decks) { + if (typeof key !== 'string' || key.length === 0) + throw new ValidationError('every deck must be a non-empty key string') + if (!COCKTAIL_ELIGIBLE_SET.has(key)) + throw new ValidationError(`deck '${key}' is not cocktail-eligible`) + } + if (new Set(decks).size !== decks.length) + throw new ValidationError('decks must be distinct') + + return { name: name.trim(), decks: [...(decks as string[])] } +} + +/** + * Boot-load the weekly cocktail from the DB, defaulting to SEED when no row + * exists yet. Default `fetch` is the real gateway; tests inject a fake. + */ +export async function loadWeeklyCocktail( + fetch: () => Promise<{ + name: string + decks: string[] + } | null> = loadWeeklyCocktailFromDb, +): Promise { + try { + const row = await fetch() + current = row + ? { name: row.name, decks: [...row.decks] } + : { ...SEED, decks: [...SEED.decks] } + } catch (err) { + // A cocktail DB problem (e.g. table missing pre-migration) must never take + // down the server -- degrade gracefully: fall back to SEED and log. + console.warn( + '[draft] weekly cocktail DB load failed, using SEED default:', + err instanceof Error ? err.message : err, + ) + current = { ...SEED, decks: [...SEED.decks] } + } +} + +/** + * Rotate the weekly cocktail: validate, update the in-memory cache, then + * write through to the DB. Default `save` is the real gateway; tests inject a + * fake. + */ +export async function setWeeklyCocktail( + input: unknown, + save: (wc: WeeklyCocktail) => Promise = saveWeeklyCocktailToDb, +): Promise { + const wc = validateWeeklyCocktail(input) + current = wc + await save(wc) + console.log( + `[draft] weekly cocktail rotated: ${current.name} -- ${current.decks.join(', ')}`, + ) + return current +} diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts index 1bb712ce..fb9a0d2c 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -109,6 +109,17 @@ export const serverConfig = pgTable('server_config', { .defaultNow(), }) +// Singleton row (id = 1) holding the weekly cocktail composition. Defaults to +// the in-code SEED (weekly-cocktail.ts) when no row exists yet. +export const weeklyCocktail = pgTable('weekly_cocktail', { + id: integer('id').primaryKey().default(1), + name: varchar('name', { length: 40 }).notNull(), + decks: text('decks').array().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}) + // One row per official mod — version string and download URL. export const modVersions = pgTable('mod_versions', { modId: varchar('mod_id', { length: 64 }).primaryKey(), diff --git a/apps/server/src/infrastructure/gateways/weekly-cocktail.gateway.ts b/apps/server/src/infrastructure/gateways/weekly-cocktail.gateway.ts new file mode 100644 index 00000000..89cec3af --- /dev/null +++ b/apps/server/src/infrastructure/gateways/weekly-cocktail.gateway.ts @@ -0,0 +1,25 @@ +import { db } from '../db/index.js' +import { weeklyCocktail } from '../db/schema.js' + +export async function loadWeeklyCocktailFromDb(): Promise<{ + name: string + decks: string[] +} | null> { + const row = await db.query.weeklyCocktail.findFirst() + if (!row) return null + return { name: row.name, decks: row.decks } +} + +export async function saveWeeklyCocktailToDb(wc: { + name: string + decks: string[] +}): Promise { + const updatedAt = new Date() + await db + .insert(weeklyCocktail) + .values({ id: 1, name: wc.name, decks: wc.decks, updatedAt }) + .onConflictDoUpdate({ + target: weeklyCocktail.id, + set: { name: wc.name, decks: wc.decks, updatedAt }, + }) +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 04487b4a..de06f117 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,6 +13,8 @@ import { provisionEmqxWebhook } from './infrastructure/emqx/emqx-provision.servi import { mqttService } from './infrastructure/mqtt/mqtt.service.js' import { startSessionCleanup, stopSessionCleanup } from './state/index.js' import { loadConfigFromDb } from './infrastructure/gateways/config.gateway.js' +import { loadWeeklyCocktail } from './features/draft/weekly-cocktail.js' +import { validateDraftPolicies } from './features/draft/draft-policy.js' const app = express() @@ -64,6 +66,8 @@ type PrivateModule = { registerPrivate: (app: Express) => Promise } async function start() { try { await loadConfigFromDb() + await loadWeeklyCocktail() + validateDraftPolicies() // Load private features if available (not present in public builds) const privatePath: string = '@v-rtualized/bmp-internal' diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 849f64b3..03291e0d 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -11,6 +11,9 @@ import { createAuthRouter } from '../features/auth/auth.route.js' import { createLobbyRouter } from '../features/lobby/lobby.route.js' import { createMatchmakingRouter } from '../features/matchmaking/matchmaking.route.js' import adminRouter from '../features/admin/admin.route.js' +import configRouter from '../features/draft/config.route.js' +import draftRouter from '../features/draft/draft.route.js' +import weeklyCocktailAdminRouter from '../features/draft/weekly-cocktail-admin.route.js' import emqxRouter from '../features/emqx/emqx.route.js' import releasesRouter from '../features/releases/releases.route.js' import statsRouter from '../features/stats/stats.route.js' @@ -36,12 +39,15 @@ export const lobbyService = createLobbyService({ const router = Router() router.use('/api/auth', createAuthRouter(authService)) +router.use('/api/config', configRouter) router.use('/api/lobbies', createLobbyRouter(lobbyService)) +router.use('/api/matches', draftRouter) router.use('/api/matchmaking', createMatchmakingRouter(matchmakingService)) router.use('/api/stats', statsRouter) router.use('/api/releases', releasesRouter) router.use('/api/webadmin', webadminRouter) router.use('/emqx', emqxRouter) router.use('/admin', adminRouter) +router.use('/admin', weeklyCocktailAdminRouter) export default router diff --git a/apps/server/src/shared/utils/errors.ts b/apps/server/src/shared/utils/errors.ts index 332bfd37..2a35c81b 100644 --- a/apps/server/src/shared/utils/errors.ts +++ b/apps/server/src/shared/utils/errors.ts @@ -7,3 +7,24 @@ export class AppError extends Error { this.name = 'AppError' } } + +export class ValidationError extends AppError { + constructor(message: string) { + super(message, 400) + this.name = 'ValidationError' + } +} + +export class NotFoundError extends AppError { + constructor(message: string) { + super(message, 404) + this.name = 'NotFoundError' + } +} + +export class ForbiddenError extends AppError { + constructor(message: string) { + super(message, 403) + this.name = 'ForbiddenError' + } +} diff --git a/apps/server/src/tests/routes/draft.test.ts b/apps/server/src/tests/routes/draft.test.ts new file mode 100644 index 00000000..0fbe112e --- /dev/null +++ b/apps/server/src/tests/routes/draft.test.ts @@ -0,0 +1,185 @@ +// Draft route tests: route -> validation -> service -> response wiring for the +// server-generated draft pool, plus the weekly-cocktail admin endpoints. The +// pure cores (generate-draft-pool, draft.request parsers, weekly-cocktail's +// validateWeeklyCocktail) are already covered by +// src/tests/services/draft*.test.ts -- this file only exercises the wiring and +// a couple of representative validation cases per endpoint. +// +// No real test DB: setup.ts mocks `infrastructure/db/index.js` with a minimal +// `db.insert().values()` chain that does NOT support `.onConflictDoUpdate`, the +// call weekly-cocktail.gateway.ts's `saveWeeklyCocktailToDb` makes. So the PUT +// /admin/weekly-cocktail HAPPY PATH (valid rotation, which write-throughs to the +// DB via the real gateway -- the route never injects a fake) is intentionally +// OMITTED here to avoid depending on real DB behavior; it is covered by +// e2e/DB-integration tests instead. The INVALID-body PUT case is safe to test +// here because `validateWeeklyCocktail` throws before any DB call is made. + +import { describe, expect, it } from 'vitest' +import request from 'supertest' +import { createTestApp } from './app.js' +import { signJwt } from '../../features/auth/jwt.js' +import { createSession } from '../../state/index.js' +import { matches } from '../../state/matchmaking.js' +import type { Match } from '../../shared/types/index.js' + +const app = createTestApp() +const ADMIN_SECRET = 'test-admin-secret' // matches setup.ts's env.ADMIN_SECRET + +function authHeader(playerId: string, steamName: string) { + createSession(steamName, { id: playerId }) + const token = signJwt({ playerId, steamName }) + return `Bearer ${token}` +} + +// MultiplayerPvP queues resolve a draft policy via the mod-wide default +// (see draft-policy.ts); any other modId has no policy registered at all. +function makePolicedMatch(matchId: string, playerIds: string[]): Match { + const match: Match = { + matchId, + lobbyCode: `LOBBY-${matchId}`, + modId: 'MultiplayerPvP', + gameMode: 'pvp_standard', + playerIds, + createdAt: new Date(), + } + matches.set(matchId, match) + return match +} + +function makeUnpolicedMatch(matchId: string, playerIds: string[]): Match { + const match: Match = { + matchId, + lobbyCode: `LOBBY-${matchId}`, + modId: 'mod1', + gameMode: 'mode1', + playerIds, + createdAt: new Date(), + } + matches.set(matchId, match) + return match +} + +describe('draft routes', () => { + describe('POST /api/matches/:matchId/draft-pool', () => { + it('returns 401 without auth', async () => { + makePolicedMatch('dp-noauth', ['p1', 'p2']) + const res = await request(app).post('/api/matches/dp-noauth/draft-pool') + expect(res.status).toBe(401) + }) + + it('returns 401 with an invalid token', async () => { + makePolicedMatch('dp-badauth', ['p1', 'p2']) + const res = await request(app) + .post('/api/matches/dp-badauth/draft-pool') + .set('Authorization', 'Bearer not.a.valid.token') + expect(res.status).toBe(401) + }) + + it('returns 200 with a full-size pool for a participant on a policied queue', async () => { + makePolicedMatch('dp-1', ['p1', 'p2']) + const res = await request(app) + .post('/api/matches/dp-1/draft-pool') + .set('Authorization', authHeader('p1', 'Alice')) + .send({}) + + expect(res.status).toBe(200) + expect(res.body.reused).toBe(false) + expect(Array.isArray(res.body.pool)).toBe(true) + expect(res.body.pool).toHaveLength(9) + }) + + it('returns the identical pool on a second call (idempotent)', async () => { + makePolicedMatch('dp-2', ['p1', 'p2']) + const auth = authHeader('p1', 'Alice') + + const first = await request(app) + .post('/api/matches/dp-2/draft-pool') + .set('Authorization', auth) + .send({}) + const second = await request(app) + .post('/api/matches/dp-2/draft-pool') + .set('Authorization', auth) + .send({}) + + expect(first.status).toBe(200) + expect(second.status).toBe(200) + expect(second.body.reused).toBe(true) + expect(second.body.pool).toEqual(first.body.pool) + }) + + it('returns 404 no_draft_policy when the match queue has no policy', async () => { + makeUnpolicedMatch('dp-3', ['p1', 'p2']) + const res = await request(app) + .post('/api/matches/dp-3/draft-pool') + .set('Authorization', authHeader('p1', 'Alice')) + .send({}) + + expect(res.status).toBe(404) + }) + + it('returns 403 not_a_participant for a non-participant player', async () => { + makePolicedMatch('dp-4', ['p1', 'p2']) + const res = await request(app) + .post('/api/matches/dp-4/draft-pool') + .set('Authorization', authHeader('outsider', 'Eve')) + .send({}) + + expect(res.status).toBe(403) + }) + }) + + describe('GET /admin/weekly-cocktail', () => { + it('returns 401 when x-admin-secret is missing', async () => { + const res = await request(app).get('/admin/weekly-cocktail') + expect(res.status).toBe(401) + }) + + it('returns 401 when x-admin-secret is wrong', async () => { + const res = await request(app) + .get('/admin/weekly-cocktail') + .set('x-admin-secret', 'wrong-secret') + expect(res.status).toBe(401) + }) + + it('returns 200 with the current in-memory cocktail when the secret is correct', async () => { + const res = await request(app) + .get('/admin/weekly-cocktail') + .set('x-admin-secret', ADMIN_SECRET) + + expect(res.status).toBe(200) + expect(typeof res.body.name).toBe('string') + expect(Array.isArray(res.body.decks)).toBe(true) + }) + }) + + describe('PUT /admin/weekly-cocktail', () => { + it('returns 401 when x-admin-secret is wrong', async () => { + const res = await request(app) + .put('/admin/weekly-cocktail') + .set('x-admin-secret', 'wrong-secret') + .send({ name: 'Test', decks: ['b_green'] }) + expect(res.status).toBe(401) + }) + + it('returns 400 invalid_input for too many decks (4)', async () => { + const res = await request(app) + .put('/admin/weekly-cocktail') + .set('x-admin-secret', ADMIN_SECRET) + .send({ + name: 'Test', + decks: ['b_green', 'b_black', 'b_mp_orange', 'b_red'], + }) + + expect(res.status).toBe(400) + }) + + it('returns 400 invalid_input for a non-eligible deck', async () => { + const res = await request(app) + .put('/admin/weekly-cocktail') + .set('x-admin-secret', ADMIN_SECRET) + .send({ name: 'Test', decks: ['b_mp_cocktail'] }) + + expect(res.status).toBe(400) + }) + }) +}) diff --git a/apps/server/src/tests/services/draft-contract.test.ts b/apps/server/src/tests/services/draft-contract.test.ts new file mode 100644 index 00000000..f14060f7 --- /dev/null +++ b/apps/server/src/tests/services/draft-contract.test.ts @@ -0,0 +1,84 @@ +// CONTRACT: this pool shape is consumed by the Lua client -- keep in sync with +// BalatroMultiplayer/tests/test_pool_contract.lua +// +// Canonical wire shape (exact JSON issueDraftPool returns): an array of items -- +// plain item: { "key": , "stake": } (no other fields) +// cocktail item: { "key": "b_mp_cocktail", "stake": , +// "decks": [, ...], "name": } +// +// e.g. [ +// { "key": "b_red", "stake": 1 }, +// { "key": "b_mp_cocktail", "stake": 3, "decks": ["b_green", "b_black", "b_mp_orange"], "name": "Casjb" } +// ] + +import { describe, expect, it } from 'vitest' +import { DECK } from '../../features/draft/draft-constants.js' +import { getDraftPolicy } from '../../features/draft/draft-policy.js' +import { + type DraftTuple, + type Rng, + generateDraftPool, +} from '../../features/draft/generate-draft-pool.js' +import { getWeeklyCocktail } from '../../features/draft/weekly-cocktail.js' + +// Deterministic PRNG (mulberry32), matching draft.test.ts's convention. +function mulberry32(seed: number): Rng { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Mirrors the enrichment issueDraftPool performs (draft.service.ts) exactly, so +// the pool asserted here is what the endpoint actually returns. +function issuePoolLikeTheService(seed: number): DraftTuple[] { + const policy = getDraftPolicy('MultiplayerPvP', 'ranked:pvp_standard') + if (!policy) throw new Error('ranked pvp policy missing') + const pool = generateDraftPool(policy, mulberry32(seed)) + const weekly = getWeeklyCocktail() + for (const tuple of pool) { + if (tuple.key === DECK.COCKTAIL) { + tuple.decks = [...weekly.decks] + tuple.name = weekly.name + } + } + return pool +} + +describe('draft pool wire contract', () => { + it('every item is key+stake only, or key+stake+decks+name for the cocktail tuple', () => { + // Sweep seeds so both plain items and a cocktail item are exercised. + let sawCocktail = false + for (let seed = 1; seed <= 60; seed++) { + const pool = issuePoolLikeTheService(seed) + for (const item of pool) { + expect(typeof item.key).toBe('string') + expect(item.key.length).toBeGreaterThan(0) + expect(Number.isInteger(item.stake)).toBe(true) + + if (item.key === DECK.COCKTAIL) { + sawCocktail = true + expect(Array.isArray(item.decks)).toBe(true) + expect(item.decks!.every((d) => typeof d === 'string')).toBe(true) + expect(typeof item.name).toBe('string') + } else { + expect(item.decks).toBeUndefined() + expect(item.name).toBeUndefined() + } + } + } + expect(sawCocktail).toBe(true) + }) + + it('survives a real wire round-trip (JSON.parse(JSON.stringify(pool))) unchanged', () => { + for (let seed = 1; seed <= 10; seed++) { + const pool = issuePoolLikeTheService(seed) + const roundTripped = JSON.parse(JSON.stringify(pool)) + expect(roundTripped).toEqual(pool) + } + }) +}) diff --git a/apps/server/src/tests/services/draft-request.test.ts b/apps/server/src/tests/services/draft-request.test.ts new file mode 100644 index 00000000..6b303718 --- /dev/null +++ b/apps/server/src/tests/services/draft-request.test.ts @@ -0,0 +1,43 @@ +// Draft HTTP-input parser tests: pure parse functions, no req/res/service -- +// one happy path + one rejection per validation rule. + +import { describe, expect, it } from 'vitest' +import { parseMatchIdParam } from '../../features/draft/draft.request.js' +import { ValidationError } from '../../shared/utils/errors.js' + +function expectRejects(fn: () => unknown): void { + try { + fn() + throw new Error('expected fn to throw') + } catch (err) { + expect(err).toBeInstanceOf(ValidationError) + expect((err as ValidationError).statusCode).toBe(400) + } +} + +describe('parseMatchIdParam', () => { + it('accepts a normal matchId', () => { + expect(parseMatchIdParam({ matchId: 'm1' })).toBe('m1') + }) + + it('rejects a missing matchId', () => { + expectRejects(() => parseMatchIdParam({})) + }) + + it('rejects a non-string matchId', () => { + expectRejects(() => parseMatchIdParam({ matchId: 123 })) + }) + + it('rejects a blank (whitespace-only) matchId', () => { + expectRejects(() => parseMatchIdParam({ matchId: ' ' })) + }) + + it('rejects a matchId over the max length', () => { + expectRejects(() => parseMatchIdParam({ matchId: 'a'.repeat(129) })) + }) + + it('accepts a matchId exactly at the max length', () => { + const matchId = 'a'.repeat(128) + expect(parseMatchIdParam({ matchId })).toBe(matchId) + }) +}) diff --git a/apps/server/src/tests/services/draft.test.ts b/apps/server/src/tests/services/draft.test.ts new file mode 100644 index 00000000..d828b69d --- /dev/null +++ b/apps/server/src/tests/services/draft.test.ts @@ -0,0 +1,577 @@ +// Draft feature tests: generator invariants (property-style over seeded PRNG -- +// no fast-check dep because the lockfile is in flight) and service idempotency. + +import { beforeEach, describe, expect, it } from 'vitest' +import { DECK, STAKE } from '../../features/draft/draft-constants.js' +import { + ForbiddenError, + NotFoundError, + ValidationError, +} from '../../shared/utils/errors.js' +import { + COCKTAIL_ELIGIBLE_DECKS, + type DraftPolicy, + getDraftPolicy, +} from '../../features/draft/draft-policy.js' +import { InMemoryDraftRepository } from '../../features/draft/draft.repository.js' +import { + type DraftMatchInfo, + makeDraftService, +} from '../../features/draft/draft.service.js' +import { + type Rng, + generateDraftPool, +} from '../../features/draft/generate-draft-pool.js' + +// Deterministic PRNG (mulberry32) so every "property" run is reproducible. +function mulberry32(seed: number): Rng { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const RANKED = getDraftPolicy( + 'MultiplayerPvP', + 'ranked:pvp_standard', +) as DraftPolicy + +describe('draft policy seeds', () => { + it('covers ranked and casual pvp queues (mod-wide default), not other mods', () => { + for (const mode of [ + 'pvp_standard', + 'pvp_expanded', + 'pvp_vanilla', + 'pvp_smallworld', + ]) { + expect(getDraftPolicy('MultiplayerPvP', `ranked:${mode}`)).toBeDefined() + expect(getDraftPolicy('MultiplayerPvP', mode)).toBeDefined() + } + expect( + getDraftPolicy('MultiplayerSPDRN', 'white_stake_triple'), + ).toBeUndefined() + }) + + it('keeps the cocktail-only decks out of the policy entirely -- they only exist in the cocktail-eligibility allowlist', () => { + const byKey = new Map(RANKED.decks.map((d) => [d.key, d])) + for (const key of [ + DECK.INDIGO, + DECK.GRADIENT, + DECK.ORACLE, + DECK.HEIDELBERG, + DECK.ECHO, + ]) { + expect(byKey.has(key)).toBe(false) + } + expect(byKey.get(DECK.COCKTAIL)?.draftWeight).toBe(1) + }) + + it('COCKTAIL_ELIGIBLE_DECKS is the single explicit allowlist: every deck except the cocktail itself', () => { + const eligible = new Set(COCKTAIL_ELIGIBLE_DECKS) + expect(eligible.size).toBe(COCKTAIL_ELIGIBLE_DECKS.length) + for (const key of Object.values(DECK)) { + if (key === DECK.COCKTAIL) { + expect(eligible.has(key)).toBe(false) + } else { + expect(eligible.has(key)).toBe(true) + } + } + }) +}) + +describe('generateDraftPool invariants (300 seeded runs)', () => { + const draftable = new Set( + RANKED.decks.filter((d) => d.draftWeight > 0).map((d) => d.key), + ) + const stakeIds = new Set(RANKED.stakes.map((s) => s.id)) + + it('holds every Botlatro invariant on every run', () => { + for (let seed = 1; seed <= 300; seed++) { + const pool = generateDraftPool(RANKED, mulberry32(seed)) + + expect(pool).toHaveLength(RANKED.poolSize) + + const stakeCounts = new Map() + const deckCounts = new Map() + const pairs = new Set() + for (const t of pool) { + expect(draftable.has(t.key)).toBe(true) + expect(stakeIds.has(t.stake)).toBe(true) + expect(pairs.has(`${t.key}${t.stake}`)).toBe(false) + pairs.add(`${t.key}${t.stake}`) + stakeCounts.set(t.stake, (stakeCounts.get(t.stake) ?? 0) + 1) + deckCounts.set(t.key, (deckCounts.get(t.key) ?? 0) + 1) + } + for (const count of stakeCounts.values()) + expect(count).toBeLessThanOrEqual(RANKED.maxPerStake) + for (const count of deckCounts.values()) + expect(count).toBeLessThanOrEqual(RANKED.maxPerDeck) + + // White-stake guarantee + expect( + pool.some((t) => t.stake === RANKED.guaranteedStakes[0].stakeId), + ).toBe(true) + } + }) + + it('is deterministic for the same seed', () => { + expect(generateDraftPool(RANKED, mulberry32(42))).toEqual( + generateDraftPool(RANKED, mulberry32(42)), + ) + }) + + it('never emits weight-0 decks even under an adversarial rng', () => { + const excluded = new Set([ + DECK.INDIGO, + DECK.GRADIENT, + DECK.ORACLE, + DECK.HEIDELBERG, + DECK.ECHO, + ]) + for (const rngValue of [0, 0.5, 0.9999]) { + const pool = generateDraftPool(RANKED, () => rngValue) + for (const t of pool) expect(excluded.has(t.key)).toBe(false) + } + }) + + it('throws up front on infeasible configs', () => { + const bad: DraftPolicy = { ...RANKED, poolSize: 99 } + expect(() => generateDraftPool(bad, mulberry32(1))).toThrow(/infeasible/) + }) + + it('a weight-0 guaranteed stake degrades gracefully; overflowing mins are rejected', () => { + const zeroWhite: DraftPolicy = { + ...RANKED, + stakes: RANKED.stakes.map((st) => + st.id === STAKE.WHITE ? { ...st, weight: 0 } : { ...st }, + ), + } + expect(generateDraftPool(zeroWhite, mulberry32(1))).toHaveLength( + RANKED.poolSize, + ) + // A single min above the per-stake cap is rejected... + expect(() => + generateDraftPool( + { ...RANKED, guaranteedStakes: [{ stakeId: STAKE.WHITE, min: 99 }] }, + mulberry32(1), + ), + ).toThrow(/exceeds per-stake cap/) + // ...and minimums that together overflow the pool (each within cap) too. + const overflow: DraftPolicy = { + ...RANKED, + guaranteedStakes: [ + { stakeId: STAKE.WHITE, min: 4 }, + { stakeId: STAKE.GREEN, min: 4 }, + { stakeId: STAKE.BLACK, min: 4 }, + ], + } + expect(() => generateDraftPool(overflow, mulberry32(1))).toThrow( + /guaranteed minimums exceed/, + ) + }) + + it('never emits a stake-less tuple on tight policies: retries past a greedy dead-end to a valid pool, never throws', () => { + // This exact shape passes the aggregate feasibility checks but the greedy + // ranked fill (matroid intersection over per-stake/per-deck caps) can + // dead-end short of poolSize on some rankings even though a valid + // selection always exists (the deck x stake graph is complete). The + // generator retries with a fresh ranking on a dead-end, so every seed + // must succeed -- never throw, never emit a corrupt/stake-less tuple. + const tight: DraftPolicy = { + poolSize: 6, + maxPerStake: 2, + maxPerDeck: 2, + guaranteedStakes: [], + decks: [ + { key: 'a', draftWeight: 1 }, + { key: 'b', draftWeight: 1 }, + { key: 'c', draftWeight: 1 }, + ], + stakes: [ + { id: STAKE.WHITE, weight: 1 }, + { id: STAKE.GREEN, weight: 1 }, + { id: STAKE.BLACK, weight: 1 }, + ], + } + for (let seed = 1; seed <= 300; seed++) { + const pool = generateDraftPool(tight, mulberry32(seed)) + expect(pool).toHaveLength(6) + for (const t of pool) { + expect(Number.isInteger(t.stake)).toBe(true) + expect(typeof t.key).toBe('string') + } + } + }) + + it('policy rows are independent copies, not aliases', () => { + const a = getDraftPolicy( + 'MultiplayerPvP', + 'ranked:pvp_standard', + ) as DraftPolicy + const b = getDraftPolicy('MultiplayerPvP', 'pvp_standard') as DraftPolicy + expect(a).not.toBe(b) + expect(a.decks[0]).not.toBe(b.decks[0]) + }) + + it('weights deck occurrence proportionally: a heavily-weighted deck is drafted far more than a weight-1 deck', () => { + // Two decks with plenty of stake room so both are always eligible; + // one deck weighted 10x. Efraimidis-Spirakis picks larger-weight items + // with priority rng()^(1/w), which skews toward 1 (picked earlier) as w + // grows, so the heavy deck should be drafted far more often across seeds. + const heavy: DraftPolicy = { + poolSize: 4, + maxPerStake: 4, + maxPerDeck: 4, + guaranteedStakes: [], + decks: [ + { key: 'heavy', draftWeight: 10 }, + { key: 'light', draftWeight: 1 }, + ], + stakes: [ + { id: STAKE.WHITE, weight: 1 }, + { id: STAKE.GREEN, weight: 1 }, + { id: STAKE.BLACK, weight: 1 }, + { id: STAKE.PURPLE, weight: 1 }, + ], + } + let heavyCount = 0 + let lightCount = 0 + for (let seed = 1; seed <= 500; seed++) { + const pool = generateDraftPool(heavy, mulberry32(seed)) + for (const t of pool) { + if (t.key === 'heavy') heavyCount++ + else lightCount++ + } + } + expect(heavyCount).toBeGreaterThan(lightCount * 2) + }) + + it('draws each eligible pair with roughly even frequency when all weights are equal', () => { + const uniform: DraftPolicy = { + poolSize: 2, + maxPerStake: 2, + maxPerDeck: 2, + guaranteedStakes: [], + decks: [ + { key: 'a', draftWeight: 1 }, + { key: 'b', draftWeight: 1 }, + ], + stakes: [ + { id: STAKE.WHITE, weight: 1 }, + { id: STAKE.GREEN, weight: 1 }, + ], + } + const counts = new Map() + const runs = 800 + for (let seed = 1; seed <= runs; seed++) { + const pool = generateDraftPool(uniform, mulberry32(seed)) + for (const t of pool) { + const pairKey = `${t.key}@${t.stake}` + counts.set(pairKey, (counts.get(pairKey) ?? 0) + 1) + } + } + // 4 distinct pairs, poolSize 2 -> 2*runs tuple-slots total, ~evenly split. + const totalSlots = runs * uniform.poolSize + const expectedPerPair = totalSlots / 4 + for (const count of counts.values()) { + expect(count).toBeGreaterThan(expectedPerPair * 0.5) + expect(count).toBeLessThan(expectedPerPair * 1.5) + } + }) +}) + +describe('draft service', () => { + const MATCH: DraftMatchInfo = { + matchId: 'm1', + modId: 'MultiplayerPvP', + gameMode: 'ranked:pvp_standard', + playerIds: ['host', 'guest'], + } + let repo: InMemoryDraftRepository + let service: ReturnType + + beforeEach(() => { + repo = new InMemoryDraftRepository() + service = makeDraftService({ + repo, + getMatch: (id) => (id === 'm1' ? MATCH : undefined), + getPolicy: getDraftPolicy, + rng: mulberry32(1), + log: { log: () => {} }, + }) + }) + + it('issues a pool once and returns the identical pool on retries', async () => { + const first = await service.issueDraftPool('m1', 'host') + const second = await service.issueDraftPool('m1', 'guest') + expect(first.reused).toBe(false) + expect(second.reused).toBe(true) + expect(second.pool).toEqual(first.pool) + }) + + it('rolls exactly once under concurrent first calls (single-flight)', async () => { + const [a, b] = await Promise.all([ + service.issueDraftPool('m1', 'host'), + service.issueDraftPool('m1', 'guest'), + ]) + expect(a.pool).toEqual(b.pool) + expect([a.reused, b.reused].filter((r) => r === false)).toHaveLength(1) + expect(await repo.getPool('m1')).toEqual(a.pool) + }) + + it('attaches the weekly composition to any cocktail tuple in the pool', async () => { + // The ranked policy makes b_mp_cocktail draftable, so across a few rolls + // it will appear; when it does, the pool item must carry decks + a bare + // name (the client appends its own "Cocktail" suffix). + let sawCocktail = false + for (let i = 0; i < 40 && !sawCocktail; i++) { + const localRepo = new InMemoryDraftRepository() + const svc = makeDraftService({ + repo: localRepo, + getMatch: () => MATCH, + getPolicy: getDraftPolicy, + }) + const { pool } = await svc.issueDraftPool(`mm${i}`, 'host') + for (const t of pool) { + if (t.key === 'b_mp_cocktail') { + sawCocktail = true + expect(Array.isArray(t.decks)).toBe(true) + expect(t.decks!.length).toBeGreaterThan(0) + expect(typeof t.name).toBe('string') + // bare name, no "Cocktail" suffix (client owns the wording) + expect(t.name).not.toMatch(/cocktail/i) + } else { + expect(t.decks).toBeUndefined() + } + } + } + expect(sawCocktail).toBe(true) + }) + + it('rejects non-participants and unknown matches', async () => { + await expect(service.issueDraftPool('m1', 'stranger')).rejects.toThrow( + /participant/, + ) + await expect( + service.issueDraftPool('m1', 'stranger'), + ).rejects.toBeInstanceOf(ForbiddenError) + + await expect(service.issueDraftPool('nope', 'host')).rejects.toThrow( + /not found/i, + ) + await expect(service.issueDraftPool('nope', 'host')).rejects.toBeInstanceOf( + NotFoundError, + ) + }) + + it('404s when the queue has no policy (client falls back)', async () => { + const unconfigured = makeDraftService({ + repo, + getMatch: () => ({ + ...MATCH, + modId: 'MultiplayerSPDRN', + gameMode: 'white_stake_triple', + }), + getPolicy: getDraftPolicy, + }) + await expect(unconfigured.issueDraftPool('m1', 'host')).rejects.toThrow( + /No draft policy/, + ) + await expect( + unconfigured.issueDraftPool('m1', 'host'), + ).rejects.toBeInstanceOf(NotFoundError) + }) +}) + +describe('InMemoryDraftRepository TTL eviction', () => { + it('evicts pools older than the TTL on writes, keeping fresh entries', async () => { + let now = 0 + const repo = new InMemoryDraftRepository({ ttlMs: 1_000, now: () => now }) + await repo.savePool('old', [{ key: 'b_red', stake: 1 }]) + now = 500 + await repo.savePool('young', [{ key: 'b_blue', stake: 1 }]) + now = 1_001 + await repo.savePool('fresh', [{ key: 'b_green', stake: 1 }]) + expect(await repo.getPool('old')).toBeUndefined() + expect(await repo.getPool('young')).toBeDefined() + expect(await repo.getPool('fresh')).toBeDefined() + }) + + it('refreshes the TTL on every write to the same match', async () => { + let now = 0 + const repo = new InMemoryDraftRepository({ ttlMs: 1_000, now: () => now }) + await repo.savePool('m', [{ key: 'b_red', stake: 1 }]) + now = 900 + await repo.savePool('m', [{ key: 'b_red', stake: 1 }]) + now = 1_800 + await repo.savePool('other', [{ key: 'b_blue', stake: 1 }]) + expect(await repo.getPool('m')).toBeDefined() + }) + + it('never serves an expired entry, even before any sweep runs', async () => { + let now = 0 + const repo = new InMemoryDraftRepository({ ttlMs: 1_000, now: () => now }) + await repo.savePool('m', [{ key: 'b_red', stake: 1 }]) + now = 2_000 + expect(await repo.getPool('m')).toBeUndefined() + }) +}) + +describe('weekly cocktail config', () => { + it('every weekly deck is in COCKTAIL_ELIGIBLE_DECKS', async () => { + const { getWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + const weekly = getWeeklyCocktail() + expect(weekly.decks.length).toBeGreaterThan(0) + const eligible = new Set(COCKTAIL_ELIGIBLE_DECKS) + for (const key of weekly.decks) { + expect( + eligible.has(key as (typeof COCKTAIL_ELIGIBLE_DECKS)[number]), + ).toBe(true) + } + }) + + it('accepts a rotation whose decks are all cocktail-eligible', async () => { + const { validateWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + const validated = validateWeeklyCocktail({ + name: 'AcceptanceCheck', + decks: [DECK.HEIDELBERG, DECK.ORACLE, DECK.INDIGO], + }) + expect(validated.decks).toEqual([DECK.HEIDELBERG, DECK.ORACLE, DECK.INDIGO]) + }) + + it('rejects a rotation containing the cocktail deck itself (the only ineligible deck)', async () => { + const { validateWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + expect(() => + validateWeeklyCocktail({ name: 'X', decks: [DECK.COCKTAIL] }), + ).toThrow(/not cocktail-eligible/) + expect(() => + validateWeeklyCocktail({ name: 'X', decks: [DECK.COCKTAIL] }), + ).toThrow(ValidationError) + }) + + it('accepts Echo -- now cocktail-eligible', async () => { + const { validateWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + expect( + validateWeeklyCocktail({ name: 'X', decks: [DECK.ECHO] }).decks, + ).toEqual([DECK.ECHO]) + }) + + it('rotation validates; bad rotations throw and change nothing', async () => { + const { validateWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + + expect(() => + validateWeeklyCocktail({ name: '', decks: [DECK.GREEN] }), + ).toThrow(/name/) + expect(() => + validateWeeklyCocktail({ name: '', decks: [DECK.GREEN] }), + ).toThrow(ValidationError) + expect(() => validateWeeklyCocktail({ name: 'X', decks: [] })).toThrow( + /1-3/, + ) + expect(() => + validateWeeklyCocktail({ + name: 'X', + decks: [DECK.GREEN, DECK.BLACK, DECK.RED, DECK.BLUE], + }), + ).toThrow(/1-3/) + expect(() => + validateWeeklyCocktail({ name: 'X', decks: [DECK.COCKTAIL] }), + ).toThrow(/not cocktail-eligible/) + expect(() => + validateWeeklyCocktail({ name: 'X', decks: [DECK.GREEN, DECK.GREEN] }), + ).toThrow(/distinct/) + expect(() => + validateWeeklyCocktail({ name: 'X', decks: [DECK.GREEN, DECK.GREEN] }), + ).toThrow(ValidationError) + + const rotated = validateWeeklyCocktail({ + name: 'NextWeek', + decks: [DECK.GRADIENT, DECK.RED], + }) + expect(rotated.name).toBe('NextWeek') + expect(rotated.decks).toEqual([DECK.GRADIENT, DECK.RED]) + }) + + it('loadWeeklyCocktail defaults to SEED when no DB row exists', async () => { + const { getWeeklyCocktail, loadWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + const fakeFetch = async () => null + await loadWeeklyCocktail(fakeFetch) + expect(getWeeklyCocktail()).toEqual({ + name: 'Casjb', + decks: ['b_green', 'b_black', 'b_mp_orange'], + }) + }) + + it('loadWeeklyCocktail adopts the DB row when one exists', async () => { + const { getWeeklyCocktail, loadWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + const dbRow = { name: 'FromDb', decks: [DECK.GRADIENT, DECK.RED] } + const fakeFetch = async () => dbRow + await loadWeeklyCocktail(fakeFetch) + expect(getWeeklyCocktail()).toEqual(dbRow) + + // restore the seed for other tests + await loadWeeklyCocktail(async () => null) + }) + + it('loadWeeklyCocktail falls back to SEED when the DB fetch throws (never crashes boot)', async () => { + const { getWeeklyCocktail, loadWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + const throwingFetch = async () => { + throw new Error('relation "weekly_cocktail" does not exist') + } + await loadWeeklyCocktail(throwingFetch) + expect(getWeeklyCocktail()).toEqual({ + name: 'Casjb', + decks: ['b_green', 'b_black', 'b_mp_orange'], + }) + }) + + it('setWeeklyCocktail validates, updates the cache, and writes through save', async () => { + const { getWeeklyCocktail, setWeeklyCocktail } = await import( + '../../features/draft/weekly-cocktail.js' + ) + const before = getWeeklyCocktail() + const saved: Array<{ name: string; decks: string[] }> = [] + const fakeSave = async (wc: { name: string; decks: string[] }) => { + saved.push(wc) + } + + const rotated = await setWeeklyCocktail( + { name: 'NextWeek', decks: [DECK.GRADIENT, DECK.RED] }, + fakeSave, + ) + expect(rotated).toEqual({ + name: 'NextWeek', + decks: [DECK.GRADIENT, DECK.RED], + }) + expect(saved).toEqual([ + { name: 'NextWeek', decks: [DECK.GRADIENT, DECK.RED] }, + ]) + expect(getWeeklyCocktail()).toEqual(rotated) + + // restore the seed for other tests + await setWeeklyCocktail( + { name: before.name, decks: before.decks }, + fakeSave, + ) + }) +}) diff --git a/apps/server/src/tests/utils/errors.test.ts b/apps/server/src/tests/utils/errors.test.ts index a31e9ec0..5356ea89 100644 --- a/apps/server/src/tests/utils/errors.test.ts +++ b/apps/server/src/tests/utils/errors.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { AppError } from '../../shared/utils/errors.js' +import { + AppError, + ForbiddenError, + NotFoundError, + ValidationError, +} from '../../shared/utils/errors.js' describe('AppError', () => { it('creates an error with message and default status 500', () => { @@ -20,3 +25,30 @@ describe('AppError', () => { expect(err).toBeInstanceOf(AppError) }) }) + +describe('ValidationError', () => { + it('defaults to statusCode 400', () => { + const err = new ValidationError('bad input') + expect(err.statusCode).toBe(400) + expect(err.name).toBe('ValidationError') + expect(err).toBeInstanceOf(AppError) + }) +}) + +describe('NotFoundError', () => { + it('defaults to statusCode 404', () => { + const err = new NotFoundError('missing') + expect(err.statusCode).toBe(404) + expect(err.name).toBe('NotFoundError') + expect(err).toBeInstanceOf(AppError) + }) +}) + +describe('ForbiddenError', () => { + it('defaults to statusCode 403', () => { + const err = new ForbiddenError('nope') + expect(err.statusCode).toBe(403) + expect(err.name).toBe('ForbiddenError') + expect(err).toBeInstanceOf(AppError) + }) +})