1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:38:13 -04:00
parent 67971dd7e6
commit a2ab066645
5 changed files with 201 additions and 6 deletions

View file

@ -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.

View file

@ -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=="],

View file

@ -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",

View file

@ -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}"
}

View file

@ -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<string, unknown> {
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,