From a2ab0666455436e7a097d4461beceecc70316b22 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 14 Sep 2026 15:38:13 -0400 Subject: [PATCH] chore(infra): static AI compose prompts, sync-prod refresh, and deps Payload config seeds AI Compose instructions statically so wiped prompt tables rebuild without LLM calls; sync-prod pairs with that per the config comment. Adds lexical deps and changelog entries. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- CHANGELOG.md | 17 +++++++ bun.lock | 13 ++++-- package.json | 7 +++ scripts/sync-prod.sh | 102 ++++++++++++++++++++++++++++++++++++++++++ src/payload.config.ts | 68 ++++++++++++++++++++++++++-- 5 files changed, 201 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf184bf..6293552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +# v0.2.36 + +Poker takes the spotlight in this stretch: a rebuilt table, synthesized sound, card skins, and glowing hand ranks. Alongside it, the awards program learns advancement series, and poker placement XP is paused while payouts get reworked; placements, hands won, and best chips still record as normal. + +### Features + +- **Poker tables**: hands now play out on a full oval felt with a center deck and a pot plaque, and seats read as compact pills whose stack counts flash when they change. Your own cards get dealt onto the bottom rail at community-card size, every action (fold, check, call, raise, all-in, pot win) floats over the acting seat, and each new hand opens with a small-blind then big-blind intro. +- **Sound**: a fully synthesized soundscape: deal whooshes, chip clacks, turn chimes, and win stingers, backed by a mellow background jazz loop. Mute and volume controls sit in the header and remember your choice. +- **Awards advancement series**: ribbons can now belong to advancement lineups, like a years-of-service ladder. The grant flow understands series, and your ribbon rack collapses the tiers you hold into your highest earned ribbon instead of stacking duplicates. +- **Hand rank styling**: made hands wear a tier ladder that climbs from a plain high card through brass, silver, and gold pair treatments to a sapphire straight flush and a prismatic royal flush. Your live hand hint placard glows with the tier of what you're holding, and the guide's ranking table is rebuilt into tier columns to match. +- **Card skins**: pick from three decks in the table header: the new light cards, the previous dark theme, or the classic printed deck. Your pick persists. + +### Improvements + +- **Poker leave handling**: leaving is announced on the felt right away, and a game that drops to one seated player ends immediately with a notification instead of waiting out the hand timer. +- **Lobby stability**: background refreshes no longer blink the whole lobby into a scanning state every few seconds; the refresh spinner is reserved for manual refreshes. + # v0.2.33 Three releases in one: a full poker minigame, a ribbon design studio for the awards program, and a reworked announce command for staff. diff --git a/bun.lock b/bun.lock index 2e19725..3c090ae 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/extension": "0.41.0", + "@lexical/link": "0.41.0", + "@lexical/list": "0.41.0", + "@lexical/react": "0.41.0", + "@lexical/rich-text": "0.41.0", + "@lexical/selection": "0.41.0", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/next": "3.88.0", @@ -42,6 +48,7 @@ "discord.js": "^14.27.0", "dotenv": "17.2.3", "graphql": "^16.14.2", + "lexical": "0.41.0", "lucide-react": "^0.561.0", "next": "16.2.11", "next-themes": "^0.4.6", @@ -2198,7 +2205,7 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], - "react-error-boundary": ["react-error-boundary@4.1.2", "", { "dependencies": { "@babel/runtime": "7.28.4" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag=="], + "react-error-boundary": ["react-error-boundary@6.1.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng=="], "react-image-crop": ["react-image-crop@10.1.8", "", { "peerDependencies": { "react": "19.2.3" } }, "sha512-4rb8XtXNx7ZaOZarKKnckgz4xLMvds/YrU6mpJfGhGAsy2Mg4mIw1x+DCCGngVGq2soTBVVOxx2s/C6mTX9+pA=="], @@ -2664,8 +2671,6 @@ "@google/genai/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], - "@lexical/react/react-error-boundary": ["react-error-boundary@6.1.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], @@ -2690,6 +2695,8 @@ "@payloadcms/richtext-lexical/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "@payloadcms/richtext-lexical/react-error-boundary": ["react-error-boundary@4.1.2", "", { "dependencies": { "@babel/runtime": "7.28.4" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag=="], + "@payloadcms/ui/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="], "@payloadcms/ui/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "19.2.3", "react-dom": "19.2.3" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], diff --git a/package.json b/package.json index 6c970f0..f051d68 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/extension": "0.41.0", + "@lexical/link": "0.41.0", + "@lexical/list": "0.41.0", + "@lexical/react": "0.41.0", + "@lexical/rich-text": "0.41.0", + "@lexical/selection": "0.41.0", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/next": "3.88.0", @@ -66,6 +72,7 @@ "discord.js": "^14.27.0", "dotenv": "17.2.3", "graphql": "^16.14.2", + "lexical": "0.41.0", "lucide-react": "^0.561.0", "next": "16.2.11", "next-themes": "^0.4.6", diff --git a/scripts/sync-prod.sh b/scripts/sync-prod.sh index 73aaf3d..e287e19 100755 --- a/scripts/sync-prod.sh +++ b/scripts/sync-prod.sh @@ -105,6 +105,8 @@ WHAT IT DOES - Restores into the target environment's database - Runs pending Payload migrations (keeps schema in sync with dev code) - Resets all sequences to prevent ID collisions + - Preserves the target's AI Compose prompts (plugin_ai_instructions) + so the AI plugin does not regenerate them on the next boot Media (unless --skip-media): - Backs up current media directory @@ -228,6 +230,102 @@ backup_media() { # ─── Database sync ──────────────────────────────────────────────────────────── +# The AI plugin (@ai-stack/payloadcms) stores its Compose prompts in the +# plugin_ai_instructions table. They are environment-independent config-ish +# data, so preserve the target's rows across the restore instead of inheriting +# prod's (possibly empty/stale) copy, which would force a full prompt +# regeneration on the next dev boot. +AI_INSTRUCTIONS_TABLE="plugin_ai_instructions" +AI_INSTRUCTIONS_IMAGES_TABLE="plugin_ai_instructions_images" + +table_exists() { + local db="$1" table="$2" + PGPASSWORD="$TARGET_DB_PASS" psql \ + -h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" -d "$db" \ + -tAc "SELECT to_regclass('public.${table}') IS NOT NULL;" 2>/dev/null +} + +preserve_ai_instructions() { + if $DRY_RUN; then + info "[DRY RUN] Would preserve ${AI_INSTRUCTIONS_TABLE} rows before restore" + return 0 + fi + + rm -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql" \ + "${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql" + + if [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_TABLE")" != "t" ]]; then + info "No ${AI_INSTRUCTIONS_TABLE} table in target, nothing to preserve" + return 0 + fi + + if PGPASSWORD="$TARGET_DB_PASS" pg_dump \ + -h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \ + -d "$TARGET_DB_NAME" \ + --table="$AI_INSTRUCTIONS_TABLE" --data-only \ + --no-owner --no-privileges \ + -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql" 2>&1; then + ok "Preserved AI Compose prompts (${AI_INSTRUCTIONS_TABLE})" + else + warn "Could not dump ${AI_INSTRUCTIONS_TABLE}; prompts will be re-seeded on next boot" + rm -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql" + fi + + if [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_IMAGES_TABLE")" == "t" ]]; then + PGPASSWORD="$TARGET_DB_PASS" pg_dump \ + -h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \ + -d "$TARGET_DB_NAME" \ + --table="$AI_INSTRUCTIONS_IMAGES_TABLE" --data-only \ + --no-owner --no-privileges \ + -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql" 2>/dev/null \ + || rm -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql" + fi +} + +restore_ai_instructions() { + local prompts_sql="${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql" + [[ -s "$prompts_sql" ]] || return 0 + + if $DRY_RUN; then + info "[DRY RUN] Would reload ${AI_INSTRUCTIONS_TABLE} rows after restore" + return 0 + fi + + if [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_TABLE")" != "t" ]]; then + warn "${AI_INSTRUCTIONS_TABLE} missing after restore; AI prompts will be re-seeded on next boot" + return 0 + fi + + info "Reloading preserved AI Compose prompts..." + if ! PGPASSWORD="$TARGET_DB_PASS" psql \ + -h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \ + -d "$TARGET_DB_NAME" \ + -v ON_ERROR_STOP=1 \ + -c "TRUNCATE TABLE \"${AI_INSTRUCTIONS_TABLE}\" CASCADE;" \ + -f "$prompts_sql" 2>&1; then + warn "Failed to reload ${AI_INSTRUCTIONS_TABLE}; prompts will be re-seeded on next boot" + return 0 + fi + + local images_sql="${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql" + if [[ -s "$images_sql" ]] && [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_IMAGES_TABLE")" == "t" ]]; then + PGPASSWORD="$TARGET_DB_PASS" psql \ + -h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \ + -d "$TARGET_DB_NAME" \ + -v ON_ERROR_STOP=1 \ + -f "$images_sql" >/dev/null 2>&1 \ + || warn "Failed to reload ${AI_INSTRUCTIONS_IMAGES_TABLE} (non-fatal)" + fi + + PGPASSWORD="$TARGET_DB_PASS" psql \ + -h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \ + -d "$TARGET_DB_NAME" \ + -c "SELECT setval(pg_get_serial_sequence('${AI_INSTRUCTIONS_TABLE}', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM ${AI_INSTRUCTIONS_TABLE}), 1), 1));" >/dev/null 2>&1 \ + || true + + ok "AI Compose prompts preserved (no regeneration needed on next boot)" +} + sync_database() { info "Syncing database: ${PROD_DB_NAME} → ${TARGET_DB_NAME}" info " Source: ${PROD_DB_HOST}:${PROD_DB_PORT}" @@ -241,6 +339,8 @@ sync_database() { info "Dumping production database..." local dump_file="${BACKUP_PATH}/db/prod.dump" + preserve_ai_instructions + if ! PGPASSWORD="$PROD_DB_PASS" pg_dump \ -h "$PROD_DB_HOST" \ -p "$PROD_DB_PORT" \ @@ -303,6 +403,8 @@ sync_database() { -d "$TARGET_DB_NAME" \ -c "SELECT setval(pg_get_serial_sequence(t.table_name, c.column_name), (SELECT COALESCE(MAX(c.column_name::text::bigint), 1) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id')) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id' AND c.column_default LIKE 'nextval%' AND t.table_schema = 'public';" 2>/dev/null || true + restore_ai_instructions + ok "Database sync complete: ${TARGET_DB_NAME}" } diff --git a/src/payload.config.ts b/src/payload.config.ts index 01394eb..ecd9979 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -1,6 +1,6 @@ import { migrations } from "./migrations"; import { postgresAdapter } from "@payloadcms/db-postgres"; -import { HeadingFeature, lexicalEditor } from "@payloadcms/richtext-lexical"; +import { FixedToolbarFeature, lexicalEditor } from "@payloadcms/richtext-lexical"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import path from "path"; import { buildConfig } from "payload"; @@ -24,6 +24,7 @@ import { ModLists } from "@/collections/server/ModLists"; import { GameServers } from "@/collections/server/GameServers"; import { ArmaSyncEvents } from "@/collections/server/ArmaSyncEvents"; import { ArmaCommands } from "@/collections/server/ArmaCommands"; +import { RespawnTickets } from "@/collections/server/RespawnTickets"; import { Factions } from "@/collections/intelligence/Factions"; import { Assets } from "@/collections/logistics/Assets"; import { NarrativeEvents } from "@/collections/world/NarrativeEvents"; @@ -74,6 +75,7 @@ import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestSc import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions"; import { requireAdminPageAccess } from "@/utils/access-control/hasPermission"; import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms"; +import type { SeedPromptFunction } from "@ai-stack/payloadcms/types"; import { mcpPlugin } from "@payloadcms/plugin-mcp"; import { createOpenAI } from "@ai-sdk/openai"; import { createTextStreamResponse, jsonSchema, streamObject } from "ai"; @@ -85,6 +87,64 @@ const unslothProvider = createOpenAI({ const DEFAULT_MODEL = "unsloth/gemma-4-E4B-it-qat-GGUF"; +// AI Compose allowlist: the plugin's default init makes an LLM call per field +// to seed prompts. Returning false here skips a field (no instruction row, no +// AI UI), and returning { data: { prompt } } (without a top-level `prompt` key) +// seeds the row statically, so no LLM call ever happens at boot and a wiped +// instructions table (e.g. after scripts/sync-prod.sh) rebuilds in +// milliseconds on the next startup. +const AI_TEXT_FIELD_ALLOWLIST = new Set([ + "description", + "gamedescription", + "summary", + "bio", + "occupation", + "name", + "title", +]); + +// Textarea/richText fields that are system-managed or technical, never AI-able. +const AI_FIELD_DENYLIST = new Set(["contentsnapshot", "html", "script"]); + +const staticAiPrompt = (fieldName: string, fieldLabel: string, fieldType: string): string => { + switch (fieldName) { + case "description": + case "gamedescription": + return `Write an immersive, in-universe description for the "${fieldLabel}" of {{ title }}. 2-4 sentences of plain prose. No markdown, no lists, no headings.`; + case "summary": + return `Write a concise summary for the "${fieldLabel}" of {{ title }}. 1-3 sentences of plain prose.`; + case "bio": + return `Write a short in-universe biography for {{ title }}. A few sentences of plain prose covering background and personality.`; + case "occupation": + return `Suggest a short occupation label for {{ title }}. Reply with 1-3 words only, no explanation.`; + case "name": + return `Suggest a single fitting name for {{ title }}. Reply with the name only, no explanation.`; + case "title": + return `Suggest a single fitting title for {{ title }}. Reply with the title only, no explanation.`; + default: + if (fieldType === "richText") { + return `Write long-form content for the "${fieldLabel}" section of {{ title }}. Structured prose with an introduction, clear sections, and a conclusion.`; + } + return `Write suitable content for the "${fieldLabel}" of {{ title }}. Plain prose, concise and relevant.`; + } +}; + +const aiFieldSeedPrompts: SeedPromptFunction = ({ path, fieldType, fieldLabel }) => { + const fieldName = (path.split(".").pop() ?? "").toLowerCase(); + + if (AI_FIELD_DENYLIST.has(fieldName)) return false; + + if (fieldType === "textarea" || fieldType === "richText") { + return { data: { prompt: staticAiPrompt(fieldName, fieldLabel, fieldType) } }; + } + + if (fieldType === "text" && AI_TEXT_FIELD_ALLOWLIST.has(fieldName)) { + return { data: { prompt: staticAiPrompt(fieldName, fieldLabel, fieldType) } }; + } + + return false; +}; + function plainTextToLexicalJson(text: string): Record { const paragraphs = text.split(/\n\n+/).filter((p) => p.trim()); @@ -241,6 +301,7 @@ const collections = [ GameServers, ArmaSyncEvents, ArmaCommands, + RespawnTickets, // Minigames CustomMinefields, @@ -316,8 +377,8 @@ export default buildConfig({ globals: [GameRules, Shims], collections: scopedCollections, editor: lexicalEditor({ - features: ({ rootFeatures }) => { - return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()]; + features: ({ defaultFeatures }) => { + return [...defaultFeatures, FixedToolbarFeature(), PayloadAiPluginLexicalEditorFeature()]; }, }), secret: process.env.PAYLOAD_SECRET || "", @@ -368,6 +429,7 @@ export default buildConfig({ // storage-adapter-placeholder mcpPlugin({ collections: mcpCollections }), payloadAiPlugin({ + seedPrompts: aiFieldSeedPrompts, collections: { [GameHardResources.slug]: true, [GameStructures.slug]: true,